feat: choose recurring bill destinations

This commit is contained in:
Lucas Winther committed 2026-09-29 20:49:18 +02:00
1 parent 1e30671d76
commit 6de115843b
6 files changed
+318 -20

No files matched your search

+68 -20
View File
@@ -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>
);