Files
budget2.0/src/ui/components/SplitEditor.tsx
T
Lucas WintherandClaude Opus 5.5 933027cb1b Merge main into the anchored model
Main moved on by 27 commits since this branch forked, including three schema
steps of its own (budget folders, placement, nested bills). Resolutions worth
knowing:

- The anchored-model migration becomes 10 -> 11; main's 7 -> 10 run first.
- Main's nextUnconfirmedOccurrenceOnOrAfter walked the calendar grid for
  every cost, which would have shown the slot-model "next due" for an
  anchored bill in the budget tree. Both walks are now generators and the
  lookup dispatches on anchoring, like occurrencesOf.
- A folder's "Bills due" summed only occurrences, and an anchored bill's
  charges are never occurrences, so a charged phone plan read as 0 due.
  budgetTree now adds an anchored bill's charges in the month.
- Main's RecurringSection (with RecurringSplitEditor) supersedes this
  branch's split editor; the anchoring rules are ported onto it: start-date
  validation via startDateNeedsDay, month precision hidden for lastCharge,
  the anchoring control locked once a cost has charges.
- PurchaseDialog keeps main's placement control. The "Recurring cost" select
  replaces main's "Unlink from bill" checkbox, since "None" does the same.
  A bill created from the dialog nests under its post like one from Settings.
- Dropped main's "bounded projection ignores invalid confirmations" test: it
  exercised slot confirmations on a lastCharge cost, which no longer exist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 16:51:17 +02:00

164 lines
5.0 KiB
TypeScript

import { Button } from "@/components/ui/button";
import { useDataset } from "../hooks/useDataset.ts";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { Label } from "@/components/ui/label";
import { Segmented } from "./Segmented.tsx";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import type { SplitDraft, SplitTarget } from "../purchaseForm.ts";
import { splitBalance } from "../purchaseForm.ts";
import type { Post } from "../../domain/types.ts";
interface Props<T extends SplitTarget> {
draft: T;
posts: Post[];
onChange: (next: T) => void;
}
export function SplitEditor<T extends SplitTarget>({ draft, posts, onChange }: Props<T>) {
const balance = splitBalance(draft, useDataset().settings.digits);
const fmt = useMoneyFormat();
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">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={draft.splits.length === 0}
onChange={(event) =>
onChange({
...draft,
splits: event.target.checked
? []
: posts[0]
? [{ postId: posts[0].id, value: 100, absorbsRemainder: true }]
: [],
})
}
disabled={posts.length === 0 && draft.splits.length === 0}
/>
No post — leave this purchase unassigned
</label>
{draft.splits.length > 0 && (
<>
<div className="flex items-center justify-between">
<Label>Split across posts</Label>
<Segmented
label="Split by"
value={draft.splitMode}
onChange={(splitMode) => onChange({ ...draft, splitMode })}
options={[
{ value: "percent", label: "Percentages" },
{ value: "fixed", label: "Amounts" },
]}
/>
</div>
{draft.splits.map((split, index) => (
<div key={index} className="flex items-center gap-2">
{/* `min-w-0` comes from NativeSelect. Without it `flex-1` could not
shrink below the widest option, and this row's min-content width
was what pushed the whole dialog past its own padding. */}
<NativeSelect
className="flex-1"
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>
))}
</NativeSelect>
<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 shrink-0 items-center gap-1.5 rounded-md py-1.5 text-xs"
title="This post absorbs rounding"
>
<input
type="radio"
name="absorber"
className="size-3.5"
checked={split.absorbsRemainder}
onChange={() => setAbsorber(index)}
/>
rounding
</label>
<Button
type="button"
size="xs"
variant="ghost"
className="-mr-2 shrink-0"
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."
: `${fmt.amount(balance)} ${unit} unassigned — the rounding post absorbs it.`}
</span>
</div>
</>
)}
</fieldset>
);
}