"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(); 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 = ( ); function ProgressBar({ percent }: { percent: number }) { return (
); } 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 (
{row.displayName} ×{row.count} · {hidden ? MASKED : tugrikRaw(row.total)}
{row.suggestion ? ( ) : null}
); } /** * 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(null); useEffect(() => { if (data && queue === null) setQueue(buildQueue(data)); }, [data, queue]); const [removingKeys, setRemovingKeys] = useState>(new Set()); const [pickerFor, setPickerFor] = useState(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 (

{s.review.title}

{isLoading && queue === null ? ( ) : rows.length > 0 ? ( <>
{s.review.remaining(remainingCount)} {percent !== null ? ( {s.review.coverage(percent)} ) : null}
{percent !== null ? : null}
{bulkRunning ? s.review.bulkProgress(bulkProgress.done, bulkProgress.total) : s.review.bulkApply} {rows.map((row) => ( ) : ( <> {s.review.close} )} { if (!open) setPickerFor(null); }} categories={mainCategories} selected={pickerFor ? (suggestCategory(pickerFor.merchant, categories) ?? undefined) : undefined} onSelect={(cat) => { if (pickerFor) assign(pickerFor, cat.name); setPickerFor(null); }} />
); }