Allow month-only purchases to start finance plans. Keep unbudgeted posts distinct from explicit zero rules in overspend reporting.
139 lines
5.6 KiB
TypeScript
139 lines
5.6 KiB
TypeScript
import { useState } from "react";
|
|
import { Link } from "react-router";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useDataset } from "../hooks/useDataset.ts";
|
|
import { datasetMonthSpan, summaryView } from "../../domain/views.ts";
|
|
import { monthOf } from "../../domain/months.ts";
|
|
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
|
|
import { Section, Stat } from "../components/Section.tsx";
|
|
import { Segmented } from "../components/Segmented.tsx";
|
|
|
|
export function isMonthId(value: string): boolean {
|
|
try {
|
|
return monthOf(value) === value;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function SummaryRoute() {
|
|
const fmt = useMoneyFormat();
|
|
const dataset = useDataset();
|
|
const span = datasetMonthSpan(dataset);
|
|
const [from, setFrom] = useState(span.from);
|
|
const [to, setTo] = useState(span.to);
|
|
const [groupBy, setGroupBy] = useState<"post" | "month">("post");
|
|
|
|
const validFrom = isMonthId(from);
|
|
const validTo = isMonthId(to);
|
|
const view = validFrom && validTo ? summaryView(dataset, from, to) : null;
|
|
const base = dataset.settings.baseCurrency;
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<h1 className="text-2xl">Summary</h1>
|
|
|
|
<Section>
|
|
<div className="flex flex-wrap items-end gap-4">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="from">From</Label>
|
|
<Input id="from" className="w-32" value={from} aria-invalid={!validFrom} onChange={(e) => setFrom(e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="to">To</Label>
|
|
<Input id="to" className="w-32" value={to} aria-invalid={!validTo} onChange={(e) => setTo(e.target.value)} />
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setFrom(span.from);
|
|
setTo(span.to);
|
|
}}
|
|
>
|
|
All time
|
|
</Button>
|
|
</div>
|
|
|
|
{view ? (
|
|
<dl className="mt-5 flex flex-wrap gap-x-10 gap-y-4 border-t border-budget-rule pt-5">
|
|
<Stat label="Income">{fmt.money(view.totalIncome, base)}</Stat>
|
|
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
|
|
<Stat label="Difference" tone={view.difference < 0 ? "overspend" : "default"}>
|
|
{fmt.money(view.difference, base)}
|
|
</Stat>
|
|
</dl>
|
|
) : (
|
|
<p role="alert" className="mt-4 text-sm text-destructive">Enter a month as YYYY-MM in each field.</p>
|
|
)}
|
|
</Section>
|
|
|
|
{view && <Section
|
|
title={groupBy === "post" ? "By post" : "By month"}
|
|
action={
|
|
<Segmented
|
|
label="Group rows by"
|
|
value={groupBy}
|
|
onChange={setGroupBy}
|
|
options={[
|
|
{ value: "post", label: "By post" },
|
|
{ value: "month", label: "By month" },
|
|
]}
|
|
/>
|
|
}
|
|
>
|
|
<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">
|
|
<th className="py-2 font-medium">{groupBy === "post" ? "Post" : "Month"}</th>
|
|
<th className="py-2 pl-6 text-right font-medium">Spent</th>
|
|
<th className="py-2 pl-6 text-right font-medium">Share</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{groupBy === "post"
|
|
? <>
|
|
{view.byPost.map((entry) => (
|
|
<tr key={entry.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
|
|
<td className="py-2.5">{entry.post.name}</td>
|
|
<td className="font-money py-2.5 pl-6 text-right">{fmt.money(entry.charges, base)}</td>
|
|
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
|
|
{view.totalCharges === 0
|
|
? "—"
|
|
: `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{view.looseCharges !== 0 && (
|
|
<tr className="border-b border-budget-rule text-budget-ink-muted">
|
|
<td className="py-2.5">Unassigned</td>
|
|
<td className="font-money py-2.5 pl-6 text-right">{fmt.amount(view.looseCharges)}</td>
|
|
<td className="font-money py-2.5 pl-6 text-right">
|
|
{view.totalCharges === 0 ? "—" : `${((view.looseCharges / view.totalCharges) * 100).toFixed(1)}%`}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
: view.byMonth.map((entry) => (
|
|
<tr key={entry.monthId} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
|
|
<td className="py-2.5">
|
|
<Link to={`/month/${entry.monthId}`} className="hover:underline">
|
|
{entry.monthId}
|
|
</Link>
|
|
</td>
|
|
<td className="font-money py-2.5 pl-6 text-right">{fmt.money(entry.charges, base)}</td>
|
|
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
|
|
{view.totalCharges === 0
|
|
? "—"
|
|
: `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</Section>}
|
|
</div>
|
|
);
|
|
}
|