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]>
164 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|