From 9156f297073fefa2fe1c5cb9659495e2d532ac89 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 13 Aug 2026 22:45:00 +0300 Subject: [PATCH] feat: add portfolio --- src/entities/brokerage-account/index.ts | 3 +- .../portfolio/api/usePortfolio.test.tsx | 45 ++++ src/entities/portfolio/api/usePortfolio.ts | 36 +++ src/entities/portfolio/index.ts | 12 + src/entities/portfolio/model/display.test.ts | 11 + src/entities/portfolio/model/display.ts | 32 +++ src/pages/account/ui/AccountPage.module.css | 31 +++ src/pages/account/ui/AccountPage.test.tsx | 117 +++++++++ src/pages/account/ui/AccountPage.tsx | 240 +++++++++++++++++- src/shared/api/generated/schema.d.ts | 59 +++++ src/shared/i18n/locales/ru.ts | 20 ++ 11 files changed, 594 insertions(+), 12 deletions(-) create mode 100644 src/entities/portfolio/api/usePortfolio.test.tsx create mode 100644 src/entities/portfolio/api/usePortfolio.ts create mode 100644 src/entities/portfolio/index.ts create mode 100644 src/entities/portfolio/model/display.test.ts create mode 100644 src/entities/portfolio/model/display.ts create mode 100644 src/pages/account/ui/AccountPage.module.css create mode 100644 src/pages/account/ui/AccountPage.test.tsx diff --git a/src/entities/brokerage-account/index.ts b/src/entities/brokerage-account/index.ts index b4c45d6..bcd0559 100644 --- a/src/entities/brokerage-account/index.ts +++ b/src/entities/brokerage-account/index.ts @@ -1,6 +1,7 @@ +export type { BrokerageAccount } from "./api/useBrokerageAccounts"; export { brokerageAccountsQueryKey, useBrokerageAccounts, } from "./api/useBrokerageAccounts"; -export { errorCode } from "./model/display"; +export { accountTypeKey, errorCode } from "./model/display"; export { BrokerageAccountList } from "./ui/BrokerageAccountList"; diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx new file mode 100644 index 0000000..1731992 --- /dev/null +++ b/src/entities/portfolio/api/usePortfolio.test.tsx @@ -0,0 +1,45 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +const { get } = vi.hoisted(() => ({ get: vi.fn() })); + +vi.mock("../../../shared/api", () => ({ apiClient: { GET: get } })); + +import { portfolioQueryPolicy, usePortfolio } from "./usePortfolio"; + +describe("usePortfolio", () => { + it("uses the account endpoint with a 30 second cache and no retry", async () => { + const client = new QueryClient(); + get.mockResolvedValueOnce({ + data: { + lastSuccessfulAt: "2026-08-13T10:00:00Z", + isStale: false, + totalsByCurrency: [], + positions: [], + cashBalances: [], + }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => usePortfolio("account-1", "token"), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(get).toHaveBeenCalledWith( + "/api/v1/brokerage-accounts/{accountId}/portfolio", + { + params: { path: { accountId: "account-1" } }, + headers: { Authorization: "Bearer token" }, + }, + ); + expect(portfolioQueryPolicy).toEqual({ staleTime: 30_000, retry: false }); + }); +}); diff --git a/src/entities/portfolio/api/usePortfolio.ts b/src/entities/portfolio/api/usePortfolio.ts new file mode 100644 index 0000000..aaf9480 --- /dev/null +++ b/src/entities/portfolio/api/usePortfolio.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "../../../shared/api"; +import type { paths } from "../../../shared/api/generated/schema"; + +export const portfolioQueryKey = (accountId: string) => + ["portfolio", accountId] as const; + +export const portfolioQueryPolicy = { + staleTime: 30_000, + retry: false, +} as const; + +export type Portfolio = + paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]; + +export function usePortfolio( + accountId: string | undefined, + accessToken: string | null, +) { + return useQuery({ + queryKey: portfolioQueryKey(accountId ?? ""), + enabled: Boolean(accountId && accessToken), + ...portfolioQueryPolicy, + queryFn: async () => { + const { data, error } = await apiClient.GET( + "/api/v1/brokerage-accounts/{accountId}/portfolio", + { + params: { path: { accountId: accountId ?? "" } }, + headers: { Authorization: `Bearer ${accessToken}` }, + }, + ); + if (error) throw error; + return data as Portfolio; + }, + }); +} diff --git a/src/entities/portfolio/index.ts b/src/entities/portfolio/index.ts new file mode 100644 index 0000000..ffcc189 --- /dev/null +++ b/src/entities/portfolio/index.ts @@ -0,0 +1,12 @@ +export type { Portfolio } from "./api/usePortfolio"; +export { + portfolioQueryKey, + portfolioQueryPolicy, + usePortfolio, +} from "./api/usePortfolio"; +export { + formatDecimal, + formatMoney, + formatSnapshotTime, + portfolioErrorCode, +} from "./model/display"; diff --git a/src/entities/portfolio/model/display.test.ts b/src/entities/portfolio/model/display.test.ts new file mode 100644 index 0000000..23e2e09 --- /dev/null +++ b/src/entities/portfolio/model/display.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { formatDecimal, formatMoney } from "./display"; + +describe("portfolio display helpers", () => { + it("formats decimal strings without financial arithmetic", () => { + expect(formatDecimal("-1234567.89")).toBe("-1 234 567,89"); + expect(formatMoney({ value: "10", currency: "RUB" })).toBe("10 ₽"); + expect(formatMoney({ value: "0.5", currency: "USD" })).toBe("0,5 USD"); + expect(formatMoney(null)).toBe("—"); + }); +}); diff --git a/src/entities/portfolio/model/display.ts b/src/entities/portfolio/model/display.ts new file mode 100644 index 0000000..ea89d9c --- /dev/null +++ b/src/entities/portfolio/model/display.ts @@ -0,0 +1,32 @@ +import type { Portfolio } from "../api/usePortfolio"; + +type Money = Portfolio["totalsByCurrency"][number]; + +export function formatDecimal(value: string): string { + const [integer, fraction] = value.split("."); + const sign = integer.startsWith("-") ? "-" : ""; + const grouped = (sign ? integer.slice(1) : integer).replace( + /\B(?=(\d{3})+(?!\d))/g, + " ", + ); + return `${sign}${grouped}${fraction ? `,${fraction}` : ""}`; +} + +export function formatMoney(money: Money | null): string { + if (!money) return "—"; + return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`; +} + +export function formatSnapshotTime(value: string): string { + return new Intl.DateTimeFormat("ru-RU", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(new Date(value)); +} + +export function portfolioErrorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? String(error.code) + : undefined; +} diff --git a/src/pages/account/ui/AccountPage.module.css b/src/pages/account/ui/AccountPage.module.css new file mode 100644 index 0000000..109a4dc --- /dev/null +++ b/src/pages/account/ui/AccountPage.module.css @@ -0,0 +1,31 @@ +.page { + max-width: 960px; +} + +.total, +.row, +.cash { + border-color: var(--mantine-color-gray-3); + background: var(--mantine-color-gray-0); +} + +.total { + padding: var(--mantine-spacing-md); + border-top: 3px solid var(--mantine-color-blue-6); +} + +.row, +.cash { + padding: var(--mantine-spacing-md); +} + +.cash { + display: flex; + justify-content: space-between; +} + +@media (max-width: 36em) { + .row :global(.mantine-Group-root) { + align-items: flex-start; + } +} diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx new file mode 100644 index 0000000..0680178 --- /dev/null +++ b/src/pages/account/ui/AccountPage.test.tsx @@ -0,0 +1,117 @@ +import { MantineProvider } from "@mantine/core"; +import { cleanup, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Portfolio } from "../../../entities/portfolio"; +import { + AccountPageEmpty, + AccountPageError, + AccountPageLoaded, + AccountPageLoading, +} from "./AccountPage"; + +function renderPage(ui: React.ReactNode) { + return render( + + {ui} + , + ); +} + +const portfolio: Portfolio = { + lastSuccessfulAt: "2026-08-13T10:00:00Z", + isStale: true, + totalsByCurrency: [{ currency: "RUB", value: "1234.5" }], + positions: [ + { + figi: "FIGI-2", + displayName: "Вторая", + type: "STOCK", + quantity: "2", + currentValue: null, + averagePurchasePrice: null, + absoluteReturn: null, + }, + { + figi: "FIGI-1", + displayName: "Первая", + type: "BOND", + quantity: "1", + currentValue: { currency: "RUB", value: "10" }, + averagePurchasePrice: null, + absoluteReturn: { currency: "RUB", value: "-1" }, + }, + ], + cashBalances: [{ currency: "USD", value: "0.5" }], +}; + +describe("AccountPage states", () => { + afterEach(cleanup); + it("renders loading and empty states", () => { + renderPage(); + expect(screen.getByRole("status")).toBeInTheDocument(); + + renderPage(); + expect( + screen.getByRole("heading", { name: "Портфель пока пуст" }), + ).toBeInTheDocument(); + }); + + it("renders allowed error actions", () => { + const retry = vi.fn(); + renderPage(); + expect(screen.getByRole("link", { name: "К счетам" })).toHaveAttribute( + "href", + "/accounts", + ); + + renderPage(); + expect( + screen.getByRole("link", { name: "Открыть настройки T-Bank" }), + ).toHaveAttribute("href", "/settings"); + + renderPage(); + screen.getByRole("button", { name: "Повторить" }).click(); + expect(retry).toHaveBeenCalledOnce(); + }); + + it("renders a stale portfolio in upstream order with nullable values", () => { + renderPage(); + + expect( + screen.getByRole("heading", { level: 1, name: "Портфель" }), + ).toBeInTheDocument(); + expect( + screen.getByText( + "T-Bank временно недоступен. Показан последний успешный снимок.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("—")).toBeInTheDocument(); + expect(screen.getByText("Доход: —")).toBeInTheDocument(); + const positions = screen + .getAllByText(/Вторая|Первая/) + .map((element) => element.textContent); + expect(positions).toEqual(["Вторая", "Первая"]); + expect(screen.getByRole("heading", { name: "Акции" })).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Облигации" }), + ).toBeInTheDocument(); + }); + + it("uses the cached account title when available", () => { + renderPage( + , + ); + expect( + screen.getByRole("heading", { level: 1, name: "Основной" }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/pages/account/ui/AccountPage.tsx b/src/pages/account/ui/AccountPage.tsx index 5caeed9..6407679 100644 --- a/src/pages/account/ui/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage.tsx @@ -1,15 +1,54 @@ -import { Stack, Title } from "@mantine/core"; +import { + Alert, + Button, + Card, + Group, + Paper, + SimpleGrid, + Stack, + Text, + Title, +} from "@mantine/core"; +import { useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; +import { Link, useParams } from "react-router"; +import { + accountTypeKey, + type BrokerageAccount, +} from "../../../entities/brokerage-account"; +import type { Portfolio } from "../../../entities/portfolio"; +import { + formatMoney, + formatSnapshotTime, + portfolioErrorCode, + usePortfolio, +} from "../../../entities/portfolio"; +import { useAuth } from "../../../features/auth"; import { StateView } from "../../../shared/ui/StateView"; +import classes from "./AccountPage.module.css"; + +const accountsQueryKey = ["brokerage-accounts"] as const; export function AccountPage() { - const { t } = useTranslation(); - return ( - - {t("account.title")} - - - ); + const { accountId } = useParams(); + const { accessToken } = useAuth(); + const portfolio = usePortfolio(accountId, accessToken); + const account = useQueryClient() + .getQueryData(accountsQueryKey) + ?.find((item) => item.id === accountId); + + if (portfolio.isFetching) return ; + if (portfolio.isError) + return ( + portfolio.refetch()} + /> + ); + if (!portfolio.data) return ; + if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length) + return ; + return ; } export function AccountPageLoading() { @@ -23,20 +62,199 @@ export function AccountPageLoading() { ); } -export function AccountPageError() { +export function AccountPageError({ + code, + retry, +}: { + code?: string; + retry: () => void; +}) { const { t } = useTranslation(); + if (code === "NOT_FOUND") + return ( + + {t("portfolio.back")} + + } + /> + ); + if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN") + return ( + + {t("accounts.openSettings")} + + } + /> + ); + if (code === "TBANK_UNAVAILABLE") + return ( + {t("accounts.retry")}} + /> + ); return ( {t("accounts.retry")}} /> ); } -export function AccountPageEmpty() { +export function AccountPageEmpty({ account }: { account?: BrokerageAccount }) { const { t } = useTranslation(); return ( - + + + + + ); +} + +export function AccountPageLoaded({ + account, + portfolio, +}: { + account?: BrokerageAccount; + portfolio: Portfolio; +}) { + const { t } = useTranslation(); + const stocks = portfolio.positions.filter( + (position) => position.type === "STOCK", + ); + const bonds = portfolio.positions.filter( + (position) => position.type === "BOND", + ); + const otherPositions = portfolio.positions.filter( + (position) => position.type !== "STOCK" && position.type !== "BOND", + ); + return ( + + + {portfolio.isStale ? ( + {t("portfolio.staleWarning")} + ) : null} + + {t("portfolio.updatedAt", { + date: formatSnapshotTime(portfolio.lastSuccessfulAt), + })} + + + {portfolio.totalsByCurrency.map((total) => ( + + + {total.currency} + + + {formatMoney(total)} + + + ))} + + {stocks.length ? ( + + ) : null} + {bonds.length ? ( + + ) : null} + {otherPositions.length ? ( + + ) : null} +
+ {portfolio.cashBalances.map((balance) => ( + + {balance.currency} + {formatMoney(balance)} + + ))} +
+
+ ); +} + +function PositionSection({ + title, + positions, +}: { + title: string; + positions: Portfolio["positions"]; +}) { + const { t } = useTranslation(); + return ( +
+ {positions.map((position) => ( + + + + {position.displayName} + + {t("portfolio.quantity", { value: position.quantity })} + + + + {formatMoney(position.currentValue)} + + {t("portfolio.return", { + value: formatMoney(position.absoluteReturn), + })} + + + + + ))} +
+ ); +} + +function AccountHeading({ account }: { account?: BrokerageAccount }) { + const { t } = useTranslation(); + return ( + + {account?.name ?? t("portfolio.title")} + {account ? ( + + {t(accountTypeKey(account.type))} + + ) : null} + + ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + {title} + {children} + ); } diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index c5d411d..798e6e4 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -116,6 +116,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/brokerage-accounts/{accountId}/portfolio": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["PortfolioController_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -150,6 +166,28 @@ export interface components { /** Format: date */ openedOn: string | null; }; + MoneyDto: { + currency: string; + value: string; + }; + PortfolioPositionDto: { + figi: string | null; + displayName: string; + /** @enum {string} */ + type: "STOCK" | "BOND" | "FUND" | "CURRENCY" | "FUTURE" | "OPTION" | "STRUCTURED_NOTE" | "DIGITAL_FINANCIAL_ASSET" | "OTHER"; + quantity: string; + currentValue: components["schemas"]["MoneyDto"] | null; + averagePurchasePrice: components["schemas"]["MoneyDto"] | null; + absoluteReturn: components["schemas"]["MoneyDto"] | null; + }; + PortfolioDto: { + /** Format: date-time */ + lastSuccessfulAt: string; + isStale: boolean; + totalsByCurrency: components["schemas"]["MoneyDto"][]; + positions: components["schemas"]["PortfolioPositionDto"][]; + cashBalances: components["schemas"]["MoneyDto"][]; + }; }; responses: never; parameters: never; @@ -338,4 +376,25 @@ export interface operations { }; }; }; + PortfolioController_get: { + parameters: { + query?: never; + header?: never; + path: { + accountId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PortfolioDto"]; + }; + }; + }; + }; } diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 1371811..a976323 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -42,6 +42,26 @@ export const ru = { }, }, account: { title: "Счет" }, + portfolio: { + title: "Портфель", + emptyTitle: "Портфель пока пуст", + emptyText: "На счете нет позиций и доступных денежных остатков.", + accountUnavailableTitle: "Счет недоступен", + accountUnavailableText: "Вернитесь к списку и выберите доступный счет.", + connectionErrorTitle: "Проверьте подключение T-Bank", + connectionErrorText: "Настройте подключение, чтобы посмотреть портфель.", + back: "К счетам", + staleWarning: + "T-Bank временно недоступен. Показан последний успешный снимок.", + updatedAt: "Данные получены {{date}}", + positions: "Позиции", + stocks: "Акции", + bonds: "Облигации", + otherPositions: "Другие позиции", + cashBalances: "Денежные остатки", + quantity: "Количество: {{value}}", + return: "Доход: {{value}}", + }, settings: { title: "Настройки" }, tbank: { title: "T-Bank Invest",