From 6e89c1a8f765025bd7be37b59c696d092fd4bbe4 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 13 Aug 2026 20:57:01 +0300 Subject: [PATCH] feat: add brocker account list --- src/app/routes/AppRoutes.test.tsx | 54 ++++++++++++++- .../api/useBrokerageAccounts.ts | 27 ++++++++ src/entities/brokerage-account/index.ts | 6 ++ .../brokerage-account/model/display.ts | 11 +++ .../ui/BrokerageAccountList.module.css | 22 ++++++ .../ui/BrokerageAccountList.test.tsx | 33 +++++++++ .../ui/BrokerageAccountList.tsx | 46 +++++++++++++ .../ui/TbankConnectionSection.tsx | 3 + src/pages/accounts/ui/AccountsPage.tsx | 69 +++++++++++++++++-- src/shared/api/generated/schema.d.ts | 43 ++++++++++++ src/shared/i18n/locales/ru.ts | 24 ++++++- 11 files changed, 330 insertions(+), 8 deletions(-) create mode 100644 src/entities/brokerage-account/api/useBrokerageAccounts.ts create mode 100644 src/entities/brokerage-account/index.ts create mode 100644 src/entities/brokerage-account/model/display.ts create mode 100644 src/entities/brokerage-account/ui/BrokerageAccountList.module.css create mode 100644 src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx create mode 100644 src/entities/brokerage-account/ui/BrokerageAccountList.tsx diff --git a/src/app/routes/AppRoutes.test.tsx b/src/app/routes/AppRoutes.test.tsx index 75cdc47..944f787 100644 --- a/src/app/routes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes.test.tsx @@ -2,7 +2,19 @@ import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../shared/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + apiClient: { + ...actual.apiClient, + POST: vi.fn(async () => ({ data: undefined })), + }, + }; +}); + import { AuthProvider } from "../../features/auth"; import { AccountsPageEmpty, @@ -46,6 +58,37 @@ describe("AppRoutes", () => { ); }); + it("renders the T-Bank settings action for a missing connection", () => { + render( + + + + + , + ); + + expect( + screen.getByRole("link", { name: "Открыть настройки T-Bank" }), + ).toHaveAttribute("href", "/settings"); + }); + + it("renders retry and settings actions when T-Bank is unavailable", () => { + const retry = vi.fn(); + render( + + + + + , + ); + + screen.getByRole("button", { name: "Повторить" }).click(); + expect(retry).toHaveBeenCalledOnce(); + expect( + screen.getByRole("link", { name: "Открыть настройки T-Bank" }), + ).toHaveAttribute("href", "/settings"); + }); + it("redirects anonymous users from accounts", async () => { renderRoutes("/accounts"); @@ -71,7 +114,10 @@ describe("AppRoutes", () => { expect(screen.getByRole(role)).toBeInTheDocument(); } else { expect( - screen.getByRole("heading", { level: 2, name: "Пока ничего нет" }), + screen.getByRole("heading", { + level: 2, + name: "Нет инвестиционных счетов", + }), ).toBeInTheDocument(); } @@ -81,6 +127,8 @@ describe("AppRoutes", () => { expect(screen.queryAllByRole("alert")).toHaveLength( role === "alert" ? 1 : 0, ); - expect(screen.queryAllByText("Пока ничего нет")).toHaveLength(role ? 0 : 1); + expect(screen.queryAllByText("Нет инвестиционных счетов")).toHaveLength( + role ? 0 : 1, + ); }); }); diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.ts b/src/entities/brokerage-account/api/useBrokerageAccounts.ts new file mode 100644 index 0000000..a442807 --- /dev/null +++ b/src/entities/brokerage-account/api/useBrokerageAccounts.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "../../../shared/api"; +import type { paths } from "../../../shared/api/generated/schema"; + +export const brokerageAccountsQueryKey = ["brokerage-accounts"] as const; + +export type BrokerageAccount = + paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number]; + +export function useBrokerageAccounts(accessToken: string | null) { + return useQuery({ + queryKey: brokerageAccountsQueryKey, + enabled: Boolean(accessToken), + staleTime: 60_000, + retry: false, + queryFn: async () => { + const { data, error } = await apiClient.GET( + "/api/v1/brokerage-accounts", + { + headers: { Authorization: `Bearer ${accessToken}` }, + }, + ); + if (error) throw error; + return data as BrokerageAccount[]; + }, + }); +} diff --git a/src/entities/brokerage-account/index.ts b/src/entities/brokerage-account/index.ts new file mode 100644 index 0000000..b4c45d6 --- /dev/null +++ b/src/entities/brokerage-account/index.ts @@ -0,0 +1,6 @@ +export { + brokerageAccountsQueryKey, + useBrokerageAccounts, +} from "./api/useBrokerageAccounts"; +export { 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 new file mode 100644 index 0000000..0a9344e --- /dev/null +++ b/src/entities/brokerage-account/model/display.ts @@ -0,0 +1,11 @@ +import type { BrokerageAccount } from "../api/useBrokerageAccounts"; + +export function accountTypeKey(type: BrokerageAccount["type"]) { + return `accounts.types.${type}` as const; +} + +export function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? String(error.code) + : undefined; +} diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.module.css b/src/entities/brokerage-account/ui/BrokerageAccountList.module.css new file mode 100644 index 0000000..6e06345 --- /dev/null +++ b/src/entities/brokerage-account/ui/BrokerageAccountList.module.css @@ -0,0 +1,22 @@ +.card { + background: linear-gradient(135deg, #eff6ff 0%, #ffffff 72%); + border-color: #bfdbfe; + box-shadow: 0 10px 28px rgb(15 23 42 / 6%); + color: inherit; + text-decoration: none; + transition: + transform 120ms ease, + box-shadow 120ms ease; +} + +.card:hover, +.card:focus-visible { + box-shadow: 0 16px 36px rgb(15 23 42 / 10%); + transform: translateY(-2px); +} + +.kicker { + color: #2563eb; + letter-spacing: 0.04em; + text-transform: uppercase; +} diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx new file mode 100644 index 0000000..8a13a49 --- /dev/null +++ b/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx @@ -0,0 +1,33 @@ +import { MantineProvider } from "@mantine/core"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it } from "vitest"; +import { BrokerageAccountList } from "./BrokerageAccountList"; + +describe("BrokerageAccountList", () => { + it("renders account details and an account link", () => { + render( + + + + + , + ); + + expect(screen.getByRole("link", { name: /Основной/ })).toHaveAttribute( + "href", + "/accounts/account-1", + ); + expect(screen.getByText("Брокерский счет")).toBeInTheDocument(); + expect(screen.getByText(/Открыт/)).toBeInTheDocument(); + }); +}); diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList.tsx new file mode 100644 index 0000000..3e50131 --- /dev/null +++ b/src/entities/brokerage-account/ui/BrokerageAccountList.tsx @@ -0,0 +1,46 @@ +import { Card, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import type { BrokerageAccount } from "../api/useBrokerageAccounts"; +import { accountTypeKey } from "../model/display"; +import classes from "./BrokerageAccountList.module.css"; + +type BrokerageAccountListProps = { accounts: BrokerageAccount[] }; + +export function BrokerageAccountList({ accounts }: BrokerageAccountListProps) { + const { t } = useTranslation(); + return ( + + {accounts.map((account) => ( + + + + {t(accountTypeKey(account.type))} + + + {account.name} + + {account.openedOn ? ( + + {t("accounts.openedOn", { + date: new Intl.DateTimeFormat("ru-RU", { + dateStyle: "long", + timeZone: "UTC", + }).format(new Date(`${account.openedOn}T00:00:00Z`)), + })} + + ) : null} + + + ))} + + ); +} diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.tsx index 8b4e8d7..d94cd98 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection.tsx @@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { brokerageAccountsQueryKey } from "../../../entities/brokerage-account"; import { apiClient } from "../../../shared/api"; import type { paths } from "../../../shared/api/generated/schema"; import { useAuth } from "../../auth"; @@ -57,6 +58,7 @@ export function TbankConnectionSection() { }, onSuccess: (data) => { queryClient.setQueryData(["tbank-connection"], data); + queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey }); reset(); }, onError: (error) => setRequestError(errorCode(error)), @@ -73,6 +75,7 @@ export function TbankConnectionSection() { connected: false, connectedAt: null, }); + queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey }); setDeleteOpened(false); }, onError: () => setRequestError("REQUEST_ERROR"), diff --git a/src/pages/accounts/ui/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage.tsx index ad67734..b6ec77c 100644 --- a/src/pages/accounts/ui/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage.tsx @@ -1,13 +1,27 @@ -import { Stack, Title } from "@mantine/core"; +import { Button, Stack, Title } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { + BrokerageAccountList, + errorCode, + useBrokerageAccounts, +} from "../../../entities/brokerage-account"; +import { useAuth } from "../../../features/auth"; import { StateView } from "../../../shared/ui/StateView"; export function AccountsPage() { const { t } = useTranslation(); + const { accessToken } = useAuth(); + const accounts = useBrokerageAccounts(accessToken); + const code = errorCode(accounts.error); + if (accounts.isFetching) return ; + if (accounts.isError) + return accounts.refetch()} />; + if (!accounts.data?.length) return ; return ( {t("accounts.title")} - + ); } @@ -23,13 +37,57 @@ export function AccountsPageLoading() { ); } -export function AccountsPageError() { +export function AccountsPageError({ + code, + retry, +}: { + code?: string; + retry?: () => void; +}) { const { t } = useTranslation(); + const settingsAction = ( + + ); + if (code === "TBANK_NOT_CONNECTED") + return ( + + ); + if (code === "INVALID_TBANK_TOKEN") + return ( + + ); + if (code === "TBANK_UNAVAILABLE") + return ( + + + {settingsAction} + + } + /> + ); return ( {t("accounts.retry")}} /> ); } @@ -37,6 +95,9 @@ export function AccountsPageError() { export function AccountsPageEmpty() { const { t } = useTranslation(); return ( - + ); } diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 1725d4c..c5d411d 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -100,6 +100,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/brokerage-accounts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BrokerageAccountsController_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -126,6 +142,14 @@ export interface components { PutTbankConnectionDto: { token: string; }; + BrokerageAccountDto: { + id: string; + name: string; + /** @enum {string} */ + type: "BROKERAGE" | "INDIVIDUAL_INVESTMENT" | "INVEST_BOX" | "MONEY_MARKET_FUND"; + /** Format: date */ + openedOn: string | null; + }; }; responses: never; parameters: never; @@ -295,4 +319,23 @@ export interface operations { }; }; }; + BrokerageAccountsController_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrokerageAccountDto"][]; + }; + }; + }; + }; } diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 3e0c5b2..1371811 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -18,7 +18,29 @@ export const ru = { passwordsDoNotMatch: "Пароли не совпадают", }, }, - accounts: { title: "Счета" }, + accounts: { + title: "Счета", + emptyTitle: "Нет инвестиционных счетов", + emptyText: + "Откройте инвестиционный счет в T-Bank, чтобы он появился здесь.", + openedOn: "Открыт {{date}}", + openSettings: "Открыть настройки T-Bank", + notConnectedTitle: "Подключите T-Bank", + notConnectedText: + "Для просмотра счетов подключите T-Bank Invest в настройках.", + invalidTokenTitle: "Проверьте подключение T-Bank", + invalidTokenText: "Сохраненный token больше не дает доступ к счетам.", + unavailableTitle: "T-Bank временно недоступен", + unavailableText: + "Попробуйте загрузить счета еще раз или проверьте подключение.", + retry: "Повторить", + types: { + BROKERAGE: "Брокерский счет", + INDIVIDUAL_INVESTMENT: "ИИС", + INVEST_BOX: "Инвесткопилка", + MONEY_MARKET_FUND: "Фонд денежного рынка", + }, + }, account: { title: "Счет" }, settings: { title: "Настройки" }, tbank: {