From 488ccad540c771e862fb795edf2df6c6cea315af Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 26 Aug 2026 15:03:48 +0300 Subject: [PATCH] feat: improve ledger event rows --- src/entities/broker-operation/index.ts | 1 + .../ui/LedgerEntryRow.module.css | 42 ++++ .../broker-operation/ui/LedgerEntryRow.tsx | 100 +++++++++ .../ui/AccountOperationsPage.test.tsx | 59 +++++- .../ui/AccountOperationsPage.tsx | 193 ++---------------- .../ui/AccountOperationsPage.view.tsx | 135 ++++++++++++ .../ui/AccountOperationsPage.vm.ts | 84 ++++++++ src/pages/account/ui/AccountPage.test.tsx | 34 +++ .../ui/components/RecentOperations.view.tsx | 14 +- src/shared/api/generated/schema.d.ts | 1 + src/shared/api/spec/openapi.json | 5 + src/shared/i18n/locales/ru.ts | 5 + 12 files changed, 480 insertions(+), 193 deletions(-) create mode 100644 src/entities/broker-operation/ui/LedgerEntryRow.module.css create mode 100644 src/entities/broker-operation/ui/LedgerEntryRow.tsx create mode 100644 src/pages/account-operations/ui/AccountOperationsPage.view.tsx create mode 100644 src/pages/account-operations/ui/AccountOperationsPage.vm.ts diff --git a/src/entities/broker-operation/index.ts b/src/entities/broker-operation/index.ts index 87e343e..cfe67cb 100644 --- a/src/entities/broker-operation/index.ts +++ b/src/entities/broker-operation/index.ts @@ -4,3 +4,4 @@ export { operationCategories, useBrokerageOperations, } from "./api/useBrokerageOperations"; +export { LedgerEntryRow } from "./ui/LedgerEntryRow"; diff --git a/src/entities/broker-operation/ui/LedgerEntryRow.module.css b/src/entities/broker-operation/ui/LedgerEntryRow.module.css new file mode 100644 index 0000000..2fd0d75 --- /dev/null +++ b/src/entities/broker-operation/ui/LedgerEntryRow.module.css @@ -0,0 +1,42 @@ +.row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: var(--mantine-spacing-sm); + align-items: center; + padding: var(--mantine-spacing-md); + border-color: var(--mantine-color-gray-3); + background: var(--mantine-color-gray-0); +} + +.identity, +.amount { + min-width: 0; +} + +.amount { + text-align: right; + white-space: nowrap; +} + +.positive { + color: var(--mantine-color-green-6); +} + +.negative { + color: var(--mantine-color-red-6); +} + +.neutral { + color: var(--mantine-color-dimmed); +} + +@media (max-width: 36em) { + .row { + grid-template-columns: auto minmax(0, 1fr); + } + + .amount { + grid-column: 2; + text-align: left; + } +} diff --git a/src/entities/broker-operation/ui/LedgerEntryRow.tsx b/src/entities/broker-operation/ui/LedgerEntryRow.tsx new file mode 100644 index 0000000..1392828 --- /dev/null +++ b/src/entities/broker-operation/ui/LedgerEntryRow.tsx @@ -0,0 +1,100 @@ +import { Avatar, Paper, Stack, Text } from "@mantine/core"; +import type { FC } from "react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + formatMoney, + formatSnapshotTime, + instrumentLogoUrl, +} from "@/entities/portfolio"; +import type { BrokerageOperation } from "../api/useBrokerageOperations"; +import classes from "./LedgerEntryRow.module.css"; + +type LedgerEntryRowProps = { + operation: BrokerageOperation; +}; + +type PaymentPresentation = { + amount: string; + direction: "incoming" | "outgoing" | "neutral"; + tone: "positive" | "negative" | "neutral"; +}; + +const LedgerEntryRow: FC = (props) => { + const { operation } = props; + const { t } = useTranslation(); + const [imageFailed, setImageFailed] = useState(false); + const instrument = + operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument"); + const logoSrc = imageFailed ? null : instrumentLogoUrl(operation.logoName); + const payment = paymentPresentation(operation); + const metadata = [ + formatSnapshotTime(operation.occurredAt), + instrument, + t(`ledger.category.${operation.category}`), + ].join(" · "); + + return ( + + + + + {operation.description || operation.type} + + + {metadata} + + + + + {payment.amount} + + + {t(`ledger.direction.${payment.direction}`)} + + + + ); +}; + +function paymentPresentation( + operation: BrokerageOperation, +): PaymentPresentation { + if (!operation.payment) + return { amount: "—", direction: "neutral", tone: "neutral" }; + if (isZeroDecimal(operation.payment.value)) + return { + amount: formatMoney(operation.payment), + direction: "neutral", + tone: "neutral", + }; + if (operation.payment.value.startsWith("-")) + return { + amount: `−${formatMoney({ + ...operation.payment, + value: operation.payment.value.slice(1), + })}`, + direction: "outgoing", + tone: "negative", + }; + return { + amount: `+${formatMoney(operation.payment)}`, + direction: "incoming", + tone: "positive", + }; +} + +function isZeroDecimal(value: string): boolean { + return /^-?0(?:\.0+)?$/.test(value); +} + +export { LedgerEntryRow }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage.test.tsx index 238c579..8bfe0f8 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage.test.tsx @@ -1,6 +1,12 @@ import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -19,6 +25,7 @@ const operation: BrokerageOperation = { instrumentUid: null, ticker: "SBER", instrumentType: "share", + logoName: null, payment: null, quantity: "2", price: { currency: "RUB", value: "100" }, @@ -34,6 +41,8 @@ function configureTestAuth() { }); } +afterEach(cleanup); + describe("OperationRow", () => { it("marks cashless operations without rendering operation details", () => { render( @@ -51,6 +60,54 @@ describe("OperationRow", () => { expect(screen.queryByText("Цена: 100 ₽")).toBeNull(); expect(screen.queryByText("Количество: 2")).toBeNull(); }); + + it("labels a positive payment as a Russian incoming amount", () => { + render( + + + + + , + ); + + expect(screen.getByText("Поступление")).toBeInTheDocument(); + expect(screen.getByText("+15 ₽")).toBeInTheDocument(); + }); + + it("keeps a zero payment neutral", () => { + render( + + + + + , + ); + + expect(screen.getByText("Без движения денег")).toBeInTheDocument(); + expect(screen.queryByText("Поступление")).toBeNull(); + }); + + it("uses an instrument initial when the ledger entry has no logo", () => { + render( + + + + + , + ); + + expect(screen.getByText("S")).toBeInTheDocument(); + }); }); describe("AccountOperationsPage", () => { diff --git a/src/pages/account-operations/ui/AccountOperationsPage.tsx b/src/pages/account-operations/ui/AccountOperationsPage.tsx index 2666128..6e1cc29 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage.tsx @@ -1,188 +1,19 @@ -import { - Button, - Card, - Checkbox, - Group, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; import type { FC } from "react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useParams } from "react-router"; +import { LedgerEntryRow } from "@/entities/broker-operation"; import { - type BrokerageOperation, - type OperationCategory, - operationCategories, - useBrokerageOperations, -} from "@/entities/broker-operation"; -import { formatMoney, formatSnapshotTime } from "@/entities/portfolio"; -import { StateView } from "@/shared/ui"; - -function dateStart(value: string) { - return value ? `${value}T00:00:00.000Z` : undefined; -} - -function dateEnd(value: string) { - return value ? `${value}T23:59:59.999Z` : undefined; -} - -type OperationRowProps = { - operation: BrokerageOperation; -}; + AccountOperationsPageError, + AccountOperationsPageLoaded, + AccountOperationsPageLoading, +} from "./AccountOperationsPage.view"; +import { useAccountOperationsPageVm } from "./AccountOperationsPage.vm"; const AccountOperationsPage: FC = () => { - const { accountId } = useParams(); - const { t } = useTranslation(); - const [from, setFrom] = useState(""); - const [to, setTo] = useState(""); - const [categories, setCategories] = useState([]); - const [cursors, setCursors] = useState([]); - const cursor = cursors.at(-1); - const operations = useBrokerageOperations(accountId, { - from: dateStart(from), - to: dateEnd(to), - ...(categories.length ? { categories } : {}), - ...(cursor ? { cursor } : {}), - limit: 100, - }); - const reset = () => setCursors([]); + const viewModel = useAccountOperationsPageVm(); - if (operations.isLoading) - return ( - - ); - if (operations.isError) - return ( - operations.refetch()}> - {t("ledger.retry")} - - } - /> - ); - if (!operations.data) - return ( - - ); - const { data } = operations; - const nextCursor = data.nextCursor; - return ( - - {t("ledger.title")} - - { - setFrom(event.currentTarget.value); - reset(); - }} - /> - { - setTo(event.currentTarget.value); - reset(); - }} - /> - - { - setCategories(value as OperationCategory[]); - reset(); - }} - > - - {operationCategories.map((category) => ( - - ))} - - - {data.items.length ? ( - data.items.map((operation) => ( - - )) - ) : ( - - )} - {data.hasNext || cursors.length ? ( - - - - - ) : null} - - ); + if (viewModel.state === "loading") return ; + if (viewModel.state === "error") + return ; + return ; }; -const OperationRow: FC = (props) => { - const { operation } = props; - const { t } = useTranslation(); - - return ( - - - - {operation.description || operation.type} - - {formatSnapshotTime(operation.occurredAt)} ·{" "} - {operation.ticker ?? - operation.instrumentType ?? - t("ledger.noInstrument")} - - - - - {operation.payment - ? formatMoney(operation.payment) - : t("ledger.noMoney")} - - - {t(`ledger.category.${operation.category}`)} - - - - - ); -}; - -export { AccountOperationsPage, OperationRow }; +export { AccountOperationsPage, LedgerEntryRow as OperationRow }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage.view.tsx new file mode 100644 index 0000000..3800cc0 --- /dev/null +++ b/src/pages/account-operations/ui/AccountOperationsPage.view.tsx @@ -0,0 +1,135 @@ +import { + Button, + Checkbox, + Group, + Stack, + TextInput, + Title, +} from "@mantine/core"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { + LedgerEntryRow, + type OperationCategory, + operationCategories, +} from "@/entities/broker-operation"; +import { StateView } from "@/shared/ui"; +import type { AccountOperationsPageViewModel } from "./AccountOperationsPage.vm"; + +type AccountOperationsPageLoadedProps = Extract< + AccountOperationsPageViewModel, + { state: "loaded" } +>; + +type AccountOperationsPageErrorProps = Extract< + AccountOperationsPageViewModel, + { state: "error" } +>; + +const AccountOperationsPageLoading: FC = () => { + const { t } = useTranslation(); + + return ( + + ); +}; + +const AccountOperationsPageError: FC = ( + props, +) => { + const { retry } = props; + const { t } = useTranslation(); + + return ( + {t("ledger.retry")}} + /> + ); +}; + +const AccountOperationsPageLoaded: FC = ( + props, +) => { + const { + categories, + from, + hasNext, + hasPrevious, + items, + onCategoriesChange, + onFromChange, + onNext, + onPrevious, + onToChange, + to, + } = props; + const { t } = useTranslation(); + + return ( + + {t("ledger.title")} + + onFromChange(event.currentTarget.value)} + /> + onToChange(event.currentTarget.value)} + /> + + onCategoriesChange(value as OperationCategory[])} + > + + {operationCategories.map((category) => ( + + ))} + + + {items.length ? ( + items.map((operation) => ( + + )) + ) : ( + + )} + {hasNext || hasPrevious ? ( + + + + + ) : null} + + ); +}; + +export { + AccountOperationsPageError, + AccountOperationsPageLoaded, + AccountOperationsPageLoading, +}; diff --git a/src/pages/account-operations/ui/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage.vm.ts new file mode 100644 index 0000000..f8bd574 --- /dev/null +++ b/src/pages/account-operations/ui/AccountOperationsPage.vm.ts @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { useParams } from "react-router"; +import { + type BrokerageOperation, + type OperationCategory, + useBrokerageOperations, +} from "@/entities/broker-operation"; + +type AccountOperationsPageViewModel = + | { state: "loading" } + | { state: "error"; retry: () => void } + | { + state: "loaded"; + from: string; + to: string; + categories: OperationCategory[]; + items: BrokerageOperation[]; + hasNext: boolean; + hasPrevious: boolean; + onFromChange: (value: string) => void; + onToChange: (value: string) => void; + onCategoriesChange: (value: OperationCategory[]) => void; + onNext: () => void; + onPrevious: () => void; + }; + +const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { + const { accountId } = useParams(); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [categories, setCategories] = useState([]); + const [cursors, setCursors] = useState([]); + const cursor = cursors.at(-1); + const operations = useBrokerageOperations(accountId, { + from: dateStart(from), + to: dateEnd(to), + ...(categories.length ? { categories } : {}), + ...(cursor ? { cursor } : {}), + limit: 100, + }); + const reset = () => setCursors([]); + + if (operations.isLoading) return { state: "loading" }; + if (operations.isError) + return { state: "error", retry: () => operations.refetch() }; + if (!operations.data) return { state: "loading" }; + const nextCursor = operations.data.nextCursor; + + return { + state: "loaded", + from, + to, + categories, + items: operations.data.items, + hasNext: Boolean(operations.data.hasNext && nextCursor), + hasPrevious: Boolean(cursors.length), + onFromChange: (value) => { + setFrom(value); + reset(); + }, + onToChange: (value) => { + setTo(value); + reset(); + }, + onCategoriesChange: (value) => { + setCategories(value); + reset(); + }, + onNext: () => { + if (nextCursor) setCursors((value) => [...value, nextCursor]); + }, + onPrevious: () => setCursors((value) => value.slice(0, -1)), + }; +}; + +function dateStart(value: string) { + return value ? `${value}T00:00:00.000Z` : undefined; +} + +function dateEnd(value: string) { + return value ? `${value}T23:59:59.999Z` : undefined; +} + +export { type AccountOperationsPageViewModel, useAccountOperationsPageVm }; diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx index 1a31fb4..a6295bb 100644 --- a/src/pages/account/ui/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage.test.tsx @@ -399,6 +399,40 @@ describe("AccountPage states", () => { expect(screen.getByText("Вторая")).toBeInTheDocument(); }); + it("uses the ledger row for a recent outgoing operation", () => { + renderPage( + , + ); + + expect(screen.getByText("Списание")).toBeInTheDocument(); + expect(screen.getByText("−12 ₽")).toBeInTheDocument(); + }); + it("keeps the portfolio visible when upcoming events fail", () => { renderPage( = (props) => { ) : null} {operations.state === "loaded" ? operations.items.map((operation) => ( - - {operation.description || operation.type} - - {operation.payment - ? formatMoney(operation.payment) - : t("ledger.noMoney")} - - + )) : null}