feat(ui): add purchase dialog with split editor

Validation lives in purchaseForm.ts as pure functions. Unbalanced splits
and over-budget purchases are deliberately not errors.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-01 20:48:51 +02:00
co-authored by Claude Opus 5
parent b278dcaf12
commit 5a11941d39
5 changed files with 560 additions and 1 deletions
+151
View File
@@ -0,0 +1,151 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SplitEditor } from "./SplitEditor.tsx";
import {
emptyDraft,
fromPurchase,
toPurchase,
validatePurchase,
type PurchaseDraft,
} from "../purchaseForm.ts";
import { useDataset } from "../hooks/useDataset.ts";
import { useMutate } from "../hooks/useMutate.ts";
import { addPurchase, updatePurchase } from "../../store/actions.ts";
import { CURRENCIES, type MonthId, type Purchase } from "../../domain/types.ts";
interface Props {
monthId: MonthId;
/** Provide to edit an existing purchase; omit to add a new one. */
purchase?: Purchase;
trigger: React.ReactNode;
}
export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
const dataset = useDataset();
const { mutate } = useMutate();
const activePosts = dataset.posts
.filter((p) => !p.archived)
.sort((a, b) => a.order - b.order);
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<PurchaseDraft>(() =>
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
);
const errors = validatePurchase(draft);
function reset() {
setDraft(
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
);
}
function save() {
if (errors.length > 0) return;
mutate((data) => {
if (purchase) updatePurchase(data, purchase.id, toPurchase(draft));
else addPurchase(data, toPurchase(draft));
});
setOpen(false);
if (!purchase) reset();
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) reset();
}}
>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{purchase ? "Edit purchase" : "Add purchase"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="purchase-date">Date</Label>
<Input
id="purchase-date"
type="date"
value={draft.date}
onChange={(event) => setDraft({ ...draft, date: event.target.value })}
/>
</div>
<div className="space-y-1">
<Label htmlFor="purchase-description">Description</Label>
<Input
id="purchase-description"
value={draft.description}
onChange={(event) =>
setDraft({ ...draft, description: event.target.value })
}
/>
</div>
<div className="space-y-1">
<Label htmlFor="purchase-amount">Total</Label>
<Input
id="purchase-amount"
type="number"
step="0.01"
className="font-money"
value={draft.amount}
onChange={(event) =>
setDraft({ ...draft, amount: Number(event.target.value) || 0 })
}
/>
</div>
<div className="space-y-1">
<Label htmlFor="purchase-currency">Currency</Label>
<select
id="purchase-currency"
className="h-9 w-full rounded border bg-background px-2 text-sm"
value={draft.currency}
onChange={(event) =>
setDraft({ ...draft, currency: event.target.value as typeof draft.currency })
}
>
{CURRENCIES.map((currency) => (
<option key={currency} value={currency}>
{currency}
</option>
))}
</select>
</div>
</div>
<SplitEditor draft={draft} posts={activePosts} onChange={setDraft} />
{errors.length > 0 && (
<ul className="space-y-1 text-sm text-destructive">
{errors.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={save} disabled={errors.length > 0}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { formatAmount } from "../format.ts";
import type { PurchaseDraft, SplitDraft } from "../purchaseForm.ts";
import { splitBalance } from "../purchaseForm.ts";
import type { Post } from "../../domain/types.ts";
interface Props {
draft: PurchaseDraft;
posts: Post[];
onChange: (next: PurchaseDraft) => void;
}
export function SplitEditor({ draft, posts, onChange }: Props) {
const balance = splitBalance(draft);
const unit = draft.splitMode === "percent" ? "%" : draft.currency;
function updateSplit(index: number, changes: Partial<SplitDraft>) {
const splits = draft.splits.map((split, i) =>
i === index ? { ...split, ...changes } : split,
);
onChange({ ...draft, splits });
}
function setAbsorber(index: number) {
onChange({
...draft,
splits: draft.splits.map((split, i) => ({
...split,
absorbsRemainder: i === index,
})),
});
}
return (
<fieldset className="space-y-3">
<div className="flex items-center justify-between">
<Label>Split across posts</Label>
<div className="flex gap-1 text-xs">
{(["percent", "fixed"] as const).map((mode) => (
<Button
key={mode}
type="button"
size="sm"
variant={draft.splitMode === mode ? "default" : "outline"}
onClick={() => onChange({ ...draft, splitMode: mode })}
>
{mode === "percent" ? "Percentages" : "Amounts"}
</Button>
))}
</div>
</div>
{draft.splits.map((split, index) => (
<div key={index} className="flex items-center gap-2">
<select
className="h-9 flex-1 rounded border bg-background px-2 text-sm"
value={split.postId}
onChange={(event) => updateSplit(index, { postId: event.target.value })}
>
<option value="">Choose a post…</option>
{posts.map((post) => (
<option key={post.id} value={post.id}>
{post.name}
</option>
))}
</select>
<Input
type="number"
step="0.01"
className="font-money w-28"
value={split.value}
onChange={(event) =>
updateSplit(index, { value: Number(event.target.value) || 0 })
}
/>
<span className="w-10 text-xs text-muted-foreground">{unit}</span>
<label className="flex items-center gap-1 text-xs" title="This post absorbs rounding">
<input
type="radio"
name="absorber"
checked={split.absorbsRemainder}
onChange={() => setAbsorber(index)}
/>
rounding
</label>
<Button
type="button"
size="sm"
variant="ghost"
disabled={draft.splits.length === 1}
onClick={() =>
onChange({
...draft,
splits: draft.splits.filter((_, i) => i !== index),
})
}
>
remove
</Button>
</div>
))}
<div className="flex items-center justify-between text-xs">
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
onChange({
...draft,
splits: [
...draft.splits,
{ postId: "", value: 0, absorbsRemainder: false },
],
})
}
>
Add a post
</Button>
<span className={balance === 0 ? "text-muted-foreground" : ""}>
{balance === 0
? "Splits balance exactly."
: `${formatAmount(balance)} ${unit} unassigned — the rounding post absorbs it.`}
</span>
</div>
</fieldset>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { test, expect, describe } from "bun:test";
import {
emptyDraft,
splitBalance,
validatePurchase,
toPurchase,
fromPurchase,
type PurchaseDraft,
} from "./purchaseForm.ts";
function draft(overrides: Partial<PurchaseDraft> = {}): PurchaseDraft {
return { ...emptyDraft("2026-09", "food"), amount: 200, description: "Dinner", ...overrides };
}
describe("emptyDraft", () => {
test("starts on the first of the given month with one full-weight split", () => {
const d = emptyDraft("2026-09", "food");
expect(d.date).toBe("2026-09-01");
expect(d.splitMode).toBe("percent");
expect(d.splits).toEqual([{ postId: "food", value: 100, absorbsRemainder: true }]);
expect(d.plan).toBeNull();
});
});
describe("splitBalance", () => {
test("is zero when percentages total 100", () => {
expect(splitBalance(draft())).toBe(0);
});
test("reports the missing percentage", () => {
const d = draft({
splits: [
{ postId: "food", value: 60, absorbsRemainder: true },
{ postId: "events", value: 30, absorbsRemainder: false },
],
});
expect(splitBalance(d)).toBe(10);
});
test("reports the missing amount in fixed mode", () => {
const d = draft({
splitMode: "fixed",
splits: [
{ postId: "food", value: 120, absorbsRemainder: true },
{ postId: "events", value: 50, absorbsRemainder: false },
],
});
expect(splitBalance(d)).toBe(30);
});
});
describe("validatePurchase", () => {
test("a well-formed draft has no errors", () => {
expect(validatePurchase(draft())).toEqual([]);
});
test("requires a description", () => {
expect(validatePurchase(draft({ description: " " }))).toContain(
"Give the purchase a description.",
);
});
test("requires a non-zero amount", () => {
expect(validatePurchase(draft({ amount: 0 }))).toContain(
"Enter an amount other than zero.",
);
});
test("requires at least one split", () => {
expect(validatePurchase(draft({ splits: [] }))).toContain(
"Add at least one post to split across.",
);
});
test("requires exactly one remainder absorber", () => {
const none = draft({
splits: [{ postId: "food", value: 100, absorbsRemainder: false }],
});
expect(validatePurchase(none)).toContain(
"Choose exactly one post to absorb rounding.",
);
});
test("rejects the same post twice", () => {
const dup = draft({
splits: [
{ postId: "food", value: 50, absorbsRemainder: true },
{ postId: "food", value: 50, absorbsRemainder: false },
],
});
expect(validatePurchase(dup)).toContain("Each post can only appear once.");
});
test("does NOT reject going over budget or an unbalanced split", () => {
// The remainder-absorbing split reconciles any imbalance, and over-budget
// is always permitted, so neither is an error.
const unbalanced = draft({
splits: [
{ postId: "food", value: 10, absorbsRemainder: true },
{ postId: "events", value: 10, absorbsRemainder: false },
],
});
expect(validatePurchase(unbalanced)).toEqual([]);
});
});
describe("toPurchase and fromPurchase", () => {
test("round-trips a simple purchase", () => {
const d = draft();
expect(fromPurchase({ ...toPurchase(d), id: "x" })).toEqual(d);
});
test("produces a schedule-free purchase when there is no plan", () => {
expect(toPurchase(draft()).schedule).toBeNull();
});
});
+115
View File
@@ -0,0 +1,115 @@
import { roundMoney } from "../domain/money.ts";
import type {
Currency,
IsoDate,
MonthId,
Purchase,
ScheduleSlice,
} from "../domain/types.ts";
export interface SplitDraft {
postId: string;
value: number;
absorbsRemainder: boolean;
}
export interface PlanDraft {
startMonth: MonthId;
slices: ScheduleSlice[];
cancelledFromMonth?: MonthId;
}
export interface PurchaseDraft {
date: IsoDate;
description: string;
amount: number;
currency: Currency;
splitMode: "percent" | "fixed";
splits: SplitDraft[];
plan: PlanDraft | null;
}
export function emptyDraft(monthId: MonthId, postId: string): PurchaseDraft {
return {
date: `${monthId}-01`,
description: "",
amount: 0,
currency: "DKK",
splitMode: "percent",
splits: [{ postId, value: 100, absorbsRemainder: true }],
plan: null,
};
}
/** What the split editor must show: how far the parts are from the whole. */
export function splitBalance(draft: PurchaseDraft): number {
const target = draft.splitMode === "percent" ? 100 : draft.amount;
const sum = draft.splits.reduce((total, split) => total + split.value, 0);
return roundMoney(target - sum, draft.currency);
}
export function validatePurchase(draft: PurchaseDraft): string[] {
const errors: string[] = [];
if (draft.description.trim() === "") {
errors.push("Give the purchase a description.");
}
if (draft.amount === 0) {
errors.push("Enter an amount other than zero.");
}
if (draft.splits.length === 0) {
errors.push("Add at least one post to split across.");
}
if (draft.splits.filter((s) => s.absorbsRemainder).length !== 1) {
errors.push("Choose exactly one post to absorb rounding.");
}
const postIds = draft.splits.map((s) => s.postId);
if (new Set(postIds).size !== postIds.length) {
errors.push("Each post can only appear once.");
}
if (postIds.some((id) => id === "")) {
errors.push("Choose a post for every split.");
}
// Deliberately absent: any check on whether splits balance, or whether the
// purchase pushes a post over budget. Both are permitted by design.
return errors;
}
export function toPurchase(draft: PurchaseDraft): Omit<Purchase, "id"> {
return {
date: draft.date,
description: draft.description.trim(),
total: { amount: draft.amount, currency: draft.currency },
splitMode: draft.splitMode,
splits: draft.splits.map((s) => ({ ...s })),
schedule: draft.plan
? {
slices: draft.plan.slices.map((s) => ({ ...s, amount: { ...s.amount } })),
...(draft.plan.cancelledFromMonth
? { cancelledFromMonth: draft.plan.cancelledFromMonth }
: {}),
}
: null,
};
}
export function fromPurchase(purchase: Purchase): PurchaseDraft {
return {
date: purchase.date,
description: purchase.description,
amount: purchase.total.amount,
currency: purchase.total.currency,
splitMode: purchase.splitMode,
splits: purchase.splits.map((s) => ({ ...s })),
plan: purchase.schedule
? {
startMonth: purchase.schedule.slices[0]?.month ?? "",
slices: purchase.schedule.slices.map((s) => ({ ...s, amount: { ...s.amount } })),
...(purchase.schedule.cancelledFromMonth
? { cancelledFromMonth: purchase.schedule.cancelledFromMonth }
: {}),
}
: null,
};
}
+45 -1
View File
@@ -1,13 +1,16 @@
import { Link, useParams } from "react-router";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useDataset } from "../hooks/useDataset.ts";
import { useMutate } from "../hooks/useMutate.ts";
import { monthView } from "../../domain/views.ts";
import { addMonths } from "../../domain/months.ts";
import { setIncome } from "../../store/actions.ts";
import { setIncome, deletePurchase } from "../../store/actions.ts";
import { sliceAmountForMonth } from "../../domain/charges.ts";
import { formatMoney, formatSignedMoney } from "../format.ts";
import { PostTable } from "../components/PostTable.tsx";
import { PurchaseDialog } from "../components/PurchaseDialog.tsx";
export function MonthRoute() {
const { monthId = "" } = useParams();
@@ -82,6 +85,47 @@ export function MonthRoute() {
</div>
<PostTable monthId={monthId} baseCurrency={base} rows={view.rows} />
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Purchases</h2>
<PurchaseDialog monthId={monthId} trigger={<Button>Add purchase</Button>} />
</div>
<ul className="divide-y text-sm">
{dataset.purchases
.filter((purchase) => sliceAmountForMonth(purchase, monthId) !== null)
.map((purchase) => {
const slice = sliceAmountForMonth(purchase, monthId)!;
return (
<li key={purchase.id} className="flex items-center gap-3 py-2">
<span className="flex-1">{purchase.description}</span>
{purchase.schedule && (
<span className="text-xs text-muted-foreground">financed</span>
)}
<span className="font-money">
{formatMoney(slice.amount, slice.currency)}
</span>
<PurchaseDialog
monthId={monthId}
purchase={purchase}
trigger={
<Button size="sm" variant="ghost">
edit
</Button>
}
/>
<Button
size="sm"
variant="ghost"
onClick={() => mutate((data) => deletePurchase(data, purchase.id))}
>
delete
</Button>
</li>
);
})}
</ul>
</section>
</section>
);
}