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) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5b879809d2
commit
6c0fc67f1c
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 <p>That post no longer exists.</p>;
|
||||
|
||||
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 (
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Post {postId} — {monthId}
|
||||
</h1>
|
||||
<section className="space-y-6">
|
||||
<header className="flex items-center gap-4">
|
||||
<Link to={`/post/${postId}/month/${addMonths(monthId, -1)}`} className="text-sm hover:underline">
|
||||
←
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{post.name} · {monthId}
|
||||
</h1>
|
||||
<Link to={`/post/${postId}/month/${addMonths(monthId, 1)}`} className="text-sm hover:underline">
|
||||
→
|
||||
</Link>
|
||||
<Link to={`/post/${postId}/year/${monthId.slice(0, 4)}`} className="ml-auto text-sm hover:underline">
|
||||
year view
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<dl className="flex gap-6 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Carried in</dt>
|
||||
<dd
|
||||
className={`font-money ${
|
||||
(row?.figures.carriedIn ?? 0) > 0
|
||||
? "text-surplus"
|
||||
: (row?.figures.carriedIn ?? 0) < 0
|
||||
? "text-overspend"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{formatSignedMoney(row?.figures.carriedIn ?? 0, base)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Allocated</dt>
|
||||
<dd className="font-money">{formatMoney(row?.figures.allocation ?? 0, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Spent</dt>
|
||||
<dd className="font-money">{formatMoney(row?.figures.charges ?? 0, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Remaining</dt>
|
||||
<dd
|
||||
className={`font-money ${(row?.figures.remaining ?? 0) < 0 ? "text-overspend" : ""}`}
|
||||
>
|
||||
{formatSignedMoney(row?.figures.remaining ?? 0, base)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-medium">This month</h2>
|
||||
<ul className="divide-y text-sm">
|
||||
{thisMonth.map(({ purchase, amount }) => (
|
||||
<li key={purchase.id} className="flex gap-3 py-2">
|
||||
<span className="flex-1">{purchase.description}</span>
|
||||
{purchase.splits.length > 1 && (
|
||||
<span className="text-xs text-muted-foreground">split</span>
|
||||
)}
|
||||
<span className="font-money">{formatMoney(amount, base)}</span>
|
||||
</li>
|
||||
))}
|
||||
{thisMonth.length === 0 && <li className="py-2 text-muted-foreground">Nothing yet.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{committed.length > 0 && (
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-medium">Already committed</h2>
|
||||
<ul className="divide-y text-sm text-muted-foreground">
|
||||
{committed.map(({ purchase, slice }) => (
|
||||
<li key={`${purchase.id}-${slice.month}`} className="flex gap-3 py-2">
|
||||
<span className="w-20">{slice.month}</span>
|
||||
<span className="flex-1">{purchase.description}</span>
|
||||
<span className="font-money">
|
||||
{formatMoney(slice.amount.amount, slice.amount.currency)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <p>That post no longer exists.</p>;
|
||||
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const view = yearView(dataset, Number(year));
|
||||
const row = view.rows.find((r) => r.post.id === postId);
|
||||
if (!row) return <p>No data for this post in {year}.</p>;
|
||||
|
||||
return (
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Post {postId} — Year {year}
|
||||
</h1>
|
||||
<section className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{post.name} · {year}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allocated {formatMoney(row.totalAllocation, base)} · spent{" "}
|
||||
{formatMoney(row.totalCharges, base)} · closing balance{" "}
|
||||
{formatSignedMoney(row.closingBalance, base)}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">Month</th>
|
||||
<th className="py-2 text-right">Carried in</th>
|
||||
<th className="py-2 text-right">Allocated</th>
|
||||
<th className="py-2 text-right">Spent</th>
|
||||
<th className="py-2 text-right">Remaining</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{view.months.map((month, i) => {
|
||||
const figures = row.byMonth[i]!;
|
||||
return (
|
||||
<tr key={month} className="border-b last:border-0">
|
||||
<td className="py-2">
|
||||
<Link to={`/post/${postId}/month/${month}`} className="hover:underline">
|
||||
{month}
|
||||
</Link>
|
||||
</td>
|
||||
<td
|
||||
className={`font-money py-2 text-right ${
|
||||
figures.carriedIn > 0
|
||||
? "text-surplus"
|
||||
: figures.carriedIn < 0
|
||||
? "text-overspend"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{formatSignedMoney(figures.carriedIn, base)}
|
||||
</td>
|
||||
<td className="font-money py-2 text-right">{formatAmount(figures.allocation)}</td>
|
||||
<td className="font-money py-2 text-right">{formatAmount(figures.charges)}</td>
|
||||
<td
|
||||
className={`font-money py-2 text-right font-medium ${
|
||||
figures.remaining < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatSignedMoney(figures.remaining, base)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <h1 className="text-2xl font-semibold">Year {year}</h1>;
|
||||
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 (
|
||||
<section className="space-y-6">
|
||||
<header className="flex items-center gap-4">
|
||||
<Link to={`/year/${numericYear - 1}`} className="text-sm hover:underline">
|
||||
← {numericYear - 1}
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold">{year}</h1>
|
||||
<Link to={`/year/${numericYear + 1}`} className="text-sm hover:underline">
|
||||
{numericYear + 1} →
|
||||
</Link>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(["spend", "balance"] as const).map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
size="sm"
|
||||
variant={mode === option ? "default" : "outline"}
|
||||
onClick={() => setMode(option)}
|
||||
>
|
||||
{option === "spend" ? "Spent" : "Closing balance"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Income {formatMoney(view.totalIncome, base)} · spent{" "}
|
||||
{formatMoney(view.totalCharges, base)}
|
||||
</p>
|
||||
|
||||
<YearMatrix view={view} mode={mode} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user