From e478ca5826b383a251a18f0e4250420f1d90c0b8 Mon Sep 17 00:00:00 2001 From: Munkherdene Date: Sat, 22 Aug 2026 23:00:09 +0800 Subject: [PATCH] feat(web): transactions month nav + category filters + categorize-review queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/app/(app)/accounting/review/page.tsx | 6 + src/features/accounting/CategorizeReview.tsx | 176 ++++++++++++++++++ src/features/accounting/CategoryChips.tsx | 89 +++++++++ src/features/accounting/MonthNav.tsx | 42 +++++ .../accounting/TransactionList.test.tsx | 6 + src/features/accounting/TransactionList.tsx | 96 +++++++--- src/features/accounting/monthRange.test.ts | 35 ++++ src/features/accounting/monthRange.ts | 22 +++ src/features/accounting/strings.ts | 15 ++ 9 files changed, 460 insertions(+), 27 deletions(-) create mode 100644 src/app/(app)/accounting/review/page.tsx create mode 100644 src/features/accounting/CategorizeReview.tsx create mode 100644 src/features/accounting/CategoryChips.tsx create mode 100644 src/features/accounting/MonthNav.tsx create mode 100644 src/features/accounting/monthRange.test.ts create mode 100644 src/features/accounting/monthRange.ts diff --git a/src/app/(app)/accounting/review/page.tsx b/src/app/(app)/accounting/review/page.tsx new file mode 100644 index 0000000..f4de188 --- /dev/null +++ b/src/app/(app)/accounting/review/page.tsx @@ -0,0 +1,6 @@ +import { CategorizeReview } from "@/features/accounting/CategorizeReview"; + +// Ports ios/Mercury/Features/Categories/CategorizeReviewView.swift. +export default function CategorizeReviewPage() { + return ; +} diff --git a/src/features/accounting/CategorizeReview.tsx b/src/features/accounting/CategorizeReview.tsx new file mode 100644 index 0000000..80522ea --- /dev/null +++ b/src/features/accounting/CategorizeReview.tsx @@ -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(); + 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(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 ( +
+
+

{s.review.title}

+ +
+ + {isLoading && queue === null ? ( + + ) : current ? ( + <> +

+ {s.review.remaining(queue!.length)} +

+ + + + {current.merchant} + + {s.review.transactionCount(current.count)} · {hidden ? MASKED : tugrikRaw(current.total)} + + + +

{s.review.question}

+ +
+ {mainCategories.map((cat) => { + const style = categoryStyle(cat.name, cat.kind === "income"); + return ( + + ); + })} +
+ + skip(current)}> + {s.review.skip} + + + ) : ( + <> + + + {s.review.close} + + + )} +
+ ); +} diff --git a/src/features/accounting/CategoryChips.tsx b/src/features/accounting/CategoryChips.tsx new file mode 100644 index 0000000..4ad8ea8 --- /dev/null +++ b/src/features/accounting/CategoryChips.tsx @@ -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 ( +
+ onSelect(null)} /> + {categories.map((c) => { + const style = categoryStyle(c.name, c.income); + return ( + onSelect(c.name)} + /> + ); + })} +
+ ); +} + +function Chip({ + label, + icon, + active, + activeTint, + activeFg, + onClick, +}: { + label: string; + icon?: ReturnType["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 ( + + ); +} diff --git a/src/features/accounting/MonthNav.tsx b/src/features/accounting/MonthNav.tsx new file mode 100644 index 0000000..46d5642 --- /dev/null +++ b/src/features/accounting/MonthNav.tsx @@ -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 ( +
+ + {label} + +
+ ); +} diff --git a/src/features/accounting/TransactionList.test.tsx b/src/features/accounting/TransactionList.test.tsx index b30049d..ff2255a 100644 --- a/src/features/accounting/TransactionList.test.tsx +++ b/src/features/accounting/TransactionList.test.tsx @@ -27,6 +27,12 @@ const txns: Txn[] = [ vi.mock("@/api/hooks/reads", () => ({ 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"; diff --git a/src/features/accounting/TransactionList.tsx b/src/features/accounting/TransactionList.tsx index e9369b8..7439a46 100644 --- a/src/features/accounting/TransactionList.tsx +++ b/src/features/accounting/TransactionList.tsx @@ -1,14 +1,16 @@ "use client"; import Link from "next/link"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Skeleton } from "@seed-design/react"; import { useTransactions } from "@/api/hooks/reads"; 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 { Icon } from "@/ds/icons"; 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 { txnRouteId } from "./txnRoute"; import { useHiddenAmounts } from "./useHiddenAmounts"; @@ -92,21 +94,11 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) { }} >
- - - +
{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(); + 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 - * transaction rows grouped by day. Mirrors `TransactionsView.ledgerTab` / - * `TransactionsModel.recompute` (narrowed to this task's scope — no month - * nav or category chips): salary deposits (`salary === true`) are hidden by + * The Тооцоо list: a month navigator, income/expense totals for the loaded + * month, category filter chips, and the transaction rows grouped by day. + * Mirrors `TransactionsView.ledgerTab` / `TransactionsModel` (narrowed to + * this task's scope): salary deposits (`salary === true`) are hidden by * default, and transfers are excluded from the totals and tagged in the row * meta line instead of colored green/red. */ export function TransactionList() { - const { data, isLoading } = useTransactions(); + const [monthOffset, setMonthOffset] = useState(0); + const [selectedCategory, setSelectedCategory] = useState(null); + const { from, to } = useMemo(() => monthRange(monthOffset), [monthOffset]); + const { data, isLoading } = useTransactions({ from, to }); const all = useMemo(() => data ?? [], [data]); const hidden = useHiddenAmounts(); + function changeMonth(next: number) { + setMonthOffset(next); + setSelectedCategory(null); + } + const totals = useMemo(() => { let income = 0; let expense = 0; @@ -145,33 +160,60 @@ export function TransactionList() { return { income, expense }; }, [all]); + const categoryOptions = useMemo(() => categoriesIn(all), [all]); + + const hasUncategorized = useMemo( + () => all.some((txn) => !txn.category && (txn.matchKey || txn.title)), + [all], + ); + const visible = useMemo( () => all .filter((txn) => txn.salary !== true) + .filter((txn) => selectedCategory === null || txn.category === selectedCategory) .slice() .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()), - [all], + [all, selectedCategory], ); const groups = useMemo(() => groupByDay(visible), [visible]); return (
-
+

{s.list.title}

- +
+ {hasUncategorized ? ( + + + {s.list.categorizeCta} + + + ) : null} + +
+ changeMonth(monthOffset - 1)} + onNext={() => changeMonth(monthOffset + 1)} + prevLabel={s.list.prevMonth} + nextLabel={s.list.nextMonth} + /> + + + {isLoading ? ( ) : groups.length === 0 ? ( -

{s.list.empty}

+
) : ( diff --git a/src/features/accounting/monthRange.test.ts b/src/features/accounting/monthRange.test.ts new file mode 100644 index 0000000..a8964c1 --- /dev/null +++ b/src/features/accounting/monthRange.test.ts @@ -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 ' оны -р сар'", () => { + 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-р сар"); + }); +}); diff --git a/src/features/accounting/monthRange.ts b/src/features/accounting/monthRange.ts new file mode 100644 index 0000000..eef1e76 --- /dev/null +++ b/src/features/accounting/monthRange.ts @@ -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}-р сар`; +} diff --git a/src/features/accounting/strings.ts b/src/features/accounting/strings.ts index d0f5c22..5cdf886 100644 --- a/src/features/accounting/strings.ts +++ b/src/features/accounting/strings.ts @@ -10,7 +10,22 @@ export const accountingStrings = { income: "Орлого", expense: "Зарлага", empty: "Гүйлгээ алга", + emptyHint: "Энэ сард гүйлгээ бүртгэгдээгүй байна.", transferTag: "Шилжүүлэг", + allCategories: "Бүгд", + categorizeCta: "Ангилах", + prevMonth: "Өмнөх сар", + nextMonth: "Дараагийн сар", + }, + review: { + title: "Ангилалжуулах", + later: "Дараа нь", + remaining: (n: number) => `${n} ангилалгүй худалдагч үлдлээ`, + question: "Аль ангилалд хамаарах вэ?", + skip: "Алгасах", + done: "Бүгд ангилагдлаа", + close: "Хаах", + transactionCount: (n: number) => `${n} гүйлгээ`, }, detail: { total: "Нийт",