feat: choose recurring bill destinations
This commit is contained in:
@@ -2,6 +2,8 @@ import { test, expect, describe } from "bun:test";
|
||||
import * as actions from "./actions.ts";
|
||||
import { createSeedDataset } from "../domain/seed.ts";
|
||||
import { occurrencesByMonth } from "../domain/occurrences.ts";
|
||||
import { monthView } from "../domain/views.ts";
|
||||
import { createSnapshotStore } from "./snapshot.ts";
|
||||
import { exportDatasetJson, parseDatasetJson } from "../export/json.ts";
|
||||
import type { Dataset } from "../domain/types.ts";
|
||||
|
||||
@@ -570,6 +572,66 @@ describe("recurring costs", () => {
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
};
|
||||
|
||||
test("dedicated post and bill save together", () => {
|
||||
const data = draft();
|
||||
const folder = actions.addBudgetFolder(data, "Housing", null);
|
||||
const bill = actions.addRecurringCostWithPost(data, {
|
||||
name: "Rent", folderId: folder.id, archived: false,
|
||||
amount: { amount: 8000, currency: "DKK" }, startDate: "2026-09",
|
||||
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
|
||||
}, "Rent post");
|
||||
const post = data.posts.find(p => p.id === bill.splits[0]!.postId)!;
|
||||
expect(post.name).toBe("Rent post");
|
||||
expect(post.folderId).toBe(folder.id);
|
||||
expect(bill.folderId).toBe(folder.id);
|
||||
expect(bill.splits).toEqual([{ postId: post.id, value: 100, absorbsRemainder: true }]);
|
||||
});
|
||||
|
||||
test("a rejected store mutation leaves neither dedicated record", async () => {
|
||||
const data = draft();
|
||||
let writes = 0;
|
||||
const snapshot = createSnapshotStore({ read: async () => data, write: async () => { writes++; throw new Error("Disk full"); } }, "2026-01");
|
||||
await snapshot.load();
|
||||
await expect(snapshot.mutate(draft => {
|
||||
actions.addRecurringCostWithPost(draft, {
|
||||
name: "Rent", folderId: null, archived: false,
|
||||
amount: { amount: 8000, currency: "DKK" }, startDate: "2026-09",
|
||||
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
|
||||
}, "Rent post");
|
||||
})).rejects.toThrow("Disk full");
|
||||
expect(writes).toBe(1);
|
||||
expect(snapshot.get().posts.some(post => post.name === "Rent post")).toBe(false);
|
||||
expect(snapshot.get().recurring).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("editing split mode, values and absorber moves projected figures", () => {
|
||||
const data = draft();
|
||||
const other = data.posts[1]!;
|
||||
const bill = actions.addRecurringCost(data, { ...rentInput, amount: { amount: 30, currency: "DKK" }, startDate: "2026-09" });
|
||||
expect(monthView(data, "2026-09").rows.find(row => row.post.id === "housing")!.figures.expected).toBe(30);
|
||||
actions.updateRecurringCost(data, bill.id, {
|
||||
splitMode: "fixed",
|
||||
splits: [
|
||||
{ postId: "housing", value: 10, absorbsRemainder: false },
|
||||
{ postId: other.id, value: 0, absorbsRemainder: true },
|
||||
],
|
||||
});
|
||||
expect(bill.splitMode).toBe("fixed");
|
||||
expect(bill.splits.map(split => split.absorbsRemainder)).toEqual([false, true]);
|
||||
expect(monthView(data, "2026-09").rows.find(row => row.post.id === "housing")!.figures.expected).toBe(10);
|
||||
expect(monthView(data, "2026-09").rows.find(row => row.post.id === other.id)!.figures.expected).toBe(20);
|
||||
});
|
||||
|
||||
test("recurring split destinations must be active posts", () => {
|
||||
const data = draft();
|
||||
data.posts[1]!.archived = true;
|
||||
expect(() => actions.addRecurringCost(data, { ...rentInput, splits: [{ postId: "ghost", value: 100, absorbsRemainder: true }] })).toThrow(/post/i);
|
||||
expect(() => actions.addRecurringCost(data, { ...rentInput, splits: [{ postId: data.posts[1]!.id, value: 100, absorbsRemainder: true }] })).toThrow(/archived/i);
|
||||
const bill = actions.addRecurringCost(data, rentInput);
|
||||
expect(() => actions.updateRecurringCost(data, bill.id, { splits: [{ postId: "ghost", value: 100, absorbsRemainder: true }] })).toThrow(/post/i);
|
||||
expect(bill.splits).toEqual(rentInput.splits);
|
||||
});
|
||||
|
||||
test("adding assigns an id and an order", () => {
|
||||
const data = draft();
|
||||
const first = actions.addRecurringCost(data, rentInput);
|
||||
|
||||
@@ -176,6 +176,14 @@ function roundSplits(
|
||||
}));
|
||||
}
|
||||
|
||||
function requireRecurringSplitDestinations(draft: Dataset, splits: Split[]): void {
|
||||
if (splits.length === 0) throw new Error("A bill needs a destination post");
|
||||
for (const split of splits) {
|
||||
const post = requirePost(draft, split.postId);
|
||||
if (post.archived) throw new Error(`Post ${post.name} is archived`);
|
||||
}
|
||||
}
|
||||
|
||||
function roundSchedule(draft: Dataset, schedule: Schedule | null): Schedule | null {
|
||||
if (!schedule) return null;
|
||||
return {
|
||||
@@ -573,6 +581,7 @@ export function addRecurringCost(
|
||||
const recurrence = requireRecurrence(cost.recurrence);
|
||||
requireStartDateGranularity(recurrence, cost.startDate);
|
||||
if (cost.folderId !== null) requireFolder(draft, cost.folderId);
|
||||
requireRecurringSplitDestinations(draft, cost.splits);
|
||||
const created: RecurringCost = {
|
||||
...cost,
|
||||
id: newId(),
|
||||
@@ -585,6 +594,22 @@ export function addRecurringCost(
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Creates the dedicated envelope and its bill in one store mutation. */
|
||||
export function addRecurringCostWithPost(
|
||||
draft: Dataset,
|
||||
cost: Omit<RecurringCost, "id" | "order" | "splits" | "splitMode">,
|
||||
newPostName: string,
|
||||
): RecurringCost {
|
||||
const name = newPostName.trim();
|
||||
if (!name) throw new Error("Post name cannot be blank");
|
||||
const post = addPost(draft, name, cost.amount.currency, cost.folderId);
|
||||
return addRecurringCost(draft, {
|
||||
...cost,
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `archived` and `endedFrom` are excluded on purpose: they are a coherent
|
||||
* pair (`archived` mirrors "`endedFrom` is set") maintained ONLY by
|
||||
@@ -617,6 +642,7 @@ export function updateRecurringCost(
|
||||
resolved.amount = roundMoneyValue(draft, changes.amount);
|
||||
}
|
||||
if (changes.splits) {
|
||||
requireRecurringSplitDestinations(draft, changes.splits);
|
||||
// Either field may be absent from a partial update; the stored cost
|
||||
// supplies whichever one is, so the mode and currency always agree with
|
||||
// the values being rounded.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+72
@@ -1,5 +1,77 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isValidEndedFrom, isValidStartDate, normalizedStartDateFor } from "./RecurringSection.tsx";
|
||||
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 { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createSeedDataset } from "../../../domain/seed.ts";
|
||||
import { store } from "../../../store/index.ts";
|
||||
import { addRecurringCost } from "../../../store/actions.ts";
|
||||
import { RecurringSection } from "./RecurringSection.tsx";
|
||||
|
||||
test("bill requires an explicit destination", async () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
data.posts[0]!.archived = true;
|
||||
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
|
||||
store.get = () => data;
|
||||
store.subscribe = () => () => {};
|
||||
store.mutate = async change => { change(data); };
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
document.body.append(container);
|
||||
try {
|
||||
act(() => root.render(<RecurringSection />));
|
||||
const name = container.querySelector('input[placeholder="New cost name"]') as HTMLInputElement;
|
||||
act(() => { name.focus(); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(name, "Phone"); name.dispatchEvent(new InputEvent("input", { bubbles: true, data: "Phone", inputType: "insertText" })); });
|
||||
const add = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Add recurring cost")!;
|
||||
expect(add.disabled).toBe(true);
|
||||
const chosen = data.posts[1]!;
|
||||
const destination = container.querySelector('select[aria-label="Destination post"]') as HTMLSelectElement;
|
||||
expect(destination.querySelector(`option[value="${data.posts[0]!.id}"]`)).toBeNull();
|
||||
act(() => { destination.value = chosen.id; destination.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
const amount = container.querySelector('input[aria-label="New bill amount"]') as HTMLInputElement;
|
||||
act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(amount, "30$"); amount.dispatchEvent(new InputEvent("input", { bubbles: true, data: "30$", inputType: "insertText" })); });
|
||||
expect(add.disabled).toBe(false);
|
||||
await act(async () => { add.click(); });
|
||||
const saved = data.recurring[0]!;
|
||||
expect(saved.splits[0]!.postId).toBe(chosen.id);
|
||||
expect(saved.amount).toEqual({ amount: 30, currency: "USD" });
|
||||
} finally {
|
||||
act(() => root.unmount()); container.remove();
|
||||
store.get = original.get; store.subscribe = original.subscribe; store.mutate = original.mutate;
|
||||
}
|
||||
});
|
||||
|
||||
test("failed destination edit keeps its editor and error visible", async () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
const cost = addRecurringCost(data, {
|
||||
name: "Phone", folderId: null, archived: false,
|
||||
amount: { amount: 30, currency: "DKK" }, startDate: "2026-09",
|
||||
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
|
||||
splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
|
||||
});
|
||||
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
|
||||
store.get = () => data;
|
||||
store.subscribe = () => () => {};
|
||||
store.mutate = async () => { throw new Error("Disk full"); };
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
document.body.append(container);
|
||||
try {
|
||||
act(() => root.render(<RecurringSection />));
|
||||
const edit = container.querySelector(`#bill-${cost.id}`) as HTMLButtonElement;
|
||||
expect(edit).not.toBeNull();
|
||||
act(() => edit.click());
|
||||
await act(async () => { (Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Save destinations")!).click(); });
|
||||
expect(container.textContent).toContain("Save destinations");
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
|
||||
expect(cost.splits[0]!.postId).toBe(data.posts[0]!.id);
|
||||
} finally {
|
||||
act(() => root.unmount()); container.remove();
|
||||
store.get = original.get; store.subscribe = original.subscribe; store.mutate = original.mutate;
|
||||
}
|
||||
});
|
||||
|
||||
// C2: the "Starts" field must never be able to persist an empty or
|
||||
// malformed value — `isValidStartDate` is the guard the onBlur handler uses
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
@@ -6,6 +6,7 @@ import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { useMutate } from "../../hooks/useMutate.ts";
|
||||
import {
|
||||
addRecurringCost,
|
||||
addRecurringCostWithPost,
|
||||
endRecurringCost,
|
||||
moveRecurringCost,
|
||||
restoreRecurringCost,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { parseMoneyInput } from "../../moneyInput.ts";
|
||||
import { currentMonth } from "../../../store/index.ts";
|
||||
import { Section } from "../../components/Section.tsx";
|
||||
import { RecurringSplitEditor, type RecurringSplitSelection } from "../../components/RecurringSplitEditor.tsx";
|
||||
import { monthOf } from "../../../domain/months.ts";
|
||||
import { toDayOrdinal } from "../../../domain/days.ts";
|
||||
import type { IsoDate, Recurrence, RecurringCost } from "../../../domain/types.ts";
|
||||
@@ -97,9 +99,18 @@ export function RecurringSection() {
|
||||
const dataset = useDataset();
|
||||
const { mutate, error } = useMutate();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newAmount, setNewAmount] = useState("0");
|
||||
const [destinationKind, setDestinationKind] = useState<"existing" | "dedicated">("existing");
|
||||
const [newPostName, setNewPostName] = useState("");
|
||||
const [folderId, setFolderId] = useState<string | null>(null);
|
||||
const [newSelection, setNewSelection] = useState<RecurringSplitSelection>({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] });
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editSelection, setEditSelection] = useState<RecurringSplitSelection | null>(null);
|
||||
const ordered = [...dataset.recurring].sort((a, b) => a.order - b.order);
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const firstPost = [...dataset.posts].sort((a, b) => a.order - b.order)[0];
|
||||
const activePosts = dataset.posts.filter(post => !post.archived).sort((a, b) => a.order - b.order);
|
||||
const parsedNewAmount = parseMoneyInput(newAmount, dataset.currencies, base);
|
||||
const validNewSplits = newSelection.splits.length > 0 && newSelection.splits.every(split => activePosts.some(post => post.id === split.postId));
|
||||
|
||||
function amountCell(cost: RecurringCost) {
|
||||
return (
|
||||
@@ -222,9 +233,9 @@ export function RecurringSection() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ordered.map((cost, index) => (
|
||||
{ordered.map((cost, index) => (<Fragment key={cost.id}>
|
||||
<tr
|
||||
key={cost.id}
|
||||
data-bill-id={cost.id}
|
||||
className={`border-b last:border-0 ${cost.archived ? "opacity-50" : ""}`}
|
||||
>
|
||||
<td className="py-2">
|
||||
@@ -310,6 +321,10 @@ export function RecurringSection() {
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button id={`bill-${cost.id}`} size="xs" variant="ghost" onClick={() => {
|
||||
setEditingId(cost.id);
|
||||
setEditSelection({ splitMode: cost.splitMode, splits: cost.splits.map(split => ({ ...split })) });
|
||||
}}>Edit</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
@@ -343,7 +358,21 @@ export function RecurringSection() {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{editingId === cost.id && editSelection && (
|
||||
<tr key={`${cost.id}-editor`}><td colSpan={7} className="py-3">
|
||||
<div className="space-y-3 rounded-md border border-budget-rule p-3">
|
||||
<RecurringSplitEditor {...editSelection} posts={activePosts} onChange={setEditSelection} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" disabled={!editSelection.splits.every(split => activePosts.some(post => post.id === split.postId))} onClick={() => {
|
||||
const selected = { splitMode: editSelection.splitMode, splits: editSelection.splits.map(split => ({ ...split })) };
|
||||
void mutate(draft => updateRecurringCost(draft, cost.id, selected)).then(saved => { if (saved) setEditingId(null); });
|
||||
}}>Save destinations</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</td></tr>
|
||||
)}
|
||||
</Fragment>))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -363,35 +392,54 @@ export function RecurringSection() {
|
||||
className="w-48"
|
||||
placeholder="New cost name"
|
||||
value={newName}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
onInput={(event) => setNewName(event.currentTarget.value)}
|
||||
/>
|
||||
<Input className="w-28" type="text" aria-label="New bill amount" value={newAmount} onInput={event => setNewAmount(event.currentTarget.value)} />
|
||||
<NativeSelect aria-label="Bill folder" value={folderId ?? ""} onChange={event => setFolderId(event.target.value || null)}>
|
||||
<option value="">Unfiled</option>
|
||||
{dataset.folders.map(folder => <option key={folder.id} value={folder.id}>{folder.name}</option>)}
|
||||
</NativeSelect>
|
||||
<NativeSelect aria-label="Destination type" value={destinationKind} onChange={event => setDestinationKind(event.target.value as "existing" | "dedicated")}>
|
||||
<option value="existing">Use existing post</option>
|
||||
<option value="dedicated">Create dedicated post</option>
|
||||
</NativeSelect>
|
||||
{destinationKind === "dedicated" ? (
|
||||
<Input aria-label="Dedicated post name" placeholder="New post name" value={newPostName} onChange={event => setNewPostName(event.target.value)} />
|
||||
) : (
|
||||
<RecurringSplitEditor {...newSelection} posts={activePosts} onChange={setNewSelection} />
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={newName.trim() === "" || !firstPost}
|
||||
disabled={newName.trim() === "" || !parsedNewAmount || (destinationKind === "dedicated" ? !newPostName.trim() : !validNewSplits)}
|
||||
onClick={() => {
|
||||
const name = newName.trim();
|
||||
void mutate((draft) =>
|
||||
addRecurringCost(draft, {
|
||||
const amount = parsedNewAmount;
|
||||
if (!amount) return;
|
||||
const selectedFolder = folderId;
|
||||
const selectedKind = destinationKind;
|
||||
const dedicatedName = newPostName.trim();
|
||||
const selectedSplits = newSelection.splits.map(split => ({ ...split }));
|
||||
const selectedMode = newSelection.splitMode;
|
||||
void mutate((draft) => {
|
||||
const input = {
|
||||
name,
|
||||
folderId: null,
|
||||
folderId: selectedFolder,
|
||||
archived: false,
|
||||
amount: { amount: 0, currency: base },
|
||||
amount,
|
||||
startDate: currentMonth,
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: firstPost!.id, value: 100, absorbsRemainder: true }],
|
||||
}),
|
||||
recurrence: { kind: "everyNMonths" as const, n: 1 },
|
||||
anchoring: "calendar" as const,
|
||||
};
|
||||
if (selectedKind === "dedicated") addRecurringCostWithPost(draft, input, dedicatedName);
|
||||
else addRecurringCost(draft, { ...input, splitMode: selectedMode, splits: selectedSplits });
|
||||
},
|
||||
).then((saved) => {
|
||||
if (saved) setNewName("");
|
||||
if (saved) { setNewName(""); setNewPostName(""); setNewAmount("0"); setNewSelection({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] }); }
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add recurring cost
|
||||
</Button>
|
||||
{!firstPost && (
|
||||
<span className="text-xs text-budget-ink-muted">Create a post first.</span>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user