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:
parent
5eff0f661f
commit
e478ca5826
9 changed files with 460 additions and 27 deletions
6
src/app/(app)/accounting/review/page.tsx
Normal file
6
src/app/(app)/accounting/review/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { CategorizeReview } from "@/features/accounting/CategorizeReview";
|
||||||
|
|
||||||
|
// Ports ios/Mercury/Features/Categories/CategorizeReviewView.swift.
|
||||||
|
export default function CategorizeReviewPage() {
|
||||||
|
return <CategorizeReview />;
|
||||||
|
}
|
||||||
176
src/features/accounting/CategorizeReview.tsx
Normal file
176
src/features/accounting/CategorizeReview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/features/accounting/CategoryChips.tsx
Normal file
89
src/features/accounting/CategoryChips.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/features/accounting/MonthNav.tsx
Normal file
42
src/features/accounting/MonthNav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
35
src/features/accounting/monthRange.test.ts
Normal file
35
src/features/accounting/monthRange.test.ts
Normal 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-р сар");
|
||||||
|
});
|
||||||
|
});
|
||||||
22
src/features/accounting/monthRange.ts
Normal file
22
src/features/accounting/monthRange.ts
Normal 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}-р сар`;
|
||||||
|
}
|
||||||
|
|
@ -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: "Нийт",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue