diff --git a/src/ui/components/PurchaseDialog.tsx b/src/ui/components/PurchaseDialog.tsx new file mode 100644 index 0000000..ad8e105 --- /dev/null +++ b/src/ui/components/PurchaseDialog.tsx @@ -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(() => + 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 ( + { + setOpen(next); + if (next) reset(); + }} + > + {trigger} + + + {purchase ? "Edit purchase" : "Add purchase"} + + +
+
+
+ + setDraft({ ...draft, date: event.target.value })} + /> +
+
+ + + setDraft({ ...draft, description: event.target.value }) + } + /> +
+
+ + + setDraft({ ...draft, amount: Number(event.target.value) || 0 }) + } + /> +
+
+ + +
+
+ + + + {errors.length > 0 && ( +
    + {errors.map((message) => ( +
  • {message}
  • + ))} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/ui/components/SplitEditor.tsx b/src/ui/components/SplitEditor.tsx new file mode 100644 index 0000000..da3d5ea --- /dev/null +++ b/src/ui/components/SplitEditor.tsx @@ -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) { + 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 ( +
+
+ +
+ {(["percent", "fixed"] as const).map((mode) => ( + + ))} +
+
+ + {draft.splits.map((split, index) => ( +
+ + + + updateSplit(index, { value: Number(event.target.value) || 0 }) + } + /> + {unit} + + + + +
+ ))} + +
+ + + {balance === 0 + ? "Splits balance exactly." + : `${formatAmount(balance)} ${unit} unassigned — the rounding post absorbs it.`} + +
+
+ ); +} diff --git a/src/ui/purchaseForm.test.ts b/src/ui/purchaseForm.test.ts new file mode 100644 index 0000000..9de8107 --- /dev/null +++ b/src/ui/purchaseForm.test.ts @@ -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 { + 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(); + }); +}); diff --git a/src/ui/purchaseForm.ts b/src/ui/purchaseForm.ts new file mode 100644 index 0000000..9013f79 --- /dev/null +++ b/src/ui/purchaseForm.ts @@ -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 { + 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, + }; +} diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index 68d4348..c0d9ba1 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -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() { + +
+
+

Purchases

+ Add purchase} /> +
+
    + {dataset.purchases + .filter((purchase) => sliceAmountForMonth(purchase, monthId) !== null) + .map((purchase) => { + const slice = sliceAmountForMonth(purchase, monthId)!; + return ( +
  • + {purchase.description} + {purchase.schedule && ( + financed + )} + + {formatMoney(slice.amount, slice.currency)} + + + edit + + } + /> + +
  • + ); + })} +
+
); }