feat(ui): add month view with income entry and post table
Negative remaining balances are shown in a warning treatment; nothing blocks going over budget. Applies the Tasks 18-23 design contract: --paper/--ink/--rule/--accent/ --surplus/--overspend tokens and the three type-role font variables in src/index.css, --font-money on every money figure, and the carry meter (a 3px hard-stopped gradient beneath each PostTable row) encoding carried-in surplus, allocation track, spend fill and overrun. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ccb97818d7
commit
dd612565cb
16 files changed
+914
-75
No files matched your search
@@ -0,0 +1,132 @@
|
||||
import { Fragment } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatMoney, formatSignedMoney } from "../format.ts";
|
||||
import type { MonthPostRow } from "../../domain/views.ts";
|
||||
import type { PostMonthFigures } from "../../domain/fold.ts";
|
||||
import type { Currency, MonthId } from "../../domain/types.ts";
|
||||
|
||||
interface Props {
|
||||
monthId: MonthId;
|
||||
baseCurrency: Currency;
|
||||
rows: MonthPostRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry meter: the design contract's signature element. A 3px rule beneath
|
||||
* each post row, encoding the rollover fold's four numbers as a single
|
||||
* hard-stopped gradient so envelope rollover is visible without reading
|
||||
* digits.
|
||||
*
|
||||
* Segments, left to right, sized against a per-row scale of
|
||||
* (carried-in surplus + max(allocation, charges)):
|
||||
* - --surplus : carried-in surplus (omitted when carriedIn <= 0)
|
||||
* - --accent : spend within this month's allocation, filled from the
|
||||
* track's left edge
|
||||
* - --rule : the unspent remainder of the allocation track
|
||||
* - --overspend: spend beyond the allocation, past the track's end
|
||||
*/
|
||||
function carryMeterBackground(figures: PostMonthFigures): string {
|
||||
const surplus = Math.max(figures.carriedIn, 0);
|
||||
const scale = surplus + Math.max(figures.allocation, figures.charges);
|
||||
|
||||
if (scale <= 0) {
|
||||
return "var(--rule)";
|
||||
}
|
||||
|
||||
const pSurplus = (surplus / scale) * 100;
|
||||
const pFillEnd = pSurplus + (Math.min(figures.charges, figures.allocation) / scale) * 100;
|
||||
const pTrackEnd = pSurplus + (figures.allocation / scale) * 100;
|
||||
|
||||
return (
|
||||
`linear-gradient(to right, ` +
|
||||
`var(--surplus) 0%, var(--surplus) ${pSurplus}%, ` +
|
||||
`var(--accent) ${pSurplus}%, var(--accent) ${pFillEnd}%, ` +
|
||||
`var(--rule) ${pFillEnd}%, var(--rule) ${pTrackEnd}%, ` +
|
||||
`var(--overspend) ${pTrackEnd}%, var(--overspend) 100%)`
|
||||
);
|
||||
}
|
||||
|
||||
function CarryMeter({ figures }: { figures: PostMonthFigures }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-[3px] w-full"
|
||||
style={{ background: carryMeterBackground(figures) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostTable({ monthId, baseCurrency, rows }: Props) {
|
||||
return (
|
||||
<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">Post</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>
|
||||
{rows.map(({ post, figures, overridden }) => (
|
||||
<Fragment key={post.id}>
|
||||
<tr>
|
||||
<td className="py-2">
|
||||
<Link to={`/post/${post.id}/month/${monthId}`} className="hover:underline">
|
||||
{post.name}
|
||||
</Link>
|
||||
{post.archived && (
|
||||
<Badge variant="outline" className="ml-2">
|
||||
archived
|
||||
</Badge>
|
||||
)}
|
||||
{overridden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-2"
|
||||
title="This month overrides the standing rule"
|
||||
>
|
||||
overridden
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`font-money py-2 text-right ${
|
||||
figures.carriedIn > 0
|
||||
? "text-surplus"
|
||||
: figures.carriedIn < 0
|
||||
? "text-overspend"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{formatSignedMoney(figures.carriedIn, baseCurrency)}
|
||||
</td>
|
||||
<td className="font-money py-2 text-right">
|
||||
{formatMoney(figures.allocation, baseCurrency)}
|
||||
</td>
|
||||
<td className="font-money py-2 text-right">
|
||||
{formatMoney(figures.charges, baseCurrency)}
|
||||
</td>
|
||||
<td
|
||||
className={`font-money py-2 text-right font-medium ${
|
||||
figures.remaining < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatSignedMoney(figures.remaining, baseCurrency)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={5} className="p-0">
|
||||
<CarryMeter figures={figures} />
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user