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.
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|