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