feat(ui): add year matrix and per-post month and year views

Future finance-plan slices are surfaced as already-committed so a plan is
visible before it lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-09-01 20:59:59 +02:00
co-authored by Claude Opus 5
parent 5b879809d2
commit 6c0fc67f1c
4 changed files with 307 additions and 13 deletions
+75
View File
@@ -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 (
<div className="overflow-x-auto">
<table className="w-full min-w-[48rem] text-sm">
<thead className="border-b text-left text-muted-foreground">
<tr>
<th className="py-2">Post</th>
{view.months.map((month) => (
<th key={month} className="py-2 text-right font-normal">
{month.slice(5)}
</th>
))}
<th className="py-2 text-right">Total</th>
</tr>
</thead>
<tbody>
<tr className="border-b text-muted-foreground">
<td className="py-2">Income</td>
{view.incomeByMonth.map((income, i) => (
<td key={i} className="font-money py-2 text-right">
{formatAmount(income)}
</td>
))}
<td className="font-money py-2 text-right">{formatAmount(view.totalIncome)}</td>
</tr>
{view.rows.map((row) => (
<tr key={row.post.id} className="border-b last:border-0">
<td className="py-2">
<Link to={`/post/${row.post.id}/year/${view.year}`} className="hover:underline">
{row.post.name}
</Link>
</td>
{row.byMonth.map((figures, i) => {
const value = mode === "spend" ? figures.charges : figures.remaining;
return (
<td
key={i}
className={`font-money py-2 text-right ${
mode === "balance" && value < 0 ? "text-overspend" : ""
}`}
>
{formatAmount(value)}
</td>
);
})}
<td
className={`font-money py-2 text-right font-medium ${
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
}`}
>
{formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}