feat(money): one decimals setting for the dataset, not one per currency
`Settings.digits` replaces `CurrencyDef.digits`. Every amount rounds to it, whatever its currency, and `digitsFor` is gone: the sixteen call sites read the setting, and the domain functions that took a currency table purely to look digits up take a `digits: number` instead. Schema 6. The owner asked for this with the cost in front of them, and the cost is real: a dataset mixing minor units cannot be described any more. Yen at 0 beside kroner at 2 has no correct global value — 2 gives ¥1234.56, an amount yen cannot represent; 0 rounds 49.95 DKK to 50 and drifts the carry from the bank. Nothing in the app warns. docs/specs/2026-09-02-global-decimals-design.md argues it in full, including what re-adding a per-currency override would take, and AGENTS.md §1 now says not to reintroduce one without revisiting the decision. The 5 -> 6 step takes the MAXIMUM of the per-currency digits, not the base currency's. A dataset holding a three-decimal currency already has three-decimal amounts in it, and adopting the base's 2 would truncate the next edit of one; the maximum can only keep more precision, never less. On every dataset this app has written all four currencies record 2, so nothing rounds differently than before. Changing the setting does not rewrite stored amounts. 49.95 stays 49.95 and shows as 50; totals recompute at the new precision. Rewriting each Money is a destructive whole-dataset write, which §1 owes a backup export first, and it is lossy one way. Display turned out to be the hole in the plan. src/ui/format.ts pinned minimumFractionDigits: 2 in a module-level Intl.NumberFormat, so it never read the dataset at all — equally broken under per-currency digits, where a JPY rendered "3,333.00", so a pre-existing gap rather than a regression. Shipping a Settings control that display ignores would look broken at 0 and be lossy at 3, hiding precision the dataset holds. So format.ts takes digits like roundMoney does, useMoneyFormat() binds them for the 64 call sites, and postRowCells takes the formatter as an argument because it is a plain helper, not a component. currencyDigits.test.ts is rewritten rather than deleted. Driving a dataset at ZERO decimals through rounding, both FX directions, both split modes, plan division, the fold and the month view still tells a real implementation from one assuming 2 — it just cannot prove per-currency flow, because there is none. It also asserts that raising the setting to 2 changes what the fold produces. Verified in Chrome, not only in tests: a v5 dataset with digits 2/0/3 written straight into IndexedDB migrates to a setting of 3, the table loses its Decimals column, and editing the control persists schema 6 with digits stripped from each currency and income left at 20000.55. The same month reads 600/206 at zero decimals and 600.000/205.561 at three, so the figures recompute rather than merely reformat. Not verified here: the .ods in a real spreadsheet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ebef56eef4
commit
8d2b2ca881
47 files changed
+1039
-426
No files matched your search
@@ -12,7 +12,7 @@ import { setIncome, deletePurchase, setRuleFrom } from "../../store/actions.ts";
|
||||
import { ruleAt } from "../../domain/allocation.ts";
|
||||
import type { MonthId, Post, Purchase, Rule } from "../../domain/types.ts";
|
||||
import { sliceAmountForMonth } from "../../domain/charges.ts";
|
||||
import { formatMoney, formatSignedMoney } from "../format.ts";
|
||||
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
|
||||
import { PostTable, PostTableLegend } from "../components/PostTable.tsx";
|
||||
import { PurchaseDialog } from "../components/PurchaseDialog.tsx";
|
||||
import { Meter } from "../components/Meter.tsx";
|
||||
@@ -21,6 +21,7 @@ import { allocatedPercentOfIncome, allocationMeterSegments } from "../meterSegme
|
||||
import { groupPurchasesByDate } from "../purchaseGroups.ts";
|
||||
|
||||
export function MonthRoute() {
|
||||
const fmt = useMoneyFormat();
|
||||
const { monthId = "" } = useParams();
|
||||
const dataset = useDataset();
|
||||
const { mutate, error, clearError } = useMutate();
|
||||
@@ -85,7 +86,7 @@ export function MonthRoute() {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<dl className="flex flex-wrap gap-x-10 gap-y-4">
|
||||
<Stat label="Allocated">{formatMoney(view.totalAllocation, base)}</Stat>
|
||||
<Stat label="Allocated">{fmt.money(view.totalAllocation, base)}</Stat>
|
||||
<Stat
|
||||
label="Unallocated"
|
||||
tone={view.unallocated < 0 ? "overspend" : "default"}
|
||||
@@ -95,7 +96,7 @@ export function MonthRoute() {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{formatSignedMoney(view.unallocated, base)}
|
||||
{fmt.signedMoney(view.unallocated, base)}
|
||||
</Stat>
|
||||
</dl>
|
||||
<Meter
|
||||
@@ -108,7 +109,7 @@ export function MonthRoute() {
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-end justify-between gap-x-10 gap-y-2 border-t border-budget-rule pt-3">
|
||||
<dl>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
|
||||
</dl>
|
||||
{/* Rendered in both directions. "No posts overspent" is a real answer
|
||||
to the app's second question, and a line that only appears when
|
||||
@@ -189,6 +190,7 @@ function PurchaseRow({
|
||||
monthId: MonthId;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const fmt = useMoneyFormat();
|
||||
const slice = sliceAmountForMonth(purchase, monthId)!;
|
||||
return (
|
||||
// `-mx-2 px-2` rather than `px-1`: the row's text now starts exactly where
|
||||
@@ -221,7 +223,7 @@ function PurchaseRow({
|
||||
</span>
|
||||
)}
|
||||
<span className="font-money shrink-0 tabular-nums">
|
||||
{formatMoney(slice.amount, slice.currency)}
|
||||
{fmt.money(slice.amount, slice.currency)}
|
||||
</span>
|
||||
|
||||
{/* From `sm:` up: revealed on hover, and held at a fixed width so
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { monthView } from "../../domain/views.ts";
|
||||
import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../../domain/charges.ts";
|
||||
import { addMonths, compareMonths } from "../../domain/months.ts";
|
||||
import { formatMoney, formatSignedMoney } from "../format.ts";
|
||||
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
|
||||
|
||||
export function PostMonthRoute() {
|
||||
const fmt = useMoneyFormat();
|
||||
const { postId = "", monthId = "" } = useParams();
|
||||
const dataset = useDataset();
|
||||
const post = dataset.posts.find((p) => p.id === postId);
|
||||
@@ -20,7 +21,7 @@ export function PostMonthRoute() {
|
||||
monthId,
|
||||
base,
|
||||
dataset.fxRates,
|
||||
dataset.currencies,
|
||||
dataset.settings.digits,
|
||||
);
|
||||
const mine = charges.find((c) => c.postId === postId);
|
||||
return mine ? [{ purchase, amount: mine.amount }] : [];
|
||||
@@ -72,23 +73,23 @@ export function PostMonthRoute() {
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{formatSignedMoney(row?.figures.carriedIn ?? 0, base)}
|
||||
{fmt.signedMoney(row?.figures.carriedIn ?? 0, base)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Allocated</dt>
|
||||
<dd className="font-money">{formatMoney(row?.figures.allocation ?? 0, base)}</dd>
|
||||
<dd className="font-money">{fmt.money(row?.figures.allocation ?? 0, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Spent</dt>
|
||||
<dd className="font-money">{formatMoney(row?.figures.charges ?? 0, base)}</dd>
|
||||
<dd className="font-money">{fmt.money(row?.figures.charges ?? 0, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Remaining</dt>
|
||||
<dd
|
||||
className={`font-money ${(row?.figures.remaining ?? 0) < 0 ? "text-overspend" : ""}`}
|
||||
>
|
||||
{formatSignedMoney(row?.figures.remaining ?? 0, base)}
|
||||
{fmt.signedMoney(row?.figures.remaining ?? 0, base)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -107,7 +108,7 @@ export function PostMonthRoute() {
|
||||
{purchase.splits.length > 1 && (
|
||||
<span className="text-xs text-muted-foreground">split</span>
|
||||
)}
|
||||
<span className="font-money">{formatMoney(amount, base)}</span>
|
||||
<span className="font-money">{fmt.money(amount, base)}</span>
|
||||
</li>
|
||||
))}
|
||||
{thisMonth.length === 0 && <li className="py-2 text-muted-foreground">Nothing yet.</li>}
|
||||
@@ -123,7 +124,7 @@ export function PostMonthRoute() {
|
||||
<span className="w-20">{slice.month}</span>
|
||||
<span className="flex-1">{purchase.description}</span>
|
||||
<span className="font-money">
|
||||
{formatMoney(slice.amount.amount, slice.amount.currency)}
|
||||
{fmt.money(slice.amount.amount, slice.amount.currency)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Link, useParams } from "react-router";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { yearView } from "../../domain/views.ts";
|
||||
import { formatAmount, formatMoney, formatSignedMoney } from "../format.ts";
|
||||
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
|
||||
|
||||
export function PostYearRoute() {
|
||||
const fmt = useMoneyFormat();
|
||||
const { postId = "", year = "" } = useParams();
|
||||
const dataset = useDataset();
|
||||
const post = dataset.posts.find((p) => p.id === postId);
|
||||
@@ -20,9 +21,9 @@ export function PostYearRoute() {
|
||||
{post.name} · {year}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allocated {formatMoney(row.totalAllocation, base)} · spent{" "}
|
||||
{formatMoney(row.totalCharges, base)} · closing balance{" "}
|
||||
{formatSignedMoney(row.closingBalance, base)}
|
||||
Allocated {fmt.money(row.totalAllocation, base)} · spent{" "}
|
||||
{fmt.money(row.totalCharges, base)} · closing balance{" "}
|
||||
{fmt.signedMoney(row.closingBalance, base)}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
@@ -55,16 +56,16 @@ export function PostYearRoute() {
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{formatSignedMoney(figures.carriedIn, base)}
|
||||
{fmt.signedMoney(figures.carriedIn, base)}
|
||||
</td>
|
||||
<td className="font-money py-2 text-right">{formatAmount(figures.allocation)}</td>
|
||||
<td className="font-money py-2 text-right">{formatAmount(figures.charges)}</td>
|
||||
<td className="font-money py-2 text-right">{fmt.amount(figures.allocation)}</td>
|
||||
<td className="font-money py-2 text-right">{fmt.amount(figures.charges)}</td>
|
||||
<td
|
||||
className={`font-money py-2 text-right font-medium ${
|
||||
figures.remaining < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatSignedMoney(figures.remaining, base)}
|
||||
{fmt.signedMoney(figures.remaining, base)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -5,11 +5,12 @@ import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { datasetMonthSpan, summaryView } from "../../domain/views.ts";
|
||||
import { formatAmount, formatMoney } from "../format.ts";
|
||||
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
|
||||
import { Section, Stat } from "../components/Section.tsx";
|
||||
import { Segmented } from "../components/Segmented.tsx";
|
||||
|
||||
export function SummaryRoute() {
|
||||
const fmt = useMoneyFormat();
|
||||
const dataset = useDataset();
|
||||
const span = datasetMonthSpan(dataset);
|
||||
const [from, setFrom] = useState(span.from);
|
||||
@@ -46,10 +47,10 @@ export function SummaryRoute() {
|
||||
</div>
|
||||
|
||||
<dl className="mt-5 flex flex-wrap gap-x-10 gap-y-4 border-t border-budget-rule pt-5">
|
||||
<Stat label="Income">{formatMoney(view.totalIncome, base)}</Stat>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
<Stat label="Income">{fmt.money(view.totalIncome, base)}</Stat>
|
||||
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
|
||||
<Stat label="Difference" tone={difference < 0 ? "overspend" : "default"}>
|
||||
{formatMoney(difference, base)}
|
||||
{fmt.money(difference, base)}
|
||||
</Stat>
|
||||
</dl>
|
||||
</Section>
|
||||
@@ -81,7 +82,7 @@ export function SummaryRoute() {
|
||||
? view.byPost.map((entry) => (
|
||||
<tr key={entry.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
|
||||
<td className="py-2.5">{entry.post.name}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right">{formatAmount(entry.charges)}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right">{fmt.amount(entry.charges)}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
|
||||
{view.totalCharges === 0
|
||||
? "—"
|
||||
@@ -96,7 +97,7 @@ export function SummaryRoute() {
|
||||
{entry.monthId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right">{formatAmount(entry.charges)}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right">{fmt.amount(entry.charges)}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
|
||||
{view.totalCharges === 0
|
||||
? "—"
|
||||
|
||||
@@ -2,12 +2,13 @@ import { useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { yearView } from "../../domain/views.ts";
|
||||
import { formatMoney } from "../format.ts";
|
||||
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
|
||||
import { YearMatrix } from "../components/YearMatrix.tsx";
|
||||
import { Segmented } from "../components/Segmented.tsx";
|
||||
import { Section, Stat } from "../components/Section.tsx";
|
||||
|
||||
export function YearRoute() {
|
||||
const fmt = useMoneyFormat();
|
||||
const { year = "" } = useParams();
|
||||
const dataset = useDataset();
|
||||
const [mode, setMode] = useState<"spend" | "balance">("spend");
|
||||
@@ -35,8 +36,8 @@ export function YearRoute() {
|
||||
|
||||
<Section>
|
||||
<dl className="flex flex-wrap gap-x-10 gap-y-4">
|
||||
<Stat label="Income">{formatMoney(view.totalIncome, base)}</Stat>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
<Stat label="Income">{fmt.money(view.totalIncome, base)}</Stat>
|
||||
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
addCurrency,
|
||||
removeCurrency,
|
||||
setBaseCurrency,
|
||||
setDigits,
|
||||
setFxRate,
|
||||
removeFxRate,
|
||||
updateCurrency,
|
||||
@@ -68,30 +69,66 @@ export function CurrencySection() {
|
||||
>
|
||||
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="mb-5 space-y-1.5">
|
||||
<Label
|
||||
htmlFor="base-currency"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Base currency (all totals use this)
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="base-currency"
|
||||
className="w-40"
|
||||
value={base}
|
||||
onChange={(event) => {
|
||||
const currency = event.target.value;
|
||||
mutate((draft) => setBaseCurrency(draft, currency));
|
||||
}}
|
||||
>
|
||||
{dataset.currencies.map(({ code }) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<div className="mb-5 flex flex-wrap items-end gap-x-8 gap-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="base-currency"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Base currency (all totals use this)
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="base-currency"
|
||||
className="w-40"
|
||||
value={base}
|
||||
onChange={(event) => {
|
||||
const currency = event.target.value;
|
||||
mutate((draft) => setBaseCurrency(draft, currency));
|
||||
}}
|
||||
>
|
||||
{dataset.currencies.map(({ code }) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="digits"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Decimals (all amounts)
|
||||
</Label>
|
||||
<Input
|
||||
id="digits"
|
||||
type="number"
|
||||
min="0"
|
||||
max="4"
|
||||
step="1"
|
||||
className="font-money h-9 w-20"
|
||||
value={dataset.settings.digits}
|
||||
onChange={(event) => {
|
||||
// Captured before mutate, which defers behind the write queue —
|
||||
// React resets the DOM value first, so reading it inside the
|
||||
// callback commits the OLD one. AGENTS.md §2.
|
||||
const digits = Number(event.target.value);
|
||||
// Guarded here rather than letting setDigits throw: this is an
|
||||
// event handler, and no error boundary catches a throw from one.
|
||||
if (!Number.isInteger(digits) || digits < 0 || digits > 4) return;
|
||||
mutate((draft) => setDigits(draft, digits));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-5 max-w-[70ch] text-xs leading-relaxed text-budget-ink-muted">
|
||||
Every currency rounds to this. Changing it does not rewrite amounts you
|
||||
have already recorded — they keep the precision they were entered with,
|
||||
and totals recompute at the new setting.
|
||||
</p>
|
||||
|
||||
{/* `-mr-2 pr-2`, not a bare `overflow-x-auto`: the row's remove button
|
||||
carries `-mr-2` so its label sits flush with the card's content edge
|
||||
rather than 8px inside it. That overhang is 8px of overflow to a
|
||||
@@ -107,7 +144,6 @@ export function CurrencySection() {
|
||||
<th className="py-2 font-medium">Code</th>
|
||||
<th className="py-2 pl-4 font-medium">Name</th>
|
||||
<th className="py-2 pl-4 font-medium">Symbol</th>
|
||||
<th className="py-2 pl-4 text-right font-medium">Decimals</th>
|
||||
<th className="py-2 pl-4 text-right font-medium">1 unit = ? {base}</th>
|
||||
<th className="py-2 pl-4 text-right font-medium">Source</th>
|
||||
<th className="py-2 pl-4" />
|
||||
@@ -146,22 +182,6 @@ export function CurrencySection() {
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="4"
|
||||
step="1"
|
||||
className="font-money ml-auto h-8 w-16 text-right"
|
||||
aria-label={`Decimal places for ${currency.code}`}
|
||||
value={currency.digits}
|
||||
onChange={(event) => {
|
||||
const digits = Number(event.target.value);
|
||||
if (!Number.isInteger(digits) || digits < 0 || digits > 4) return;
|
||||
mutate((draft) => updateCurrency(draft, currency.code, { digits }));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right">
|
||||
{isBase ? (
|
||||
<span className="font-money text-budget-ink-muted">1</span>
|
||||
@@ -265,28 +285,21 @@ export function CurrencySection() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decimals default to 2 because almost every currency uses hundredths; the
|
||||
* field is there for the ones that do not (yen at 0, dinars at 3).
|
||||
* No decimals field: they are one setting for the whole dataset now, edited
|
||||
* above the table rather than per currency.
|
||||
*/
|
||||
function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number; symbol?: string; name?: string }) => void }) {
|
||||
function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; symbol?: string; name?: string }) => void }) {
|
||||
const [code, setCode] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [symbol, setSymbol] = useState("");
|
||||
const [digits, setDigits] = useState("2");
|
||||
|
||||
const parsedDigits = Number(digits);
|
||||
const canAdd =
|
||||
/^[A-Za-z]{2,8}$/.test(code.trim()) &&
|
||||
Number.isInteger(parsedDigits) &&
|
||||
parsedDigits >= 0 &&
|
||||
parsedDigits <= 4;
|
||||
const canAdd = /^[A-Za-z]{2,8}$/.test(code.trim());
|
||||
|
||||
function add() {
|
||||
onAdd({ code: code.trim(), digits: parsedDigits, symbol, name });
|
||||
onAdd({ code: code.trim(), symbol, name });
|
||||
setCode("");
|
||||
setName("");
|
||||
setSymbol("");
|
||||
setDigits("2");
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -321,23 +334,6 @@ function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number;
|
||||
setSymbol(next);
|
||||
}}
|
||||
/>
|
||||
{/* No visible label. Its three siblings are labelled by placeholder, and
|
||||
the DECIMALS column heading sits directly above this field in the
|
||||
table — a fourth label here read as a heading for the whole row. */}
|
||||
<Input
|
||||
id="new-digits"
|
||||
type="number"
|
||||
min="0"
|
||||
max="4"
|
||||
step="1"
|
||||
aria-label="Decimal places for the new currency"
|
||||
className="font-money h-9 w-16 text-right"
|
||||
value={digits}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setDigits(next);
|
||||
}}
|
||||
/>
|
||||
<Button variant="outline" disabled={!canAdd} onClick={add}>
|
||||
Add currency
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user