feat: choose recurring bill destinations

This commit is contained in:
Lucas Winther
2026-09-29 20:49:18 +02:00
parent 1e30671d76
commit 6de115843b
6 changed files with 318 additions and 20 deletions
@@ -0,0 +1,30 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { RecurringSplitEditor, type RecurringSplitSelection } from "./RecurringSplitEditor.tsx";
test("bill can split across active posts", () => {
const data = createSeedDataset("2026-09");
data.posts[0]!.archived = true;
const active = data.posts.filter(post => !post.archived);
let selection: RecurringSplitSelection = { splitMode: "percent", splits: [{ postId: active[0]!.id, value: 100, absorbsRemainder: true }] };
const container = document.createElement("div");
const root = createRoot(container);
const render = () => root.render(<RecurringSplitEditor {...selection} posts={active} onChange={next => { selection = next; render(); }} />);
act(render);
expect(container.querySelector(`option[value="${data.posts[0]!.id}"]`)).toBeNull();
act(() => { (Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Add another post"))!).click(); });
expect(selection.splits).toHaveLength(2);
const selects = container.querySelectorAll("select");
act(() => { selects[2]!.value = active[1]!.id; selects[2]!.dispatchEvent(new Event("change", { bubbles: true })); });
const radios = container.querySelectorAll('input[type="radio"]');
act(() => { (radios[1] as HTMLInputElement).click(); });
expect(selection.splits.map(split => split.postId)).toEqual([active[0]!.id, active[1]!.id]);
expect(selection.splits.map(split => split.absorbsRemainder)).toEqual([false, true]);
act(() => root.unmount());
});
@@ -0,0 +1,60 @@
import { Button } from "@/components/ui/button";
import { useId } from "react";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import type { Post, RecurringCost, Split } from "../../domain/types.ts";
export type RecurringSplitSelection = Pick<RecurringCost, "splitMode" | "splits">;
interface Props extends RecurringSplitSelection {
posts: Post[];
onChange: (next: RecurringSplitSelection) => void;
}
export function RecurringSplitEditor({ splitMode, splits, posts, onChange }: Props) {
const absorberGroup = useId();
function changeSplit(index: number, changes: Partial<Split>) {
onChange({ splitMode, splits: splits.map((split, i) => i === index ? { ...split, ...changes } : split) });
}
return (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Destination posts</legend>
{splits.length > 1 && (
<NativeSelect aria-label="Split by" value={splitMode} onChange={event => {
const mode = event.target.value as RecurringCost["splitMode"];
onChange({ splitMode: mode, splits: splits.map((split, index) => ({ ...split, value: mode === "percent" && index === 0 ? 100 : 0 })) });
}}>
<option value="percent">Percentages</option>
<option value="fixed">Amounts</option>
</NativeSelect>
)}
{splits.map((split, index) => (
<div key={index} className="flex flex-wrap items-center gap-2">
<NativeSelect
aria-label={index === 0 ? "Destination post" : `Destination post ${index + 1}`}
className="min-w-36 flex-1"
value={split.postId}
onChange={event => changeSplit(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>
{splits.length > 1 && (
<>
<Input aria-label={`Split value ${index + 1}`} type="number" step="0.01" className="w-24" value={split.value} onChange={event => changeSplit(index, { value: Number(event.target.value) || 0 })} />
<span className="text-xs text-budget-ink-muted">{splitMode === "percent" ? "%" : "amount"}</span>
<label className="flex items-center gap-1 text-xs"><input type="radio" name={absorberGroup} checked={split.absorbsRemainder} onChange={() => onChange({ splitMode, splits: splits.map((item, i) => ({ ...item, absorbsRemainder: i === index })) })} />rounding</label>
<Button type="button" size="xs" variant="ghost" onClick={() => {
const remaining = splits.filter((_, i) => i !== index);
if (!remaining.some(item => item.absorbsRemainder)) remaining[0] = { ...remaining[0]!, absorbsRemainder: true };
onChange({ splitMode, splits: remaining });
}}>remove</Button>
</>
)}
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={() => onChange({ splitMode, splits: [...splits, { postId: "", value: 0, absorbsRemainder: false }] })}>Add another post</Button>
</fieldset>
);
}