feat(ui): add settings for currency, rates, posts, and data
Rate fetching is optional and fails soft: any network or parse error leaves cached rates untouched and shows a message, never throwing into render. A cleared or non-positive FX rate input now calls the new removeFxRate action instead of storing 0, since toBase() silently converts a stored 0 to a 0-value conversion while an absent row correctly throws MissingRateError. Posts are archived, never deleted. Import downloads a backup of the current data before replacing it and requires confirmation showing before/after counts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
408a473dcd
commit
31f030fdea
10 files changed
+570
-1
No files matched your search
@@ -83,6 +83,8 @@ export interface Settings {
|
||||
baseCurrency: Currency;
|
||||
foldStartMonth: MonthId;
|
||||
schemaVersion: number;
|
||||
/** Optional: no migration needed, existing datasets leave it undefined. */
|
||||
fxApiUrl?: string;
|
||||
}
|
||||
|
||||
export interface Dataset {
|
||||
|
||||
@@ -312,4 +312,31 @@ describe("settings", () => {
|
||||
expect(data.settings.baseCurrency).toBe("EUR");
|
||||
expect(data.fxRates.find((r) => r.currency === "EUR")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removeFxRate filters the currency out entirely, leaving no row", () => {
|
||||
const data = draft();
|
||||
actions.setFxRate(data, {
|
||||
currency: "EUR",
|
||||
baseUnitsPerOne: 7.46,
|
||||
updatedAt: "2026-09-01",
|
||||
source: "manual",
|
||||
});
|
||||
actions.setFxRate(data, {
|
||||
currency: "USD",
|
||||
baseUnitsPerOne: 6.9,
|
||||
updatedAt: "2026-09-01",
|
||||
source: "manual",
|
||||
});
|
||||
|
||||
actions.removeFxRate(data, "EUR");
|
||||
|
||||
expect(data.fxRates.map((r) => r.currency)).toEqual(["USD"]);
|
||||
expect(data.fxRates.find((r) => r.currency === "EUR")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removeFxRate on a currency with no row is a no-op", () => {
|
||||
const data = draft();
|
||||
actions.removeFxRate(data, "EUR");
|
||||
expect(data.fxRates).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -219,6 +219,18 @@ export function setFxRate(draft: Dataset, rate: FxRate): void {
|
||||
else draft.fxRates[index] = rate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a currency's rate row entirely. A cleared rate MUST become an
|
||||
* absent row, not a stored 0: toBase() throws MissingRateError for an absent
|
||||
* row but silently converts to 0 for a stored zero rate, which would corrupt
|
||||
* every purchase in that currency without any error surfacing. Callers (the
|
||||
* currency settings input) must route a cleared/invalid rate here instead of
|
||||
* through setFxRate with a 0.
|
||||
*/
|
||||
export function removeFxRate(draft: Dataset, currency: Currency): void {
|
||||
draft.fxRates = draft.fxRates.filter((r) => r.currency !== currency);
|
||||
}
|
||||
|
||||
/** The base currency never has a rate row of its own. */
|
||||
export function setBaseCurrency(draft: Dataset, currency: Currency): void {
|
||||
draft.settings.baseCurrency = currency;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { buildFxUrl, parseFxResponse, DEFAULT_FX_API_URL } from "./fxApi.ts";
|
||||
|
||||
describe("buildFxUrl", () => {
|
||||
test("substitutes the base and target placeholders", () => {
|
||||
expect(buildFxUrl(DEFAULT_FX_API_URL, "DKK", ["USD", "EUR"])).toBe(
|
||||
"https://api.frankfurter.app/latest?from=DKK&to=USD,EUR",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFxResponse", () => {
|
||||
test("inverts the rates into base units per one unit", () => {
|
||||
// The API returns how many target units 1 base unit buys; we store the
|
||||
// inverse, so 1 EUR = 7.46 DKK becomes baseUnitsPerOne 7.46.
|
||||
const rates = parseFxResponse({ rates: { EUR: 0.134048, USD: 0.144928 } }, "DKK");
|
||||
expect(rates.find((r) => r.currency === "EUR")!.baseUnitsPerOne).toBeCloseTo(7.46, 2);
|
||||
expect(rates.every((r) => r.source === "api")).toBe(true);
|
||||
expect(rates.every((r) => r.updatedAt !== "")).toBe(true);
|
||||
});
|
||||
|
||||
test("skips the base currency's own row if the API returns one", () => {
|
||||
const rates = parseFxResponse({ rates: { DKK: 1, EUR: 0.134 } }, "DKK");
|
||||
expect(rates.map((r) => r.currency)).toEqual(["EUR"]);
|
||||
});
|
||||
|
||||
test("ignores unsupported currencies", () => {
|
||||
const rates = parseFxResponse({ rates: { GBP: 0.11, EUR: 0.134 } }, "DKK");
|
||||
expect(rates.map((r) => r.currency)).toEqual(["EUR"]);
|
||||
});
|
||||
|
||||
test("rejects a response with no rates object", () => {
|
||||
expect(() => parseFxResponse({}, "DKK")).toThrow(/rates/);
|
||||
expect(() => parseFxResponse("nope", "DKK")).toThrow(/rates/);
|
||||
});
|
||||
|
||||
test("rejects a zero or negative rate rather than storing an infinity", () => {
|
||||
expect(() => parseFxResponse({ rates: { EUR: 0 } }, "DKK")).toThrow(/EUR/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { CURRENCIES, type Currency, type FxRate } from "../domain/types.ts";
|
||||
|
||||
/** Frankfurter needs no API key. The URL is user-editable in settings. */
|
||||
export const DEFAULT_FX_API_URL = "https://api.frankfurter.app/latest?from={base}&to={targets}";
|
||||
|
||||
export function buildFxUrl(
|
||||
template: string,
|
||||
base: Currency,
|
||||
targets: Currency[],
|
||||
): string {
|
||||
return template
|
||||
.replace("{base}", base)
|
||||
.replace("{targets}", targets.join(","));
|
||||
}
|
||||
|
||||
export function parseFxResponse(body: unknown, base: Currency): FxRate[] {
|
||||
const rates =
|
||||
typeof body === "object" && body !== null
|
||||
? (body as Record<string, unknown>).rates
|
||||
: undefined;
|
||||
if (typeof rates !== "object" || rates === null) {
|
||||
throw new Error("Exchange rate response has no `rates` object");
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
const out: FxRate[] = [];
|
||||
|
||||
for (const [currency, value] of Object.entries(rates as Record<string, unknown>)) {
|
||||
if (currency === base) continue;
|
||||
if (!CURRENCIES.includes(currency as Currency)) continue;
|
||||
if (typeof value !== "number" || !(value > 0)) {
|
||||
throw new Error(`Exchange rate for ${currency} is not a positive number`);
|
||||
}
|
||||
// The API gives target-per-base; we store base-per-target.
|
||||
out.push({
|
||||
currency: currency as Currency,
|
||||
baseUnitsPerOne: Number((1 / value).toFixed(6)),
|
||||
updatedAt,
|
||||
source: "api",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Fails soft at the call site: callers keep the cached rates on error. */
|
||||
export async function fetchFxRates(
|
||||
template: string,
|
||||
base: Currency,
|
||||
targets: Currency[],
|
||||
): Promise<FxRate[]> {
|
||||
const response = await fetch(buildFxUrl(template, base, targets));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Rate service returned ${response.status}`);
|
||||
}
|
||||
return parseFxResponse(await response.json(), base);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Triggers a browser download. Works only in a real page, not in tests. */
|
||||
export function downloadBlob(filename: string, blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
// Give the browser a turn to start the download before revoking.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
@@ -1,3 +1,14 @@
|
||||
import { CurrencySection } from "./settings/CurrencySection.tsx";
|
||||
import { PostsSection } from "./settings/PostsSection.tsx";
|
||||
import { DataSection } from "./settings/DataSection.tsx";
|
||||
|
||||
export function SettingsRoute() {
|
||||
return <h1 className="text-2xl font-semibold">Settings</h1>;
|
||||
return (
|
||||
<div className="max-w-4xl space-y-10">
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
<CurrencySection />
|
||||
<PostsSection />
|
||||
<DataSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { useMutate } from "../../hooks/useMutate.ts";
|
||||
import { setBaseCurrency, setFxRate, removeFxRate } from "../../../store/actions.ts";
|
||||
import { DEFAULT_FX_API_URL, fetchFxRates } from "../../../store/fxApi.ts";
|
||||
import { CURRENCIES, type Currency } from "../../../domain/types.ts";
|
||||
|
||||
export function CurrencySection() {
|
||||
const dataset = useDataset();
|
||||
const { mutate } = useMutate();
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const apiUrl = dataset.settings.fxApiUrl ?? DEFAULT_FX_API_URL;
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const targets = CURRENCIES.filter((c) => c !== base);
|
||||
|
||||
async function refresh() {
|
||||
setStatus("Fetching…");
|
||||
try {
|
||||
const rates = await fetchFxRates(apiUrl, base, targets);
|
||||
mutate((draft) => {
|
||||
for (const rate of rates) setFxRate(draft, rate);
|
||||
});
|
||||
setStatus(`Updated ${rates.length} rate(s).`);
|
||||
} catch (error) {
|
||||
// Fails soft: the cached rates are still in place, never cleared.
|
||||
setStatus(
|
||||
`Could not fetch rates (${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}). Your saved rates are unchanged.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium">Currency</h2>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="base-currency">Base currency (all totals use this)</Label>
|
||||
<select
|
||||
id="base-currency"
|
||||
className="h-9 w-32 rounded border bg-background px-2 text-sm"
|
||||
value={base}
|
||||
onChange={(event) =>
|
||||
mutate((draft) => setBaseCurrency(draft, event.target.value as Currency))
|
||||
}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<table className="w-full max-w-md text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">Currency</th>
|
||||
<th className="py-2 text-right">1 unit = ? {base}</th>
|
||||
<th className="py-2 text-right">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{targets.map((currency) => {
|
||||
const rate = dataset.fxRates.find((r) => r.currency === currency);
|
||||
return (
|
||||
<tr key={currency} className="border-b last:border-0">
|
||||
<td className="py-2">{currency}</td>
|
||||
<td className="py-2 text-right">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
className="font-money ml-auto h-8 w-32"
|
||||
value={rate?.baseUnitsPerOne ?? ""}
|
||||
onChange={(event) => {
|
||||
// A cleared or non-positive field MUST remove the rate
|
||||
// row rather than store 0: toBase() converts a stored 0
|
||||
// silently to a 0-value conversion, while an absent row
|
||||
// correctly throws MissingRateError. Storing 0 here
|
||||
// would turn a loud, correct error into wrong numbers
|
||||
// presented as fact.
|
||||
const parsed = Number(event.target.value);
|
||||
mutate((draft) => {
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
setFxRate(draft, {
|
||||
currency,
|
||||
baseUnitsPerOne: parsed,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "manual",
|
||||
});
|
||||
} else {
|
||||
removeFxRate(draft, currency);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-right text-xs text-muted-foreground">
|
||||
{rate ? `${rate.source} · ${rate.updatedAt.slice(0, 10)}` : "not set"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="fx-url">Rate service URL</Label>
|
||||
<Input
|
||||
id="fx-url"
|
||||
className="max-w-xl"
|
||||
value={apiUrl}
|
||||
onChange={(event) =>
|
||||
mutate((draft) => {
|
||||
draft.settings.fxApiUrl = event.target.value;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{"{base}"} and {"{targets}"} are substituted. Fetching is optional — the app
|
||||
works offline with the rates you type in above.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void refresh()}>
|
||||
Fetch rates now
|
||||
</Button>
|
||||
{status && <p className="text-xs">{status}</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { downloadBlob } from "../../download.ts";
|
||||
import { store, currentMonth } from "../../../store/index.ts";
|
||||
import {
|
||||
describeDataset,
|
||||
exportDatasetJson,
|
||||
exportFilename,
|
||||
parseDatasetJson,
|
||||
} from "../../../export/json.ts";
|
||||
import { buildOds, odsFilename } from "../../../export/ods.ts";
|
||||
import type { Dataset } from "../../../domain/types.ts";
|
||||
|
||||
export function DataSection() {
|
||||
const dataset = useDataset();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [pending, setPending] = useState<Dataset | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function exportJson() {
|
||||
downloadBlob(
|
||||
exportFilename(currentMonth),
|
||||
new Blob([exportDatasetJson(dataset)], { type: "application/json" }),
|
||||
);
|
||||
}
|
||||
|
||||
function exportOds() {
|
||||
downloadBlob(
|
||||
odsFilename(currentMonth),
|
||||
new Blob([buildOds(dataset) as BlobPart], {
|
||||
type: "application/vnd.oasis.opendocument.spreadsheet",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function chooseFile(file: File) {
|
||||
setError(null);
|
||||
try {
|
||||
setPending(parseDatasetJson(await file.text()));
|
||||
} catch (cause) {
|
||||
setPending(null);
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
if (!pending) return;
|
||||
// Back up first: import replaces everything, and this is the user's only
|
||||
// copy. Order matters — the backup must download before the destructive
|
||||
// replace, never after.
|
||||
exportJson();
|
||||
await store.replace(pending);
|
||||
setPending(null);
|
||||
}
|
||||
|
||||
const counts = describeDataset(dataset);
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium">Your data</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Everything lives in this browser. Export regularly — it is both your
|
||||
backup and how you move your budget to another device.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={exportJson}>
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button variant="outline" onClick={exportOds}>
|
||||
Export ODS (spreadsheet)
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => fileInput.current?.click()}>
|
||||
Import JSON…
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void chooseFile(file);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">Could not import: {error}</p>}
|
||||
|
||||
{pending && (
|
||||
<div className="space-y-2 rounded border border-destructive p-3 text-sm">
|
||||
<p className="font-medium">This replaces everything currently stored.</p>
|
||||
<p>
|
||||
Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases.
|
||||
<br />
|
||||
After import: {describeDataset(pending).posts} posts,{" "}
|
||||
{describeDataset(pending).months} months,{" "}
|
||||
{describeDataset(pending).purchases} purchases.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A backup of your current data will download first.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="destructive" size="sm" onClick={() => void confirmImport()}>
|
||||
Replace my data
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setPending(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { useMutate } from "../../hooks/useMutate.ts";
|
||||
import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts";
|
||||
import { CURRENCIES, type Currency, type Rule } from "../../../domain/types.ts";
|
||||
|
||||
export function PostsSection() {
|
||||
const dataset = useDataset();
|
||||
const { mutate } = useMutate();
|
||||
const [newName, setNewName] = useState("");
|
||||
const ordered = [...dataset.posts].sort((a, b) => a.order - b.order);
|
||||
const base = dataset.settings.baseCurrency;
|
||||
|
||||
function ruleEditor(postId: string, rule: Rule) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="h-8 rounded border bg-background px-1 text-xs"
|
||||
value={rule.kind}
|
||||
onChange={(event) =>
|
||||
mutate((draft) =>
|
||||
updatePost(draft, postId, {
|
||||
standingRule:
|
||||
event.target.value === "fixed"
|
||||
? { kind: "fixed", amount: { amount: 0, currency: base } }
|
||||
: { kind: "percentOfIncome", percent: 0 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="fixed">fixed amount</option>
|
||||
<option value="percentOfIncome">% of income</option>
|
||||
</select>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="font-money h-8 w-28"
|
||||
value={rule.kind === "fixed" ? rule.amount.amount : rule.percent}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value) || 0;
|
||||
mutate((draft) =>
|
||||
updatePost(draft, postId, {
|
||||
standingRule:
|
||||
rule.kind === "fixed"
|
||||
? { kind: "fixed", amount: { ...rule.amount, amount: value } }
|
||||
: { kind: "percentOfIncome", percent: value },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="w-10 text-xs text-muted-foreground">
|
||||
{rule.kind === "fixed" ? rule.amount.currency : "%"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium">Posts</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Standing rules apply to every month automatically. Any single month can
|
||||
override its own allocation from the month view. Percentages may total
|
||||
more than 100%.
|
||||
</p>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">Name</th>
|
||||
<th className="py-2">Display currency</th>
|
||||
<th className="py-2">Standing rule</th>
|
||||
<th className="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ordered.map((post, index) => (
|
||||
<tr key={post.id} className={`border-b last:border-0 ${post.archived ? "opacity-50" : ""}`}>
|
||||
<td className="py-2">
|
||||
<Input
|
||||
className="h-8 w-48"
|
||||
value={post.name}
|
||||
onChange={(event) =>
|
||||
mutate((draft) => updatePost(draft, post.id, { name: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<select
|
||||
className="h-8 rounded border bg-background px-1 text-xs"
|
||||
value={post.currency}
|
||||
onChange={(event) =>
|
||||
mutate((draft) =>
|
||||
updatePost(draft, post.id, { currency: event.target.value as Currency }),
|
||||
)
|
||||
}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-2">{ruleEditor(post.id, post.standingRule)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<Button size="sm" variant="ghost" disabled={index === 0}
|
||||
onClick={() => mutate((draft) => movePost(draft, post.id, -1))}>
|
||||
up
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={index === ordered.length - 1}
|
||||
onClick={() => mutate((draft) => movePost(draft, post.id, 1))}>
|
||||
down
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost"
|
||||
onClick={() => mutate((draft) => setPostArchived(draft, post.id, !post.archived))}>
|
||||
{post.archived ? "restore" : "archive"}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Archiving hides a post from new purchases while keeping its history. Posts
|
||||
are never deleted, because purchases reference them.
|
||||
</p>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<Input
|
||||
className="w-48"
|
||||
placeholder="New post name"
|
||||
value={newName}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={newName.trim() === ""}
|
||||
onClick={() => {
|
||||
mutate((draft) =>
|
||||
addPost(draft, newName.trim(), base, {
|
||||
kind: "fixed",
|
||||
amount: { amount: 0, currency: base },
|
||||
}),
|
||||
);
|
||||
setNewName("");
|
||||
}}
|
||||
>
|
||||
Add post
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user