feat: place purchases and bills in budget hierarchy
This commit is contained in:
1 parent
b0e8145d2e
commit
85a21c58eb
19 files changed
+281
-70
No files matched your search
@@ -7,6 +7,7 @@ import type { MonthPostRow } from "../../domain/views.ts";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
|
||||
import { DesktopPostBody, MobilePostRow } from "./PostTable.tsx";
|
||||
import { PurchaseDialog } from "./PurchaseDialog.tsx";
|
||||
|
||||
function cadence(rule: Recurrence) {
|
||||
const unit = rule.kind === "everyNMonths" ? "month" : rule.kind === "everyNWeeks" ? "week" : "day";
|
||||
@@ -32,18 +33,19 @@ export function BudgetTree({ monthId, rows, onChangeRule }: { monthId: MonthId;
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2"><span className="font-medium">{node.cost.name} <span className="ml-2 font-money font-normal">{fmt.money(node.cost.amount.amount, node.cost.amount.currency)}</span></span><Link className="text-xs underline underline-offset-2 hover:text-budget-accent" to={`/settings#bill-${node.cost.id}`}>Edit</Link></div>
|
||||
<p className="text-xs text-budget-ink-muted">{cadence(node.cost.recurrence)} · Assigned to {node.cost.splits.map(split => dataset.posts.find(post => post.id === split.postId)?.name).join(", ")} · {node.nextOccurrence ? `Next ${node.nextOccurrence.date}` : "Ended"}</p>
|
||||
</div>;
|
||||
const purchaseContent = (node: Extract<BudgetNode, { kind: "purchase" }>) => <div className="flex flex-wrap items-center justify-between gap-2 py-3 text-sm"><span>{node.purchase.description} <span className="ml-2 font-money">{fmt.money(node.amount.amount, node.amount.currency)}</span></span><PurchaseDialog monthId={monthId} purchase={node.purchase} trigger={<button type="button" className="text-xs underline underline-offset-2 hover:text-budget-accent">Edit</button>} /></div>;
|
||||
const render = (nodes: BudgetNode[], mobile: boolean, depth = 0): ReactNode[] => nodes.flatMap(node => {
|
||||
const indent = Math.min(depth, 4) * 16;
|
||||
const id = node.kind === "folder" ? node.folder.id : node.kind === "post" ? node.row.post.id : node.cost.id;
|
||||
if (node.kind === "post") return [mobile ? <MobilePostRow key={id} row={node.row} indent={indent} monthId={monthId} onChangeRule={onChangeRule} /> : <DesktopPostBody key={id} row={node.row} indent={indent} monthId={monthId} onChangeRule={onChangeRule} />];
|
||||
const name = node.kind === "folder" ? node.folder.name : node.cost.name;
|
||||
const content = node.kind === "folder" ? folderHeading(node) : billContent(node);
|
||||
const id = node.kind === "folder" ? node.folder.id : node.kind === "post" ? node.row.post.id : node.kind === "purchase" ? node.purchase.id : node.cost.id;
|
||||
if (node.kind === "post") return [mobile ? <MobilePostRow key={id} row={node.row} indent={indent} monthId={monthId} onChangeRule={onChangeRule} /> : <DesktopPostBody key={id} row={node.row} indent={indent} monthId={monthId} onChangeRule={onChangeRule} />, ...render(node.children, mobile, depth + 1)];
|
||||
const name = node.kind === "folder" ? node.folder.name : node.kind === "purchase" ? node.purchase.description : node.cost.name;
|
||||
const content = node.kind === "folder" ? folderHeading(node) : node.kind === "purchase" ? purchaseContent(node) : billContent(node);
|
||||
const style = { paddingInlineStart: `${indent}px` };
|
||||
const item = mobile ? <li key={id} data-budget-item={id} data-budget-name={name} style={style}>{content}</li> : <tbody key={id} data-budget-item={id} data-budget-name={name}><tr><td colSpan={6} className={node.kind === "folder" ? "border-b border-budget-rule pt-3" : "border-b border-budget-rule"} style={style}>{content}</td></tr></tbody>;
|
||||
return [item, ...(node.kind === "folder" && expanded.has(id) ? render(node.children, mobile, depth + 1) : [])];
|
||||
});
|
||||
return <>
|
||||
<div className="hidden overflow-x-auto sm:block"><table className="w-full text-sm"><thead className="text-left"><tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">{["Post / bill", "Carried in", "Allocated", "Spent", "Remaining", "Projected"].map((label, index) => <th key={label} className={`py-2 font-medium ${index ? "pl-6 text-right" : ""}`}>{label}</th>)}</tr></thead>
|
||||
<div className="hidden overflow-x-auto sm:block"><table className="w-full text-sm"><thead className="text-left"><tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">{["Budget item", "Carried in", "Allocated", "Spent", "Remaining", "Projected"].map((label, index) => <th key={label} className={`py-2 font-medium ${index ? "pl-6 text-right" : ""}`}>{label}</th>)}</tr></thead>
|
||||
{render(tree.roots, false)}
|
||||
{tree.unfiled.length > 0 && <tbody><tr><th colSpan={6} className="border-b border-budget-rule pb-2 pt-5 text-left font-medium">Unfiled</th></tr></tbody>}{render(tree.unfiled, false)}
|
||||
</table></div>
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { parseMoneyInput } from "../moneyInput.ts";
|
||||
import { useMutate } from "../hooks/useMutate.ts";
|
||||
import { addPurchase, cancelScheduleFrom, updatePurchase } from "../../store/actions.ts";
|
||||
import { addPurchase, cancelScheduleFrom, placePurchase, updatePurchase } from "../../store/actions.ts";
|
||||
import type { MonthId, Purchase } from "../../domain/types.ts";
|
||||
|
||||
interface Props {
|
||||
@@ -47,9 +47,13 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
const activePosts = dataset.posts
|
||||
.filter((p) => !p.archived)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const editablePosts = dataset.posts
|
||||
.filter((p) => !p.archived || purchase?.splits.some(split => split.postId === p.id))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [detachBill, setDetachBill] = useState(false);
|
||||
const [placement, setPlacement] = useState(() => purchase?.placement ? purchase.placement.folderId ?? "__unfiled" : "__ledger");
|
||||
const [draft, setDraft] = useState<PurchaseDraft>(() =>
|
||||
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
|
||||
);
|
||||
@@ -70,6 +74,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
|
||||
function reset() {
|
||||
setDetachBill(false);
|
||||
setPlacement(purchase?.placement ? purchase.placement.folderId ?? "__unfiled" : "__ledger");
|
||||
setDraft(
|
||||
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
|
||||
);
|
||||
@@ -88,10 +93,14 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
const created = bulkMode ? toPurchases(bulk) : null;
|
||||
void mutate((data) => {
|
||||
if (created) for (const p of created) addPurchase(data, p);
|
||||
else if (purchase) updatePurchase(data, purchase.id, {
|
||||
...toPurchase(draft),
|
||||
...(detachBill ? { source: undefined } : {}),
|
||||
});
|
||||
else if (purchase) {
|
||||
updatePurchase(data, purchase.id, {
|
||||
...toPurchase(draft),
|
||||
...(detachBill ? { source: undefined } : {}),
|
||||
});
|
||||
const destination = placement === "__ledger" ? undefined : placement === "__unfiled" ? null : placement;
|
||||
if (destination !== purchase.placement?.folderId || (destination === undefined) !== (purchase.placement === undefined)) placePurchase(data, purchase.id, destination);
|
||||
}
|
||||
else addPurchase(data, toPurchase(draft));
|
||||
}).then((saved) => {
|
||||
if (!saved) return;
|
||||
@@ -304,7 +313,17 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SplitEditor draft={draft} posts={activePosts} onChange={setDraft} />
|
||||
<SplitEditor draft={draft} posts={purchase ? editablePosts : activePosts} onChange={setDraft} />
|
||||
|
||||
{purchase && <div className="space-y-1">
|
||||
<Label htmlFor="purchase-placement">Budget listing</Label>
|
||||
<NativeSelect id="purchase-placement" value={placement} onChange={event => setPlacement(event.target.value)}>
|
||||
<option value="__ledger">Monthly purchases only</option>
|
||||
<option value="__unfiled">Own row in Unfiled</option>
|
||||
{dataset.folders.map(folder => <option key={folder.id} value={folder.id}>Own row in {folder.name}</option>)}
|
||||
</NativeSelect>
|
||||
<p className="text-xs text-budget-ink-muted">Post splits above determine which balances this purchase charges.</p>
|
||||
</div>}
|
||||
|
||||
{purchase?.source && (
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
|
||||
Reference in new issue
Block a user