feat(web): transactions month nav + category filters + categorize-review queue

Adds a month navigator and category filter chips to the Тооцоо list, plus a
categorize-review flow at /accounting/review for bulk-assigning categories to
uncategorized merchants (grouped by matchKey, biggest spend first).
This commit is contained in:
Munkherdene 2026-08-22 23:00:09 +08:00
parent 5eff0f661f
commit e478ca5826
9 changed files with 460 additions and 27 deletions

View file

@ -0,0 +1,6 @@
import { CategorizeReview } from "@/features/accounting/CategorizeReview";
// Ports ios/Mercury/Features/Categories/CategorizeReviewView.swift.
export default function CategorizeReviewPage() {
return <CategorizeReview />;
}

View file

@ -0,0 +1,176 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useCategories, useTransactions, todayLocalDate } from "@/api/hooks/reads";
import { useCategorize } from "@/api/hooks/mutations";
import type { Txn } from "@/api/schemas";
import { Card, EmptyState, IconChip, MercuryButton } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle";
import { Icon } from "@/ds/icons";
import { MASKED, tugrikRaw } from "@/ds/money";
import { accountingStrings as s } from "./strings";
import { useHiddenAmounts } from "./useHiddenAmounts";
interface ReviewItem {
/** Groups by `matchKey` (falling back to `title`) the same key the
* categorize endpoint applies the rule to. */
matchKey: string;
merchant: string;
count: number;
direction: "income" | "expense";
total: number;
}
function threeMonthsAgo(base: Date = new Date()): Date {
return new Date(base.getFullYear(), base.getMonth() - 3, base.getDate());
}
/** Groups uncategorized transactions by matchKey, biggest total spend first
* a handful of taps then covers most of the uncategorized money instead of
* burning through many trivial merchants. Ports the grouping in
* `CategorizeReviewModel.load()`. */
function buildQueue(txns: Txn[]): ReviewItem[] {
const groups = new Map<string, ReviewItem>();
for (const t of txns) {
if (t.category) continue; // defensive — the fetch already scopes to Uncategorized
const key = t.matchKey || t.title;
if (!key) continue;
const amount = parseFloat(t.amount) || 0;
const existing = groups.get(key);
if (existing) {
existing.count += 1;
existing.total += amount;
} else {
groups.set(key, {
matchKey: key,
merchant: t.title || key,
count: 1,
direction: t.direction === "income" ? "income" : "expense",
total: amount,
});
}
}
return Array.from(groups.values()).sort((a, b) => b.total - a.total);
}
/**
* Full-screen categorize-review flow at `/accounting/review` (ports
* `CategorizeReviewView.swift`): groups the last three months' uncategorized
* transactions by merchant, biggest spend first, and asks the user to assign
* or skip a category one merchant at a time.
*/
export function CategorizeReview() {
const router = useRouter();
const { from, to } = useMemo(() => {
const now = new Date();
return { from: todayLocalDate(threeMonthsAgo(now)), to: todayLocalDate(now) };
}, []);
const { data, isLoading } = useTransactions({ from, to, category: "Uncategorized", limit: 500 });
const { data: categories = [] } = useCategories();
const categorize = useCategorize();
const hidden = useHiddenAmounts();
// The queue is seeded once from the fetch, then mutated locally (skip
// removes, assign removes on success) — re-deriving it from `data` on every
// background refetch (categorize invalidates the transactions cache) would
// otherwise re-insert items the user already handled in this session.
const [queue, setQueue] = useState<ReviewItem[] | null>(null);
useEffect(() => {
if (data && queue === null) setQueue(buildQueue(data));
}, [data, queue]);
const mainCategories = useMemo(() => categories.filter((c) => c.depth === 1), [categories]);
function close() {
router.push("/accounting");
}
function skip(item: ReviewItem) {
setQueue((q) => (q ? q.filter((i) => i.matchKey !== item.matchKey) : q));
}
function assign(item: ReviewItem, category: string) {
categorize.mutate(
{ matchKey: item.matchKey, category, kind: item.direction },
{ onSuccess: () => setQueue((q) => (q ? q.filter((i) => i.matchKey !== item.matchKey) : q)) },
);
}
const current = queue?.[0];
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<h1 style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>{s.review.title}</h1>
<button
type="button"
onClick={close}
style={{ all: "unset", cursor: "pointer", fontSize: 14, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}
>
{s.review.later}
</button>
</div>
{isLoading && queue === null ? (
<Card style={{ minHeight: 320 }} />
) : current ? (
<>
<p style={{ margin: 0, fontSize: 13, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.review.remaining(queue!.length)}
</p>
<Card style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8, padding: "24px 16px" }}>
<IconChip icon="cart" tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" size={56} />
<span style={{ fontSize: 16, fontWeight: 700, textAlign: "center" }}>{current.merchant}</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.review.transactionCount(current.count)} · {hidden ? MASKED : tugrikRaw(current.total)}
</span>
</Card>
<p style={{ margin: 0, fontSize: 14, fontWeight: 700 }}>{s.review.question}</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
{mainCategories.map((cat) => {
const style = categoryStyle(cat.name, cat.kind === "income");
return (
<button
key={cat.name}
type="button"
onClick={() => assign(current, cat.name)}
disabled={categorize.isPending}
style={{
all: "unset",
cursor: categorize.isPending ? "default" : "pointer",
display: "flex",
alignItems: "center",
gap: 6,
padding: "10px 14px",
borderRadius: 999,
fontSize: 14,
opacity: categorize.isPending ? 0.6 : 1,
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
}}
>
<Icon name={style.icon} size={16} />
{style.name}
</button>
);
})}
</div>
<MercuryButton variant="secondary" onClick={() => skip(current)}>
{s.review.skip}
</MercuryButton>
</>
) : (
<>
<EmptyState icon="calendar-check" title={s.review.done} />
<MercuryButton variant="primary" onClick={close}>
{s.review.close}
</MercuryButton>
</>
)}
</div>
);
}

View file

@ -0,0 +1,89 @@
"use client";
import { categoryStyle } from "@/ds/categoryStyle";
import { Icon } from "@/ds/icons";
import { accountingStrings as s } from "./strings";
export interface CategoryChipOption {
/** Raw backend category name (as stored on `Txn.category`). */
name: string;
/** Direction of a representative transaction in that category decides
* the fallback style for an unrecognized category name. */
income: boolean;
}
export interface CategoryChipsProps {
categories: CategoryChipOption[];
/** `null` = "Бүгд" (all, no filter). */
selected: string | null;
onSelect: (category: string | null) => void;
}
/** Horizontal, scrollable category filter row above the Тооцоо list: "Бүгд"
* plus every category present in the loaded month, tinted with
* `categoryStyle` and filled when active. Tap to filter the visible rows. */
export function CategoryChips({ categories, selected, onSelect }: CategoryChipsProps) {
if (categories.length === 0) return null;
return (
<div style={{ display: "flex", gap: 8, overflowX: "auto", paddingBottom: 2 }}>
<Chip label={s.list.allCategories} active={selected === null} onClick={() => onSelect(null)} />
{categories.map((c) => {
const style = categoryStyle(c.name, c.income);
return (
<Chip
key={c.name}
label={style.name}
icon={style.icon}
active={selected === c.name}
activeTint={style.tint}
activeFg={style.fg}
onClick={() => onSelect(c.name)}
/>
);
})}
</div>
);
}
function Chip({
label,
icon,
active,
activeTint,
activeFg,
onClick,
}: {
label: string;
icon?: ReturnType<typeof categoryStyle>["icon"];
active: boolean;
activeTint?: string;
activeFg?: string;
onClick: () => void;
}) {
const tint = active ? (activeTint ?? "var(--seed-color-fg-neutral)") : "var(--seed-color-bg-neutral-subtle, #eef0f2)";
const fg = active ? (activeFg ?? "var(--seed-color-bg-layer-floating, #fff)") : "var(--seed-color-fg-neutral)";
return (
<button
type="button"
onClick={onClick}
style={{
all: "unset",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 6,
flexShrink: 0,
height: 32,
padding: "0 12px",
borderRadius: 16,
fontSize: 14,
fontWeight: active ? 700 : 500,
color: fg,
background: tint,
}}
>
{icon ? <Icon name={icon} size={15} /> : null}
{label}
</button>
);
}

View file

@ -0,0 +1,42 @@
"use client";
import type { CSSProperties } from "react";
import { Icon } from "@/ds/icons";
const navButtonStyle: CSSProperties = {
all: "unset",
cursor: "pointer",
width: 32,
height: 32,
flexShrink: 0,
borderRadius: 10,
display: "grid",
placeItems: "center",
color: "var(--seed-color-fg-neutral)",
};
export interface MonthNavProps {
/** e.g. "2026 оны 8-р сар" (see `monthLabel`). */
label: string;
onPrev: () => void;
onNext: () => void;
prevLabel?: string;
nextLabel?: string;
}
/** [month] the month navigator above the Тооцоо list. Ports the
* `monthRow` control from `TransactionsView.swift` (narrowed to just the
* month stepper, no week/month panel toggle). */
export function MonthNav({ label, onPrev, onNext, prevLabel = "Өмнөх сар", nextLabel = "Дараагийн сар" }: MonthNavProps) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 4 }}>
<button type="button" onClick={onPrev} aria-label={prevLabel} style={navButtonStyle}>
<Icon name="chevron-left" size={16} />
</button>
<span style={{ fontSize: 17, fontWeight: 700, minWidth: 150, textAlign: "center" }}>{label}</span>
<button type="button" onClick={onNext} aria-label={nextLabel} style={navButtonStyle}>
<Icon name="chevron-right" size={16} />
</button>
</div>
);
}

View file

@ -27,6 +27,12 @@ const txns: Txn[] = [
vi.mock("@/api/hooks/reads", () => ({ vi.mock("@/api/hooks/reads", () => ({
useTransactions: () => ({ data: txns, isLoading: false }), useTransactions: () => ({ data: txns, isLoading: false }),
todayLocalDate: (d: Date = new Date()) => {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
},
})); }));
import { TransactionList } from "./TransactionList"; import { TransactionList } from "./TransactionList";

View file

@ -1,14 +1,16 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { Skeleton } from "@seed-design/react"; import { Skeleton } from "@seed-design/react";
import { useTransactions } from "@/api/hooks/reads"; import { useTransactions } from "@/api/hooks/reads";
import type { Txn } from "@/api/schemas"; import type { Txn } from "@/api/schemas";
import { Card, HideAmountsToggle } from "@/ds"; import { Card, EmptyState, HideAmountsToggle, IconChip, MercuryButton } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle"; import { categoryStyle } from "@/ds/categoryStyle";
import { Icon } from "@/ds/icons";
import { MASKED, tugrikRaw } from "@/ds/money"; import { MASKED, tugrikRaw } from "@/ds/money";
import { CategoryChips, type CategoryChipOption } from "./CategoryChips";
import { MonthNav } from "./MonthNav";
import { monthLabel, monthRange } from "./monthRange";
import { accountingStrings as s } from "./strings"; import { accountingStrings as s } from "./strings";
import { txnRouteId } from "./txnRoute"; import { txnRouteId } from "./txnRoute";
import { useHiddenAmounts } from "./useHiddenAmounts"; import { useHiddenAmounts } from "./useHiddenAmounts";
@ -92,21 +94,11 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
}} }}
> >
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}> <div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
<span <IconChip
aria-hidden icon={isTransfer ? "repeat" : cat.icon}
style={{ fg={isTransfer ? "var(--seed-color-fg-neutral-muted, #6b7280)" : cat.fg}
width: 40, tint={isTransfer ? "var(--seed-color-bg-neutral-subtle, #eef0f2)" : cat.tint}
height: 40, />
flexShrink: 0,
borderRadius: 13,
display: "grid",
placeItems: "center",
color: isTransfer ? "var(--seed-color-fg-neutral-muted, #6b7280)" : cat.fg,
background: isTransfer ? "var(--seed-color-bg-neutral-subtle, #eef0f2)" : cat.tint,
}}
>
<Icon name={isTransfer ? "repeat" : cat.icon} size={20} />
</span>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}> <div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{txn.title || cat.name} {txn.title || cat.name}
@ -121,19 +113,42 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
); );
} }
/** Distinct categories present in `txns`, most-frequent first the source
* for the category filter chip row. */
function categoriesIn(txns: Txn[]): CategoryChipOption[] {
const counts = new Map<string, { count: number; income: boolean }>();
for (const txn of txns) {
if (!txn.category) continue;
const existing = counts.get(txn.category);
if (existing) existing.count += 1;
else counts.set(txn.category, { count: 1, income: txn.direction === "income" });
}
return Array.from(counts.entries())
.sort((a, b) => b[1].count - a[1].count)
.map(([name, v]) => ({ name, income: v.income }));
}
/** /**
* The Тооцоо list: income/expense totals for the loaded month, then the * The Тооцоо list: a month navigator, income/expense totals for the loaded
* transaction rows grouped by day. Mirrors `TransactionsView.ledgerTab` / * month, category filter chips, and the transaction rows grouped by day.
* `TransactionsModel.recompute` (narrowed to this task's scope no month * Mirrors `TransactionsView.ledgerTab` / `TransactionsModel` (narrowed to
* nav or category chips): salary deposits (`salary === true`) are hidden by * this task's scope): salary deposits (`salary === true`) are hidden by
* default, and transfers are excluded from the totals and tagged in the row * default, and transfers are excluded from the totals and tagged in the row
* meta line instead of colored green/red. * meta line instead of colored green/red.
*/ */
export function TransactionList() { export function TransactionList() {
const { data, isLoading } = useTransactions(); const [monthOffset, setMonthOffset] = useState(0);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const { from, to } = useMemo(() => monthRange(monthOffset), [monthOffset]);
const { data, isLoading } = useTransactions({ from, to });
const all = useMemo(() => data ?? [], [data]); const all = useMemo(() => data ?? [], [data]);
const hidden = useHiddenAmounts(); const hidden = useHiddenAmounts();
function changeMonth(next: number) {
setMonthOffset(next);
setSelectedCategory(null);
}
const totals = useMemo(() => { const totals = useMemo(() => {
let income = 0; let income = 0;
let expense = 0; let expense = 0;
@ -145,33 +160,60 @@ export function TransactionList() {
return { income, expense }; return { income, expense };
}, [all]); }, [all]);
const categoryOptions = useMemo(() => categoriesIn(all), [all]);
const hasUncategorized = useMemo(
() => all.some((txn) => !txn.category && (txn.matchKey || txn.title)),
[all],
);
const visible = useMemo( const visible = useMemo(
() => () =>
all all
.filter((txn) => txn.salary !== true) .filter((txn) => txn.salary !== true)
.filter((txn) => selectedCategory === null || txn.category === selectedCategory)
.slice() .slice()
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()), .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
[all], [all, selectedCategory],
); );
const groups = useMemo(() => groupByDay(visible), [visible]); const groups = useMemo(() => groupByDay(visible), [visible]);
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>{s.list.title}</h1> <h1 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>{s.list.title}</h1>
<HideAmountsToggle label={s.list.hideToggle} /> <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{hasUncategorized ? (
<Link href="/accounting/review" style={{ textDecoration: "none" }}>
<MercuryButton variant="secondary" size="small">
{s.list.categorizeCta}
</MercuryButton>
</Link>
) : null}
<HideAmountsToggle label={s.list.hideToggle} />
</div>
</div> </div>
<MonthNav
label={monthLabel(monthOffset)}
onPrev={() => changeMonth(monthOffset - 1)}
onNext={() => changeMonth(monthOffset + 1)}
prevLabel={s.list.prevMonth}
nextLabel={s.list.nextMonth}
/>
<Card style={{ display: "flex", flexDirection: "column", gap: 12 }}> <Card style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<SummaryRow label={s.list.income} value={totals.income} hidden={hidden} tone="income" /> <SummaryRow label={s.list.income} value={totals.income} hidden={hidden} tone="income" />
<SummaryRow label={s.list.expense} value={totals.expense} hidden={hidden} tone="expense" /> <SummaryRow label={s.list.expense} value={totals.expense} hidden={hidden} tone="expense" />
</Card> </Card>
<CategoryChips categories={categoryOptions} selected={selectedCategory} onSelect={setSelectedCategory} />
{isLoading ? ( {isLoading ? (
<Skeleton style={{ height: 320, width: "100%", borderRadius: "var(--seed-radius-r3)" }} /> <Skeleton style={{ height: 320, width: "100%", borderRadius: "var(--seed-radius-r3)" }} />
) : groups.length === 0 ? ( ) : groups.length === 0 ? (
<Card> <Card>
<p style={{ margin: 0, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{s.list.empty}</p> <EmptyState icon="calendar-check" title={s.list.empty} hint={s.list.emptyHint} compact />
</Card> </Card>
) : ( ) : (
<Card style={{ display: "flex", flexDirection: "column", gap: 20 }}> <Card style={{ display: "flex", flexDirection: "column", gap: 20 }}>

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { monthLabel, monthRange } from "./monthRange";
describe("monthRange", () => {
it("returns the first and last day of the base month at offset 0", () => {
expect(monthRange(0, new Date(2026, 7, 15))).toEqual({ from: "2026-08-01", to: "2026-08-31" });
});
it("steps back a month, crossing a year boundary", () => {
expect(monthRange(-1, new Date(2026, 0, 10))).toEqual({ from: "2025-12-01", to: "2025-12-31" });
});
it("steps forward a month, crossing a year boundary", () => {
expect(monthRange(1, new Date(2025, 11, 20))).toEqual({ from: "2026-01-01", to: "2026-01-31" });
});
it("handles a short month (February, non-leap year)", () => {
expect(monthRange(0, new Date(2026, 1, 1))).toEqual({ from: "2026-02-01", to: "2026-02-28" });
});
it("handles a leap-year February", () => {
expect(monthRange(0, new Date(2028, 1, 1))).toEqual({ from: "2028-02-01", to: "2028-02-29" });
});
});
describe("monthLabel", () => {
it("formats as '<year> оны <month>-р сар'", () => {
expect(monthLabel(0, new Date(2026, 7, 15))).toBe("2026 оны 8-р сар");
});
it("rolls the year when stepping across January", () => {
expect(monthLabel(-1, new Date(2026, 0, 5))).toBe("2025 оны 12-р сар");
expect(monthLabel(1, new Date(2025, 11, 5))).toBe("2026 оны 1-р сар");
});
});

View file

@ -0,0 +1,22 @@
import { todayLocalDate } from "@/api/hooks/reads";
/**
* Local-calendar [from, to] bounds (`YYYY-MM-DD`, matching `?from=&to=`) for
* the month `offset` months from `base` 0 = `base`'s own month, -1 = the
* month before, +1 = the month after. Powers the Тооцоо month navigator.
*/
export function monthRange(offset: number, base: Date = new Date()): { from: string; to: string } {
const year = base.getFullYear();
const month = base.getMonth() + offset;
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0); // day 0 of next month = this month's last day
return { from: todayLocalDate(first), to: todayLocalDate(last) };
}
/** "2026 оны 8-р сар" for the month `offset` months from `base`. */
export function monthLabel(offset: number, base: Date = new Date()): string {
const year = base.getFullYear();
const month = base.getMonth() + offset;
const d = new Date(year, month, 1);
return `${d.getFullYear()} оны ${d.getMonth() + 1}-р сар`;
}

View file

@ -10,7 +10,22 @@ export const accountingStrings = {
income: "Орлого", income: "Орлого",
expense: "Зарлага", expense: "Зарлага",
empty: "Гүйлгээ алга", empty: "Гүйлгээ алга",
emptyHint: "Энэ сард гүйлгээ бүртгэгдээгүй байна.",
transferTag: "Шилжүүлэг", transferTag: "Шилжүүлэг",
allCategories: "Бүгд",
categorizeCta: "Ангилах",
prevMonth: "Өмнөх сар",
nextMonth: "Дараагийн сар",
},
review: {
title: "Ангилалжуулах",
later: "Дараа нь",
remaining: (n: number) => `${n} ангилалгүй худалдагч үлдлээ`,
question: "Аль ангилалд хамаарах вэ?",
skip: "Алгасах",
done: "Бүгд ангилагдлаа",
close: "Хаах",
transactionCount: (n: number) => `${n} гүйлгээ`,
}, },
detail: { detail: {
total: "Нийт", total: "Нийт",