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:
co-authored by
Claude Opus 5
parent
b278dcaf12
commit
5a11941d39
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user