feat: choose recurring bill destinations
This commit is contained in:
1 parent
1e30671d76
commit
6de115843b
6 files changed
+318
-20
No files matched your search
@@ -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