From fd93133cfcdfd9372b2630cacaa7f5195f1beb58 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 26 Aug 2026 20:57:55 +0300 Subject: [PATCH] feat(accounts): enrich brokerage account list --- scripts/lazy-route-bundle-baseline.json | 10 ++-- src/entities/brokerage-account/index.ts | 7 ++- .../brokerage-account/model/display.ts | 41 +++++++++++++- .../ui/BrokerageAccountList.module.css | 37 +++++++++++- .../ui/BrokerageAccountList.test.tsx | 36 ++++++++++-- .../ui/BrokerageAccountList.tsx | 55 ++++++++++++++++-- src/pages/account/ui/AccountPage.test.tsx | 1 + src/pages/accounts/ui/AccountsPage.view.tsx | 4 +- src/pages/accounts/ui/AccountsPage.vm.ts | 8 ++- src/shared/api/generated/schema.d.ts | 12 ++-- src/shared/api/spec/openapi.json | 56 +++++++++++++------ src/shared/i18n/locales/ru.ts | 7 +++ 12 files changed, 230 insertions(+), 44 deletions(-) diff --git a/scripts/lazy-route-bundle-baseline.json b/scripts/lazy-route-bundle-baseline.json index 9c3cff3..c0de285 100644 --- a/scripts/lazy-route-bundle-baseline.json +++ b/scripts/lazy-route-bundle-baseline.json @@ -1,11 +1,11 @@ { "src/app/routes/ProtectedLayout.tsx": 3057, - "src/pages/account-events/index.ts": 4364, - "src/pages/account-operations/index.ts": 13854, - "src/pages/account/index.ts": 32466, - "src/pages/accounts/index.ts": 6145, + "src/pages/account-events/index.ts": 4331, + "src/pages/account-operations/index.ts": 13473, + "src/pages/account/index.ts": 31277, + "src/pages/accounts/index.ts": 6205, "src/pages/login/index.ts": 662, "src/pages/not-found/index.ts": 877, "src/pages/register/index.ts": 668, - "src/pages/settings/index.ts": 38979 + "src/pages/settings/index.ts": 38980 } diff --git a/src/entities/brokerage-account/index.ts b/src/entities/brokerage-account/index.ts index 4e43036..fb1163e 100644 --- a/src/entities/brokerage-account/index.ts +++ b/src/entities/brokerage-account/index.ts @@ -3,5 +3,10 @@ export { brokerageAccountsQueryKey, useBrokerageAccounts, } from "./api/useBrokerageAccounts"; -export { accountTypeKey, errorCode } from "./model/display"; +export { + accountTypeKey, + type BrokerageAccountCard, + brokerageAccountCards, + errorCode, +} from "./model/display"; export { BrokerageAccountList } from "./ui/BrokerageAccountList"; diff --git a/src/entities/brokerage-account/model/display.ts b/src/entities/brokerage-account/model/display.ts index b2c5d83..61c45aa 100644 --- a/src/entities/brokerage-account/model/display.ts +++ b/src/entities/brokerage-account/model/display.ts @@ -1,5 +1,17 @@ import type { BrokerageAccount } from "../api/useBrokerageAccounts"; +type Money = NonNullable< + BrokerageAccount["portfolioSummary"] +>["totalsByCurrency"][number]; + +type BrokerageAccountCard = { + account: BrokerageAccount; + summary: + | { state: "unavailable" } + | { state: "empty" } + | { state: "available"; totals: Money[]; remainingCurrencyCount: number }; +}; + function accountTypeKey(type: BrokerageAccount["type"]) { return `accounts.types.${type}` as const; } @@ -10,4 +22,31 @@ function errorCode(error: unknown): string | undefined { : undefined; } -export { accountTypeKey, errorCode }; +function brokerageAccountCards( + accounts: BrokerageAccount[], +): BrokerageAccountCard[] { + return accounts.map((account) => { + const summary = account.portfolioSummary; + if (!summary) return { account, summary: { state: "unavailable" } }; + if (!summary.totalsByCurrency.length) + return { account, summary: { state: "empty" } }; + return { + account, + summary: { + state: "available", + totals: summary.totalsByCurrency.slice(0, 2), + remainingCurrencyCount: Math.max( + summary.totalsByCurrency.length - 2, + 0, + ), + }, + }; + }); +} + +export { + accountTypeKey, + type BrokerageAccountCard, + brokerageAccountCards, + errorCode, +}; diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.module.css b/src/entities/brokerage-account/ui/BrokerageAccountList.module.css index 6e06345..b1b2578 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList.module.css +++ b/src/entities/brokerage-account/ui/BrokerageAccountList.module.css @@ -1,6 +1,14 @@ +.list { + width: 100%; +} + .card { - background: linear-gradient(135deg, #eff6ff 0%, #ffffff 72%); - border-color: #bfdbfe; + background: linear-gradient( + 135deg, + var(--mantine-color-blue-0) 0%, + var(--mantine-color-body) 72% + ); + border-color: var(--mantine-color-blue-2); box-shadow: 0 10px 28px rgb(15 23 42 / 6%); color: inherit; text-decoration: none; @@ -16,7 +24,30 @@ } .kicker { - color: #2563eb; + color: var(--mantine-color-blue-7); letter-spacing: 0.04em; text-transform: uppercase; } + +.summary { + font-variant-numeric: tabular-nums; +} + +.total { + white-space: nowrap; +} + +.open { + color: var(--mantine-color-blue-7); +} + +@media (prefers-reduced-motion: reduce) { + .card { + transition: none; + } + + .card:hover, + .card:focus-visible { + transform: none; + } +} diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx index 8a13a49..b8ce98c 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx @@ -2,6 +2,7 @@ import { MantineProvider } from "@mantine/core"; import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; +import { brokerageAccountCards } from "../model/display"; import { BrokerageAccountList } from "./BrokerageAccountList"; describe("BrokerageAccountList", () => { @@ -10,14 +11,35 @@ describe("BrokerageAccountList", () => { , @@ -27,7 +49,13 @@ describe("BrokerageAccountList", () => { "href", "/accounts/account-1", ); - expect(screen.getByText("Брокерский счет")).toBeInTheDocument(); - expect(screen.getByText(/Открыт/)).toBeInTheDocument(); + expect(screen.getAllByText("Брокерский счет")).toHaveLength(3); + expect(screen.getByText(/^Открыт /)).toBeInTheDocument(); + expect(screen.getByText("1 500 ₽")).toBeInTheDocument(); + expect(screen.getByText("20 USD")).toBeInTheDocument(); + expect(screen.getByText("Еще 1 валюта")).toBeInTheDocument(); + expect(screen.getByText("Сводка временно недоступна")).toBeInTheDocument(); + expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument(); + expect(screen.getAllByText("Открыть счет")).toHaveLength(3); }); }); diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList.tsx index 76d4c53..abfb332 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList.tsx @@ -1,20 +1,59 @@ -import { Card, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Card, Group, Stack, Text } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import type { BrokerageAccount } from "../api/useBrokerageAccounts"; +import { formatMoney } from "@/entities/portfolio"; +import type { BrokerageAccountCard } from "../model/display"; import { accountTypeKey } from "../model/display"; import classes from "./BrokerageAccountList.module.css"; -type BrokerageAccountListProps = { accounts: BrokerageAccount[] }; +type BrokerageAccountListProps = { accounts: BrokerageAccountCard[] }; + +type BrokerageAccountSummaryProps = { + summary: BrokerageAccountCard["summary"]; +}; + +const BrokerageAccountSummary: FC = (props) => { + const { summary } = props; + const { t } = useTranslation(); + if (summary.state === "unavailable") + return ( + + {t("accounts.summaryUnavailable")} + + ); + if (summary.state === "empty") + return ( + + {t("accounts.summaryEmpty")} + + ); + + return ( + + {summary.totals.map((total) => ( + + {formatMoney(total)} + + ))} + {summary.remainingCurrencyCount ? ( + + {t("accounts.remainingCurrencies", { + count: summary.remainingCurrencyCount, + })} + + ) : null} + + ); +}; const BrokerageAccountList: FC = (props) => { const { accounts } = props; const { t } = useTranslation(); return ( - - {accounts.map((account) => ( + + {accounts.map(({ account, summary }) => ( = (props) => { {account.name} + {account.openedOn ? ( {t("accounts.openedOn", { @@ -41,10 +81,13 @@ const BrokerageAccountList: FC = (props) => { })} ) : null} + + {t("accounts.openAccount")} + ))} - + ); }; diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx index a6295bb..fc6f0f8 100644 --- a/src/pages/account/ui/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage.test.tsx @@ -374,6 +374,7 @@ describe("AccountPage states", () => { name: "Основной", type: "BROKERAGE", openedOn: null, + portfolioSummary: null, }} />, ); diff --git a/src/pages/accounts/ui/AccountsPage.view.tsx b/src/pages/accounts/ui/AccountsPage.view.tsx index 3e56022..2847832 100644 --- a/src/pages/accounts/ui/AccountsPage.view.tsx +++ b/src/pages/accounts/ui/AccountsPage.view.tsx @@ -10,7 +10,7 @@ import { import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import type { BrokerageAccount } from "@/entities/brokerage-account"; +import type { BrokerageAccountCard } from "@/entities/brokerage-account"; import { BrokerageAccountList } from "@/entities/brokerage-account"; import { StateView } from "@/shared/ui"; @@ -26,7 +26,7 @@ type AccountsPageEmptyProps = { }; type AccountsPageLoadedProps = { - accounts: BrokerageAccount[]; + accounts: BrokerageAccountCard[]; refreshing?: boolean; refreshError?: boolean; retry?: () => void; diff --git a/src/pages/accounts/ui/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage.vm.ts index 9274128..d27243b 100644 --- a/src/pages/accounts/ui/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage.vm.ts @@ -1,4 +1,8 @@ -import { errorCode, useBrokerageAccounts } from "@/entities/brokerage-account"; +import { + brokerageAccountCards, + errorCode, + useBrokerageAccounts, +} from "@/entities/brokerage-account"; function useAccountsPageViewModel() { const accounts = useBrokerageAccounts(); @@ -20,7 +24,7 @@ function useAccountsPageViewModel() { }; return { state: "loaded" as const, - accounts: accounts.data, + accounts: brokerageAccountCards(accounts.data), refreshing: accounts.isFetching, refreshError: accounts.isError, retry: () => accounts.refetch(), diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index efe5ba3..7bffb18 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -205,6 +205,13 @@ export interface components { PutTbankConnectionDto: { token: string; }; + MoneyDto: { + currency: string; + value: string; + }; + PortfolioSummaryDto: { + totalsByCurrency: components["schemas"]["MoneyDto"][]; + }; BrokerageAccountDto: { id: string; name: string; @@ -212,10 +219,7 @@ export interface components { type: "BROKERAGE" | "INDIVIDUAL_INVESTMENT" | "INVEST_BOX" | "MONEY_MARKET_FUND"; /** Format: date */ openedOn: string | null; - }; - MoneyDto: { - currency: string; - value: string; + portfolioSummary: components["schemas"]["PortfolioSummaryDto"] | null; }; BrokerageOperationDto: { id: string; diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index c00765b..6f9ac72 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -1109,6 +1109,35 @@ "token" ] }, + "MoneyDto": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "currency", + "value" + ] + }, + "PortfolioSummaryDto": { + "type": "object", + "properties": { + "totalsByCurrency": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MoneyDto" + } + } + }, + "required": [ + "totalsByCurrency" + ] + }, "BrokerageAccountDto": { "type": "object", "properties": { @@ -1131,28 +1160,23 @@ "type": "string", "nullable": true, "format": "date" + }, + "portfolioSummary": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/PortfolioSummaryDto" + } + ] } }, "required": [ "id", "name", "type", - "openedOn" - ] - }, - "MoneyDto": { - "type": "object", - "properties": { - "currency": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "currency", - "value" + "openedOn", + "portfolioSummary" ] }, "BrokerageOperationDto": { diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 91f822e..47111ec 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -32,6 +32,13 @@ const ru = { emptyText: "Откройте инвестиционный счет в T-Bank, чтобы он появился здесь.", openedOn: "Открыт {{date}}", + openAccount: "Открыть счет", + summaryUnavailable: "Сводка временно недоступна", + summaryEmpty: "Нет текущих сумм", + remainingCurrencies_one: "Еще {{count}} валюта", + remainingCurrencies_few: "Еще {{count}} валюты", + remainingCurrencies_many: "Еще {{count}} валют", + remainingCurrencies_other: "Еще {{count}} валюты", openSettings: "Открыть настройки T-Bank", notConnectedTitle: "Подключите T-Bank", notConnectedText: