merge task/t10: transactions list + detail + edits
This commit is contained in:
commit
3014385e9b
9 changed files with 758 additions and 0 deletions
11
src/app/(app)/accounting/[id]/page.tsx
Normal file
11
src/app/(app)/accounting/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { TransactionDetail } from "@/features/accounting/TransactionDetail";
|
||||||
|
|
||||||
|
// Ports ios/Mercury/Features/Transactions/TransactionDetailView.swift.
|
||||||
|
export default async function TransactionDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
return <TransactionDetail id={id} />;
|
||||||
|
}
|
||||||
7
src/app/(app)/accounting/page.tsx
Normal file
7
src/app/(app)/accounting/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { TransactionList } from "@/features/accounting/TransactionList";
|
||||||
|
|
||||||
|
// Ports ios/Mercury/Features/Transactions/TransactionsView.swift's ledger
|
||||||
|
// tab (narrowed to this task's scope — see TransactionList).
|
||||||
|
export default function AccountingPage() {
|
||||||
|
return <TransactionList />;
|
||||||
|
}
|
||||||
97
src/features/accounting/CategorizeSheet.tsx
Normal file
97
src/features/accounting/CategorizeSheet.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BottomSheetRoot,
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetPositioner,
|
||||||
|
BottomSheetContent,
|
||||||
|
BottomSheetHeader,
|
||||||
|
BottomSheetTitle,
|
||||||
|
BottomSheetCloseButton,
|
||||||
|
BottomSheetBody,
|
||||||
|
Icon,
|
||||||
|
ListRoot,
|
||||||
|
ListItem,
|
||||||
|
ListContent,
|
||||||
|
ListTitle,
|
||||||
|
} from "@seed-design/react";
|
||||||
|
import type { Category } from "@/api/schemas";
|
||||||
|
import { accountingStrings as s } from "./strings";
|
||||||
|
|
||||||
|
export interface CategorizeSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
categories: Category[];
|
||||||
|
/** The transaction's current category (main or sub) — highlighted in the list. */
|
||||||
|
selected?: string;
|
||||||
|
onSelect: (category: Category) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSvg = (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkSvg = (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M5 12.5l4.5 4.5L19 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category picker bottom sheet (ports `CategoryPickerSheet` from
|
||||||
|
* `ios/Mercury/Features/Planner/PlannerEditViews.swift`, opened from the
|
||||||
|
* transaction detail's Ангилал row): a titled list of all categories,
|
||||||
|
* tap-to-select, checkmark on the current pick.
|
||||||
|
*/
|
||||||
|
export function CategorizeSheet({ open, onOpenChange, categories, selected, onSelect }: CategorizeSheetProps) {
|
||||||
|
return (
|
||||||
|
<BottomSheetRoot open={open} onOpenChange={onOpenChange}>
|
||||||
|
<BottomSheetBackdrop />
|
||||||
|
<BottomSheetPositioner>
|
||||||
|
<BottomSheetContent>
|
||||||
|
<BottomSheetHeader style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<BottomSheetTitle>{s.categoryPicker.title}</BottomSheetTitle>
|
||||||
|
<BottomSheetCloseButton aria-label={s.categoryPicker.cancel}>
|
||||||
|
<Icon svg={closeSvg} size="16px" />
|
||||||
|
</BottomSheetCloseButton>
|
||||||
|
</BottomSheetHeader>
|
||||||
|
<BottomSheetBody style={{ maxHeight: "60vh", overflowY: "auto" }}>
|
||||||
|
<ListRoot>
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const isSelected = cat.name === selected;
|
||||||
|
return (
|
||||||
|
<ListItem key={cat.name} style={{ padding: 0 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(cat)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
textAlign: "left",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "12px 4px",
|
||||||
|
font: "inherit",
|
||||||
|
color: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListContent>
|
||||||
|
<ListTitle>{cat.name}</ListTitle>
|
||||||
|
</ListContent>
|
||||||
|
{isSelected && <Icon svg={checkSvg} size="18px" />}
|
||||||
|
</button>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ListRoot>
|
||||||
|
</BottomSheetBody>
|
||||||
|
</BottomSheetContent>
|
||||||
|
</BottomSheetPositioner>
|
||||||
|
</BottomSheetRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
321
src/features/accounting/TransactionDetail.tsx
Normal file
321
src/features/accounting/TransactionDetail.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
DialogRoot,
|
||||||
|
DialogBackdrop,
|
||||||
|
DialogPositioner,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogAction,
|
||||||
|
TextFieldRoot,
|
||||||
|
TextFieldTextarea,
|
||||||
|
} from "@seed-design/react";
|
||||||
|
import { useTransactions, useCategories } from "@/api/hooks/reads";
|
||||||
|
import { useCategorize, useRenameTxn, useSetNote } from "@/api/hooks/mutations";
|
||||||
|
import { Card, MercuryButton, NameEdit } from "@/ds";
|
||||||
|
import { MASKED, tugrikRaw } from "@/ds/money";
|
||||||
|
import { accountingStrings as s } from "./strings";
|
||||||
|
import { CategorizeSheet } from "./CategorizeSheet";
|
||||||
|
import { findTxnByRouteId } from "./txnRoute";
|
||||||
|
import { useHiddenAmounts } from "./useHiddenAmounts";
|
||||||
|
|
||||||
|
export interface TransactionDetailProps {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_BORDER: CSSProperties = { borderBottom: "1px solid var(--seed-color-border-neutral, #e5e5e5)" };
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
chevron,
|
||||||
|
bold,
|
||||||
|
last,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
chevron?: boolean;
|
||||||
|
bold?: boolean;
|
||||||
|
last?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
const wrapperStyle: CSSProperties = last ? {} : ROW_BORDER;
|
||||||
|
const body = (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "12px 16px" }}>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)", flexShrink: 0 }}>{label}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: bold ? 14 : 13,
|
||||||
|
fontWeight: bold ? 700 : 400,
|
||||||
|
textAlign: "right",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{chevron && (
|
||||||
|
<span aria-hidden style={{ color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!onClick) return <div style={wrapperStyle}>{body}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{ all: "unset", display: "block", width: "100%", boxSizing: "border-box", cursor: "pointer", ...wrapperStyle }}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction detail (ports `TransactionDetailView.swift`, narrowed to this
|
||||||
|
* task's scope): a key-value table plus categorize / rename / note edits.
|
||||||
|
* Categorize and rename both "learn a rule" server-side keyed on the
|
||||||
|
* merchant's match key (applies to all of that merchant's past + future
|
||||||
|
* transactions) — renaming shows a confirm dialog because of that broad
|
||||||
|
* effect; categorizing (like iOS) applies immediately.
|
||||||
|
*/
|
||||||
|
export function TransactionDetail({ id }: TransactionDetailProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: transactions } = useTransactions();
|
||||||
|
const { data: categories = [] } = useCategories();
|
||||||
|
const categorize = useCategorize();
|
||||||
|
const renameTxn = useRenameTxn();
|
||||||
|
const setNoteMutation = useSetNote();
|
||||||
|
const hiddenAmounts = useHiddenAmounts();
|
||||||
|
|
||||||
|
const txn = useMemo(() => findTxnByRouteId(transactions ?? [], id), [transactions, id]);
|
||||||
|
|
||||||
|
// Local overrides so an edit reflects immediately, matching iOS's
|
||||||
|
// `assigned` / `displayTitle` @State — the server call runs in the
|
||||||
|
// background and the list refetch (via mutation `onSuccess` invalidation)
|
||||||
|
// reconciles afterwards.
|
||||||
|
const [assignedCategory, setAssignedCategory] = useState<string | null>(null);
|
||||||
|
const [displayTitle, setDisplayTitle] = useState<string | null>(null);
|
||||||
|
const [noteOverride, setNoteOverride] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [pendingName, setPendingName] = useState<string | null>(null);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [editingNote, setEditingNote] = useState(false);
|
||||||
|
const [noteDraft, setNoteDraft] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAssignedCategory(null);
|
||||||
|
setDisplayTitle(null);
|
||||||
|
setNoteOverride(null);
|
||||||
|
setRenaming(false);
|
||||||
|
setEditingNote(false);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (!txn) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
<BackButton onClick={() => router.push("/accounting")} />
|
||||||
|
<Card>
|
||||||
|
<p style={{ margin: 0, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{s.detail.notFound}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const income = txn.direction === "income";
|
||||||
|
const category = assignedCategory ?? txn.category;
|
||||||
|
const title = displayTitle ?? txn.title;
|
||||||
|
const note = noteOverride ?? txn.note ?? "";
|
||||||
|
const canNote = txn.txnId != null && txn.txnId > 0;
|
||||||
|
|
||||||
|
const amountRaw = hiddenAmounts ? MASKED : tugrikRaw(txn.amount);
|
||||||
|
const signedAmount = hiddenAmounts ? MASKED : `${income ? "+" : "−"}${tugrikRaw(txn.amount)}`;
|
||||||
|
|
||||||
|
async function confirmRename() {
|
||||||
|
if (!pendingName) return;
|
||||||
|
const name = pendingName;
|
||||||
|
setDisplayTitle(name);
|
||||||
|
setPendingName(null);
|
||||||
|
try {
|
||||||
|
await renameTxn.mutateAsync({ matchKey: txn!.matchKey ?? txn!.title, name });
|
||||||
|
} catch {
|
||||||
|
// Best-effort, matches iOS's `renameMerchant` — leave the optimistic
|
||||||
|
// title in place; the next successful list load reconciles it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNote() {
|
||||||
|
const trimmed = noteDraft.trim();
|
||||||
|
setNoteOverride(trimmed);
|
||||||
|
setEditingNote(false);
|
||||||
|
if (txn!.txnId != null) {
|
||||||
|
try {
|
||||||
|
await setNoteMutation.mutateAsync({ id: txn!.txnId, note: trimmed });
|
||||||
|
} catch {
|
||||||
|
// Best-effort, matches iOS's `saveNote`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renaming) {
|
||||||
|
return (
|
||||||
|
<div style={{ paddingTop: 24 }}>
|
||||||
|
<NameEdit
|
||||||
|
title={s.rename.title}
|
||||||
|
initial={title}
|
||||||
|
placeholder={s.rename.placeholder}
|
||||||
|
onCancel={() => setRenaming(false)}
|
||||||
|
onSave={(name) => {
|
||||||
|
setRenaming(false);
|
||||||
|
setPendingName(name);
|
||||||
|
setConfirmOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||||
|
<BackButton onClick={() => router.push("/accounting")} />
|
||||||
|
|
||||||
|
<Card style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{title || category}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{category}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 700,
|
||||||
|
flexShrink: 0,
|
||||||
|
color: income ? "var(--mercury-success, #1e9e6b)" : "var(--mercury-critical, #e5484d)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{signedAmount}
|
||||||
|
</span>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card style={{ padding: 0 }}>
|
||||||
|
<DetailRow label={s.detail.total} value={amountRaw} bold />
|
||||||
|
<DetailRow label={s.detail.date} value={formatDateTime(txn.date)} />
|
||||||
|
<DetailRow label={s.detail.category} value={category} chevron onClick={() => setPickerOpen(true)} />
|
||||||
|
<DetailRow label={s.detail.name} value={title} chevron onClick={() => setRenaming(true)} />
|
||||||
|
{txn.balanceAfter != null && (
|
||||||
|
<DetailRow label={s.detail.balance} value={hiddenAmounts ? MASKED : tugrikRaw(txn.balanceAfter)} />
|
||||||
|
)}
|
||||||
|
<DetailRow
|
||||||
|
label={s.detail.type}
|
||||||
|
value={income ? s.detail.income : s.detail.expense}
|
||||||
|
last={!canNote}
|
||||||
|
/>
|
||||||
|
{canNote &&
|
||||||
|
(editingNote ? (
|
||||||
|
<div style={{ padding: 12, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<TextFieldRoot value={noteDraft} onValueChange={setNoteDraft}>
|
||||||
|
<TextFieldTextarea
|
||||||
|
aria-label={s.detail.note}
|
||||||
|
placeholder={s.detail.noteAdd}
|
||||||
|
style={{ minHeight: 72 }}
|
||||||
|
/>
|
||||||
|
</TextFieldRoot>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<MercuryButton variant="secondary" style={{ flex: 1 }} onClick={() => setEditingNote(false)}>
|
||||||
|
{s.detail.noteCancel}
|
||||||
|
</MercuryButton>
|
||||||
|
<MercuryButton
|
||||||
|
variant="primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
loading={setNoteMutation.isPending}
|
||||||
|
onClick={saveNote}
|
||||||
|
>
|
||||||
|
{s.detail.noteSave}
|
||||||
|
</MercuryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DetailRow
|
||||||
|
label={s.detail.note}
|
||||||
|
value={note || s.detail.noteAdd}
|
||||||
|
chevron
|
||||||
|
last
|
||||||
|
onClick={() => {
|
||||||
|
setNoteDraft(note);
|
||||||
|
setEditingNote(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<CategorizeSheet
|
||||||
|
open={pickerOpen}
|
||||||
|
onOpenChange={setPickerOpen}
|
||||||
|
categories={categories}
|
||||||
|
selected={category}
|
||||||
|
onSelect={(cat) => {
|
||||||
|
setAssignedCategory(cat.name);
|
||||||
|
setPickerOpen(false);
|
||||||
|
categorize.mutate({
|
||||||
|
matchKey: txn.matchKey ?? txn.title,
|
||||||
|
category: cat.name,
|
||||||
|
kind: txn.direction === "income" ? "income" : "expense",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogRoot open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
|
<DialogBackdrop />
|
||||||
|
<DialogPositioner>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{s.rename.confirmTitle}</DialogTitle>
|
||||||
|
<DialogDescription>{s.rename.confirmDescription}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter style={{ display: "flex", gap: 8 }}>
|
||||||
|
<DialogAction style={{ flex: 1 }}>{s.rename.confirmCancel}</DialogAction>
|
||||||
|
<DialogAction style={{ flex: 1 }} onClick={confirmRename}>
|
||||||
|
{s.rename.confirmSave}
|
||||||
|
</DialogAction>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPositioner>
|
||||||
|
</DialogRoot>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackButton({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={s.detail.back}
|
||||||
|
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer", alignSelf: "flex-start", padding: 0 }}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/features/accounting/TransactionList.test.tsx
Normal file
52
src/features/accounting/TransactionList.test.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import type { Txn } from "@/api/schemas";
|
||||||
|
import { setHidden } from "@/ds/money";
|
||||||
|
|
||||||
|
const txns: Txn[] = [
|
||||||
|
{
|
||||||
|
date: "2026-08-20T09:00:00Z",
|
||||||
|
amount: "15000",
|
||||||
|
direction: "expense",
|
||||||
|
category: "Хоол",
|
||||||
|
title: "Кофе шоп",
|
||||||
|
accountId: 1,
|
||||||
|
txnId: 101,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2026-08-20T08:00:00Z",
|
||||||
|
amount: "2500000",
|
||||||
|
direction: "income",
|
||||||
|
category: "Цалин",
|
||||||
|
title: "ХХК цалин",
|
||||||
|
accountId: 1,
|
||||||
|
txnId: 102,
|
||||||
|
salary: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mock("@/api/hooks/reads", () => ({
|
||||||
|
useTransactions: () => ({ data: txns, isLoading: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TransactionList } from "./TransactionList";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setHidden(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders transaction titles and formatted amounts, hiding salary rows by default", () => {
|
||||||
|
render(<TransactionList />);
|
||||||
|
|
||||||
|
// Non-salary row: title + a signed, grouped amount.
|
||||||
|
expect(screen.getByText("Кофе шоп")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("−15,000₮")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Salary row is filtered out of the row list by default...
|
||||||
|
expect(screen.queryByText("ХХК цалин")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// ...but its amount still counts toward the income total (salary is
|
||||||
|
// included in totals per TransactionsModel.recompute, only excluded from
|
||||||
|
// the row list).
|
||||||
|
expect(screen.getByText("2,500,000₮")).toBeInTheDocument();
|
||||||
|
});
|
||||||
174
src/features/accounting/TransactionList.tsx
Normal file
174
src/features/accounting/TransactionList.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo } 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 { MASKED, tugrikRaw } from "@/ds/money";
|
||||||
|
import { accountingStrings as s } from "./strings";
|
||||||
|
import { txnRouteId } from "./txnRoute";
|
||||||
|
import { useHiddenAmounts } from "./useHiddenAmounts";
|
||||||
|
|
||||||
|
/** Local calendar day (browser-local time), for grouping + the day header —
|
||||||
|
* a dependency-free stand-in for iOS's Asia/Ulaanbaatar `Calendar`. */
|
||||||
|
function dayKeyAndLabel(iso: string): { key: string; label: string } {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return { key: iso.slice(0, 10), label: iso.slice(0, 10) };
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = d.getMonth() + 1;
|
||||||
|
const day = d.getDate();
|
||||||
|
return { key: `${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`, label: `${m}-р сарын ${day}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
items: Txn[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Groups already-sorted (newest-first) rows into consecutive same-day buckets. */
|
||||||
|
function groupByDay(items: Txn[]): DayGroup[] {
|
||||||
|
const groups: DayGroup[] = [];
|
||||||
|
for (const txn of items) {
|
||||||
|
const { key, label } = dayKeyAndLabel(txn.date);
|
||||||
|
const last = groups[groups.length - 1];
|
||||||
|
if (last && last.key === key) {
|
||||||
|
last.items.push(txn);
|
||||||
|
} else {
|
||||||
|
groups.push({ key, label, items: [txn] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function amountOf(txn: Txn): number {
|
||||||
|
return parseFloat(txn.amount) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryRow({ label, value, hidden, tone }: { label: string; value: number; hidden: boolean; tone: "income" | "expense" }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<span style={{ fontSize: 16 }}>{label}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: tone === "income" ? "var(--mercury-success, #1e9e6b)" : "var(--mercury-critical, #e5484d)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hidden ? MASKED : tugrikRaw(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
|
||||||
|
const income = txn.direction === "income";
|
||||||
|
const isTransfer = txn.transfer === true;
|
||||||
|
const sign = income ? "+" : "−";
|
||||||
|
const amountText = hidden ? MASKED : `${sign}${tugrikRaw(txn.amount)}`;
|
||||||
|
const amountColor = isTransfer
|
||||||
|
? "var(--seed-color-fg-neutral-muted, #8b8b8b)"
|
||||||
|
: income
|
||||||
|
? "var(--mercury-success, #1e9e6b)"
|
||||||
|
: "var(--mercury-critical, #e5484d)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/accounting/${txnRouteId(txn)}`}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
padding: "12px 0",
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{txn.title || txn.category}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
|
||||||
|
{isTransfer ? `${txn.category} · ${s.list.transferTag}` : txn.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 700, color: amountColor, flexShrink: 0 }}>{amountText}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* 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 all = useMemo(() => data ?? [], [data]);
|
||||||
|
const hidden = useHiddenAmounts();
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
let income = 0;
|
||||||
|
let expense = 0;
|
||||||
|
for (const txn of all) {
|
||||||
|
if (txn.transfer === true) continue;
|
||||||
|
if (txn.direction === "income") income += amountOf(txn);
|
||||||
|
else expense += amountOf(txn);
|
||||||
|
}
|
||||||
|
return { income, expense };
|
||||||
|
}, [all]);
|
||||||
|
|
||||||
|
const visible = useMemo(() => all.filter((txn) => txn.salary !== true), [all]);
|
||||||
|
const groups = useMemo(() => groupByDay(visible), [visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>{s.list.title}</h1>
|
||||||
|
<HideAmountsToggle label={s.list.hideToggle} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
<SummaryRow label={s.list.income} value={totals.income} hidden={hidden} tone="income" />
|
||||||
|
<SummaryRow label={s.list.expense} value={totals.expense} hidden={hidden} tone="expense" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton style={{ height: 320, width: "100%", borderRadius: "var(--seed-radius-r3)" }} />
|
||||||
|
) : groups.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<p style={{ margin: 0, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{s.list.empty}</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.key}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--seed-color-fg-neutral-muted, #8b8b8b)",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
{group.items.map((txn, i) => (
|
||||||
|
<TxnRow key={`${txnRouteId(txn)}-${i}`} txn={txn} hidden={hidden} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/features/accounting/strings.ts
Normal file
44
src/features/accounting/strings.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Accounting (Тооцоо) feature copy, ported verbatim from
|
||||||
|
// ios/Mercury/Features/Transactions/TransactionsView.swift,
|
||||||
|
// TransactionDetailView.swift, and
|
||||||
|
// ios/Mercury/Features/Categories/CategorizeReviewView.swift,
|
||||||
|
// ios/Mercury/Features/Planner/PlannerEditViews.swift (CategoryPickerSheet).
|
||||||
|
export const accountingStrings = {
|
||||||
|
list: {
|
||||||
|
title: "Гүйлгээ",
|
||||||
|
hideToggle: "Мөнгөн дүн нуух",
|
||||||
|
income: "Орлого",
|
||||||
|
expense: "Зарлага",
|
||||||
|
empty: "Гүйлгээ алга",
|
||||||
|
transferTag: "Шилжүүлэг",
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
total: "Нийт",
|
||||||
|
date: "Огноо",
|
||||||
|
category: "Ангилал",
|
||||||
|
name: "Нэр",
|
||||||
|
balance: "Үлдэгдэл",
|
||||||
|
type: "Төрөл",
|
||||||
|
income: "Орлого",
|
||||||
|
expense: "Зарлага",
|
||||||
|
note: "Тэмдэглэл",
|
||||||
|
noteAdd: "Нэмэх",
|
||||||
|
noteSave: "Хадгалах",
|
||||||
|
noteCancel: "Болих",
|
||||||
|
notFound: "Гүйлгээ олдсонгүй",
|
||||||
|
back: "Буцах",
|
||||||
|
},
|
||||||
|
rename: {
|
||||||
|
title: "Нэр өөрчлөх",
|
||||||
|
placeholder: "Шинэ нэр",
|
||||||
|
confirmTitle: "Нэр өөрчлөх үү?",
|
||||||
|
confirmDescription: "Энэ худалдагчийн бүх өмнөх болон дараагийн гүйлгээнд шинэ нэрийг хэрэглэнэ.",
|
||||||
|
confirmCancel: "Болих",
|
||||||
|
confirmSave: "Хадгалах",
|
||||||
|
},
|
||||||
|
categoryPicker: {
|
||||||
|
title: "Гарчиг",
|
||||||
|
cancel: "Болих",
|
||||||
|
select: "Сонгох",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
18
src/features/accounting/txnRoute.ts
Normal file
18
src/features/accounting/txnRoute.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { Txn } from "@/api/schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable per-row identifier for linking a list row to `/accounting/[id]`.
|
||||||
|
* Settled transactions carry a numeric `txnId`; pending holds (no id yet,
|
||||||
|
* same as iOS's `TxnDTO.txnId == nil`) fall back to a composite of their
|
||||||
|
* match key + date so the row is still linkable, best-effort, without a
|
||||||
|
* dedicated "get one transaction" endpoint.
|
||||||
|
*/
|
||||||
|
export function txnRouteId(txn: Txn): string {
|
||||||
|
if (txn.txnId != null) return String(txn.txnId);
|
||||||
|
return `p_${encodeURIComponent(txn.matchKey ?? txn.title)}_${encodeURIComponent(txn.date)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds the transaction in `txns` that a given `/accounting/[id]` id refers to. */
|
||||||
|
export function findTxnByRouteId(txns: Txn[], id: string): Txn | undefined {
|
||||||
|
return txns.find((t) => txnRouteId(t) === id);
|
||||||
|
}
|
||||||
34
src/features/accounting/useHiddenAmounts.ts
Normal file
34
src/features/accounting/useHiddenAmounts.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { HIDE_AMOUNTS_EVENT } from "@/ds";
|
||||||
|
import { isHidden } from "@/ds/money";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the global hide-amounts flag reactively. `tugrik()`/`isHidden()`
|
||||||
|
* read `localStorage` synchronously but don't cause a re-render on their
|
||||||
|
* own — pages that show masked amounts need to listen for the
|
||||||
|
* `HideAmountsToggle`-dispatched event (and other tabs' storage writes) to
|
||||||
|
* update immediately when the switch flips.
|
||||||
|
*/
|
||||||
|
export function useHiddenAmounts(): boolean {
|
||||||
|
const [hidden, setHiddenState] = useState<boolean>(() => isHidden());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onToggle(e: Event) {
|
||||||
|
const detail = (e as CustomEvent<{ hidden: boolean }>).detail;
|
||||||
|
setHiddenState(detail ? detail.hidden : isHidden());
|
||||||
|
}
|
||||||
|
function onStorage() {
|
||||||
|
setHiddenState(isHidden());
|
||||||
|
}
|
||||||
|
window.addEventListener(HIDE_AMOUNTS_EVENT, onToggle);
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(HIDE_AMOUNTS_EVENT, onToggle);
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return hidden;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue