feat: show budget folders on month page
This commit is contained in:
1 parent
e715160235
commit
d3c14857fc
5 files changed
+143
-21
No files matched your search
@@ -0,0 +1,52 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { budgetTree, type BudgetNode } from "../../domain/budgetTree.ts";
|
||||
import type { MonthId, Recurrence } from "../../domain/types.ts";
|
||||
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";
|
||||
|
||||
function cadence(rule: Recurrence) {
|
||||
const unit = rule.kind === "everyNMonths" ? "month" : rule.kind === "everyNWeeks" ? "week" : "day";
|
||||
return `Every ${rule.n} ${unit}${rule.n === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/** A mixed placement tree; only post rows carry envelope balances. */
|
||||
export function BudgetTree({ monthId, rows, onChangeRule }: { monthId: MonthId; rows: MonthPostRow[]; onChangeRule?: (postId: string) => void }) {
|
||||
const dataset = useDataset();
|
||||
const fmt = useMoneyFormat();
|
||||
const tree = budgetTree(dataset, monthId, rows);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const toggle = (id: string) => setExpanded(previous => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; });
|
||||
const folderHeading = (node: Extract<BudgetNode, { kind: "folder" }>) => {
|
||||
const open = expanded.has(node.folder.id);
|
||||
const Icon = open ? ChevronDown : ChevronRight;
|
||||
return <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||
<button type="button" aria-label={node.folder.name} aria-expanded={open} onClick={() => toggle(node.folder.id)} className="flex items-center gap-2 rounded-sm py-2 font-medium hover:text-budget-accent focus-visible:outline-2 focus-visible:outline-ring"><Icon size={16} aria-hidden="true" />{node.folder.name}</button>
|
||||
<span className="text-xs text-budget-ink-muted">Bills due <span className="font-money ml-2">{fmt.money(node.billsDue, dataset.settings.baseCurrency)}</span></span>
|
||||
</div>;
|
||||
};
|
||||
const billContent = (node: Extract<BudgetNode, { kind: "bill" }>) => <div className="space-y-1 py-3 text-sm">
|
||||
<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 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 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>
|
||||
{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>
|
||||
<ul className="space-y-3 sm:hidden">{render(tree.roots, true)}{tree.unfiled.length > 0 && <li className="border-b border-budget-rule pb-2 pt-4 font-medium">Unfiled</li>}{render(tree.unfiled, true)}</ul>
|
||||
</>;
|
||||
}
|
||||
Reference in new issue
Block a user