diff --git a/src/ui/components/YearMatrix.tsx b/src/ui/components/YearMatrix.tsx new file mode 100644 index 0000000..4b69cb2 --- /dev/null +++ b/src/ui/components/YearMatrix.tsx @@ -0,0 +1,75 @@ +import { Link } from "react-router"; +import { formatAmount } from "../format.ts"; +import type { YearViewModel } from "../../domain/views.ts"; + +interface Props { + view: YearViewModel; + mode: "spend" | "balance"; +} + +/** + * Posts x 12 months, plus a total column. A quiet table by design-contract + * rule: the carry meter is the month view's signature element and does not + * belong here. Every figure comes from `yearView`'s rows; nothing here + * recomputes rollover. + */ +export function YearMatrix({ view, mode }: Props) { + return ( +
+ + + + + {view.months.map((month) => ( + + ))} + + + + + + + {view.incomeByMonth.map((income, i) => ( + + ))} + + + + {view.rows.map((row) => ( + + + {row.byMonth.map((figures, i) => { + const value = mode === "spend" ? figures.charges : figures.remaining; + return ( + + ); + })} + + + ))} + +
Post + {month.slice(5)} + Total
Income + {formatAmount(income)} + {formatAmount(view.totalIncome)}
+ + {row.post.name} + + + {formatAmount(value)} + + {formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)} +
+
+ ); +} diff --git a/src/ui/routes/PostMonthRoute.tsx b/src/ui/routes/PostMonthRoute.tsx index 53d5456..c6c1ca9 100644 --- a/src/ui/routes/PostMonthRoute.tsx +++ b/src/ui/routes/PostMonthRoute.tsx @@ -1,10 +1,119 @@ -import { useParams } from "react-router"; +import { Link, useParams } from "react-router"; +import { useDataset } from "../hooks/useDataset.ts"; +import { monthView } from "../../domain/views.ts"; +import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../../domain/charges.ts"; +import { addMonths, compareMonths } from "../../domain/months.ts"; +import { formatMoney, formatSignedMoney } from "../format.ts"; export function PostMonthRoute() { - const { postId, monthId } = useParams(); + const { postId = "", monthId = "" } = useParams(); + const dataset = useDataset(); + const post = dataset.posts.find((p) => p.id === postId); + if (!post) return

That post no longer exists.

; + + const base = dataset.settings.baseCurrency; + const row = monthView(dataset, monthId).rows.find((r) => r.post.id === postId); + + const thisMonth = dataset.purchases.flatMap((purchase) => { + const charges = chargesForPurchaseInMonth(purchase, monthId, base, dataset.fxRates); + const mine = charges.find((c) => c.postId === postId); + return mine ? [{ purchase, amount: mine.amount }] : []; + }); + + // Future finance-plan slices, for purchases split with this post, that have + // not yet arrived at `monthId`. This is what surfaces a committed plan + // before it lands. Strictly-after so the current month's slice stays in + // "This month" rather than also appearing here. + const committed = dataset.purchases.flatMap((purchase) => { + if (!purchase.schedule) return []; + if (!purchase.splits.some((s) => s.postId === postId)) return []; + return purchase.schedule.slices + .filter((slice) => compareMonths(slice.month, monthId) > 0) + .map((slice) => ({ purchase, slice })); + }); + return ( -

- Post {postId} — {monthId} -

+
+
+ + ← + +

+ {post.name} · {monthId} +

+ + → + + + year view + +
+ +
+
+
Carried in
+
0 + ? "text-surplus" + : (row?.figures.carriedIn ?? 0) < 0 + ? "text-overspend" + : "" + }`} + > + {formatSignedMoney(row?.figures.carriedIn ?? 0, base)} +
+
+
+
Allocated
+
{formatMoney(row?.figures.allocation ?? 0, base)}
+
+
+
Spent
+
{formatMoney(row?.figures.charges ?? 0, base)}
+
+
+
Remaining
+
+ {formatSignedMoney(row?.figures.remaining ?? 0, base)} +
+
+
+ +
+

This month

+ +
+ + {committed.length > 0 && ( +
+

Already committed

+ +
+ )} +
); } diff --git a/src/ui/routes/PostYearRoute.tsx b/src/ui/routes/PostYearRoute.tsx index 6243ef3..6d719c6 100644 --- a/src/ui/routes/PostYearRoute.tsx +++ b/src/ui/routes/PostYearRoute.tsx @@ -1,10 +1,77 @@ -import { useParams } from "react-router"; +import { Link, useParams } from "react-router"; +import { useDataset } from "../hooks/useDataset.ts"; +import { yearView } from "../../domain/views.ts"; +import { formatAmount, formatMoney, formatSignedMoney } from "../format.ts"; export function PostYearRoute() { - const { postId, year } = useParams(); + const { postId = "", year = "" } = useParams(); + const dataset = useDataset(); + const post = dataset.posts.find((p) => p.id === postId); + if (!post) return

That post no longer exists.

; + + const base = dataset.settings.baseCurrency; + const view = yearView(dataset, Number(year)); + const row = view.rows.find((r) => r.post.id === postId); + if (!row) return

No data for this post in {year}.

; + return ( -

- Post {postId} — Year {year} -

+
+

+ {post.name} · {year} +

+

+ Allocated {formatMoney(row.totalAllocation, base)} · spent{" "} + {formatMoney(row.totalCharges, base)} · closing balance{" "} + {formatSignedMoney(row.closingBalance, base)} +

+ +
+ + + + + + + + + + + + {view.months.map((month, i) => { + const figures = row.byMonth[i]!; + return ( + + + + + + + + ); + })} + +
MonthCarried inAllocatedSpentRemaining
+ + {month} + + 0 + ? "text-surplus" + : figures.carriedIn < 0 + ? "text-overspend" + : "" + }`} + > + {formatSignedMoney(figures.carriedIn, base)} + {formatAmount(figures.allocation)}{formatAmount(figures.charges)} + {formatSignedMoney(figures.remaining, base)} +
+
+
); } diff --git a/src/ui/routes/YearRoute.tsx b/src/ui/routes/YearRoute.tsx index f231462..a2bde23 100644 --- a/src/ui/routes/YearRoute.tsx +++ b/src/ui/routes/YearRoute.tsx @@ -1,6 +1,49 @@ -import { useParams } from "react-router"; +import { useState } from "react"; +import { Link, useParams } from "react-router"; +import { Button } from "@/components/ui/button"; +import { useDataset } from "../hooks/useDataset.ts"; +import { yearView } from "../../domain/views.ts"; +import { formatMoney } from "../format.ts"; +import { YearMatrix } from "../components/YearMatrix.tsx"; export function YearRoute() { - const { year } = useParams(); - return

Year {year}

; + const { year = "" } = useParams(); + const dataset = useDataset(); + const [mode, setMode] = useState<"spend" | "balance">("spend"); + const numericYear = Number(year); + const view = yearView(dataset, numericYear); + const base = dataset.settings.baseCurrency; + + return ( +
+
+ + ← {numericYear - 1} + +

{year}

+ + {numericYear + 1} → + +
+ {(["spend", "balance"] as const).map((option) => ( + + ))} +
+
+ +

+ Income {formatMoney(view.totalIncome, base)} · spent{" "} + {formatMoney(view.totalCharges, base)} +

+ + +
+ ); }