mercury-web/src/features/accounting/CategorizeReview.tsx
Munkherdene b4ff65deb8 feat(web): exclude bank fees from spending views
Consume the new `fee` flag from the API: bank service/maintenance fees
are dropped from the transaction list, expense totals, the categorize
review queue, and the recurring estimate — the user treats them as noise,
like transfers.
2026-08-23 01:27:40 +08:00

386 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 { Category, 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 { CategorizeSheet } from "./CategorizeSheet";
import { suggestCategory, extractMerchant, looksLikeCardNumber } from "./suggestCategory";
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());
}
/** Spending transactions worth reviewing: excludes auto-detected salary
* deposits, any income, and inter-account transfers — those aren't expenses
* to categorize. Shared by the uncategorized queue and the coverage
* denominator so both count the same population. */
function isReviewableSpend(t: Txn): boolean {
if (t.salary === true) return false;
if (t.direction === "income") return false;
if (t.transfer === true) return false;
if (t.fee === true) return false; // bank's own fees are excluded, not categorized
return Boolean(t.matchKey || t.title);
}
/** 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
if (!isReviewableSpend(t)) continue;
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);
}
// Masked card strings like "554835******6886:13-08-2026 09:41:22" carry a
// trailing timestamp and no real merchant name — collapse them to a short
// "card ending in ####" label instead of dumping the raw string on the row.
const CARD_LIKE = /^(\d{3,})\*{2,}(\d{2,})/;
function prettyMerchant(raw: string): string {
const name = extractMerchant(raw); // recovers "ANTHROPIC"/"TAOBAO" from card strings
if (name && !looksLikeCardNumber(name)) return name;
const head = (raw.split(":")[0] ?? raw).trim();
const m = head.match(CARD_LIKE);
return m ? `Карт •••• ${m[2]}` : head || raw;
}
interface Row extends ReviewItem {
displayName: string;
suggestion: string | null;
}
function toRows(queue: ReviewItem[], categories: Category[]): Row[] {
return queue.map((item) => ({
...item,
displayName: prettyMerchant(item.merchant),
suggestion: suggestCategory(item.merchant, categories),
}));
}
const checkSvg = (
<svg viewBox="0 0 24 24" width={18} height={18} fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.5l4.5 4.5L19 7" />
</svg>
);
function ProgressBar({ percent }: { percent: number }) {
return (
<div
style={{
height: 6,
borderRadius: 999,
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${Math.max(0, Math.min(100, percent))}%`,
borderRadius: 999,
background: "var(--mercury-success, #1e9e6b)",
transition: "width 300ms ease",
}}
/>
</div>
);
}
function ReviewRow({
row,
hidden,
busy,
removing,
onConfirm,
onPick,
}: {
row: Row;
hidden: boolean;
busy: boolean;
removing: boolean;
onConfirm: (row: Row) => void;
onPick: (row: Row) => void;
}) {
const chip = row.suggestion ? categoryStyle(row.suggestion, false) : null;
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "10px 0",
opacity: removing ? 0 : 1,
transform: removing ? "translateX(6px)" : "translateX(0)",
transition: "opacity 220ms ease, transform 220ms ease",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
<IconChip
icon={chip?.icon ?? "receipt"}
tint={chip?.tint ?? "var(--seed-color-bg-neutral-subtle, #eef0f2)"}
fg={chip?.fg ?? "var(--seed-color-fg-neutral-muted, #8b8b8b)"}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{row.displayName}
</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
×{row.count} · {hidden ? MASKED : tugrikRaw(row.total)}
</span>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexShrink: 0 }}>
<button
type="button"
onClick={() => onPick(row)}
disabled={busy}
style={{
all: "unset",
cursor: busy ? "default" : "pointer",
display: "flex",
alignItems: "center",
gap: 6,
padding: "8px 12px",
borderRadius: 999,
fontSize: 13,
fontWeight: 600,
opacity: busy ? 0.6 : 1,
background: chip?.tint ?? "var(--seed-color-bg-neutral-subtle, #eef0f2)",
color: chip?.fg ?? "var(--seed-color-fg-neutral-muted, #8b8b8b)",
}}
>
<Icon name={chip?.icon ?? "list"} size={14} />
{chip ? chip.name : s.review.pickCategory}
</button>
{row.suggestion ? (
<button
type="button"
aria-label={s.review.confirm}
onClick={() => onConfirm(row)}
disabled={busy}
style={{
all: "unset",
cursor: busy ? "default" : "pointer",
display: "grid",
placeItems: "center",
width: 32,
height: 32,
borderRadius: "50%",
opacity: busy ? 0.6 : 1,
background: "var(--mercury-success, #1e9e6b)",
color: "#fff",
}}
>
{checkSvg}
</button>
) : null}
</div>
</div>
);
}
/**
* Batch categorize-review screen at `/accounting/review`: every uncategorized
* merchant from the last three months, biggest spend first, in one scannable
* list with an auto-suggested category per row. Confirming a suggestion (or
* picking a different category) posts a retroactive rule via `useCategorize`
* that categorizes all matching past + future transactions server-side — the
* row then animates out. "Бүгдийг санал болгосноор ангилах" applies every
* confident suggestion in one pass. Replaces the old one-merchant-at-a-time
* `CategorizeReviewView.swift` port.
*/
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 });
// Same 3-month window, unfiltered — only used as the coverage bar's fixed
// denominator (total reviewable spend transactions), not re-rendered as a list.
const { data: allData } = useTransactions({ from, to, limit: 500 });
const { data: categoriesData } = useCategories();
const categories = categoriesData ?? [];
const categorize = useCategorize();
const hidden = useHiddenAmounts();
// The queue is seeded once from the fetch, then mutated locally (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 [removingKeys, setRemovingKeys] = useState<Set<string>>(new Set());
const [pickerFor, setPickerFor] = useState<ReviewItem | null>(null);
const [bulkRunning, setBulkRunning] = useState(false);
const [bulkProgress, setBulkProgress] = useState({ done: 0, total: 0 });
const rows = useMemo(() => (queue ? toRows(queue, categories) : []), [queue, categories]);
const mainCategories = useMemo(() => categories.filter((c) => c.depth === 1), [categories]);
const totalSpend = useMemo(
() => (allData ? allData.filter(isReviewableSpend).length : null),
[allData],
);
const remainingCount = useMemo(() => rows.reduce((sum, r) => sum + r.count, 0), [rows]);
const percent =
totalSpend && totalSpend > 0 ? Math.round(((totalSpend - remainingCount) / totalSpend) * 100) : null;
function close() {
router.push("/accounting");
}
function removeItem(matchKey: string) {
setRemovingKeys((r) => new Set(r).add(matchKey));
setTimeout(() => {
setQueue((q) => (q ? q.filter((i) => i.matchKey !== matchKey) : q));
setRemovingKeys((r) => {
const next = new Set(r);
next.delete(matchKey);
return next;
});
}, 220);
}
function assign(item: ReviewItem, category: string) {
categorize.mutate(
{ matchKey: item.matchKey, category, kind: item.direction },
{ onSuccess: () => removeItem(item.matchKey) },
);
}
async function runBulk() {
const targets = rows.filter((r) => r.suggestion);
if (targets.length === 0) return;
setBulkRunning(true);
setBulkProgress({ done: 0, total: targets.length });
for (const row of targets) {
try {
await categorize.mutateAsync({ matchKey: row.matchKey, category: row.suggestion!, kind: row.direction });
removeItem(row.matchKey);
} catch {
// Leave it in the queue on failure so the user can retry it manually.
}
setBulkProgress((p) => ({ ...p, done: p.done + 1 }));
}
setBulkRunning(false);
}
const confidentCount = rows.filter((r) => r.suggestion).length;
const busy = categorize.isPending || bulkRunning;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<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: 200 }} />
) : rows.length > 0 ? (
<>
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>{s.review.remaining(remainingCount)}</span>
{percent !== null ? (
<span style={{ fontSize: 13, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.review.coverage(percent)}
</span>
) : null}
</div>
{percent !== null ? <ProgressBar percent={percent} /> : null}
</Card>
<MercuryButton variant="primary" onClick={runBulk} disabled={busy || confidentCount === 0}>
{bulkRunning ? s.review.bulkProgress(bulkProgress.done, bulkProgress.total) : s.review.bulkApply}
</MercuryButton>
<Card style={{ display: "flex", flexDirection: "column" }}>
{rows.map((row) => (
<ReviewRow
key={row.matchKey}
row={row}
hidden={hidden}
busy={busy}
removing={removingKeys.has(row.matchKey)}
onConfirm={(r) => assign(r, r.suggestion!)}
onPick={(r) => setPickerFor(r)}
/>
))}
</Card>
</>
) : (
<>
<EmptyState icon="calendar-check" title={s.review.done} hint={s.review.doneHint} />
<MercuryButton variant="primary" onClick={close}>
{s.review.close}
</MercuryButton>
</>
)}
<CategorizeSheet
open={pickerFor !== null}
onOpenChange={(open) => {
if (!open) setPickerFor(null);
}}
categories={mainCategories}
selected={pickerFor ? (suggestCategory(pickerFor.merchant, categories) ?? undefined) : undefined}
onSelect={(cat) => {
if (pickerFor) assign(pickerFor, cat.name);
setPickerFor(null);
}}
/>
</div>
);
}