diff --git a/AGENTS.md b/AGENTS.md index c6709be..4425c28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ Gate non-mutating и проверяет runtime, OpenAPI snapshot, hygiene, stri - Code style и i18n: [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md), исполняются Biome и TypeScript. - FSD и public API: [`docs/STRUCTURE.md`](docs/STRUCTURE.md), исполняются dependency-cruiser и Knip; направление `app → pages → features → entities → shared`. - HTTP и types: [`docs/API-LAYER.md`](docs/API-LAYER.md); только typed client и generated OpenAPI `paths[...]`, без ручных transport DTO и прямых T-Bank calls. -- UI states: [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md); route runtime failures обрабатывает единый route-keyed ErrorBoundary, expected API states остаются локальными. +- UI states и VM/view boundary: [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md); container вызывает page VM hook, VM владеет data/state/derived presentation data, `*.view.tsx` получает props и рендерит. View files дробить по semantic responsibility, когда section/state можно назвать отдельно. - Тесты: [`docs/TESTING.md`](docs/TESTING.md); новый тест обязателен по risk trigger, а не из-за появления файла или coverage процента. Непроверяемые эстетические предпочтения считаются guidance. Не добавлять Storybook, Playwright, CI, browser MSW worker или mock development mode без утверждённого change. diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index c8fc142..cbc9bbc 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -13,8 +13,12 @@ ## File roles -- `Component.tsx` - контейнер или публичный компонент. -- `Component.vm.ts` - hook с данными и действиями. -- `Component.view.tsx` - чистое представление. +- `Component.tsx` - контейнер или публичный компонент; для data-driven pages вызывает page VM hook и выбирает top-level state view. +- `Component.vm.ts` - hook с данными, действиями, state selection и derived presentation data. +- `Component.view.tsx` - чистое представление: получает props, рендерит JSX и не вызывает data/auth/router/navigation/VM hooks. - `Component.types.ts` - props и локальные типы. - `Component.module.css` - локальные стили. + +Большие view files дробить по ответственности. Если section/state можно +самостоятельно назвать, вынести его в соседний presentation component вместо +накопления смысловой нагрузки в одном файле. diff --git a/docs/UI-PATTERN.md b/docs/UI-PATTERN.md index 2b5013c..af140f6 100644 --- a/docs/UI-PATTERN.md +++ b/docs/UI-PATTERN.md @@ -6,4 +6,29 @@ Запросные экраны без успешных данных выбирают состояние в порядке loading, error, empty, loaded. Если query уже содержит данные, они остаются видимыми при фоновом refetch и его ошибке: рядом показываются неблокирующий progress и локальное предупреждение с retry. Такое состояние называется Client Cached Snapshot; оно не заменяет доменное server-side stale/fallback состояние. -Для finance-экранов container вызывает query hooks, VM выбирает состояние и передает только presentation props в Loading/Error/Empty/Loaded view. Known API errors, retry и stale snapshot остаются local views, а непойманные route errors обрабатывает общий route-keyed ErrorBoundary. +Для finance-экранов используется граница `container -> ViewModel -> view`. +`*.tsx` без суффикса — container: он вызывает единственный page VM hook и +выбирает top-level Loading/Error/Empty/Loaded view. `*.vm.ts` владеет data +fetching, auth/route inputs, state selection, retry callbacks, domain grouping +и derived presentation data. `*.view.tsx` и соседние presentation components +получают props и рендерят JSX. + +View modules не вызывают data/auth/router/navigation/VM hooks: никаких +`useBrokerage*`, `usePortfolio`, `useAuth`, `useParams`, `useNavigate` или +page VM hooks внутри `*.view.tsx` и `components/*.view.tsx`. View modules также +не группируют domain collections и не считают derived collection values вроде +долей Portfolio Position. Эти значения готовит VM. + +Допустимые hooks во view: `useTranslation()` для labels, form-context hooks в +form views и локальный UI-only state для rendering fallback, например failed +image state. Простое форматирование одного переданного значения через +существующий formatter остаётся presentation concern; вычисления из всей +коллекции остаются во VM. + +View files дробятся по смысловой нагрузке, а не только по количеству строк. Если +section или state имеет самостоятельный смысл и может быть назван, его нужно +выносить в nearby presentation component вместо накопления внутри одного +большого `*.view.tsx`. + +Known API errors, retry и stale snapshot остаются local views, а непойманные +route errors обрабатывает общий route-keyed ErrorBoundary. diff --git a/scripts/lazy-route-bundle-baseline.json b/scripts/lazy-route-bundle-baseline.json index f8254bd..08b2014 100644 --- a/scripts/lazy-route-bundle-baseline.json +++ b/scripts/lazy-route-bundle-baseline.json @@ -2,7 +2,7 @@ "src/app/routes/ProtectedLayout.tsx": 3137, "src/pages/account-events/index.ts": 4468, "src/pages/account-operations/index.ts": 13861, - "src/pages/account/index.ts": 21872, + "src/pages/account/index.ts": 22366, "src/pages/accounts/index.ts": 6174, "src/pages/login/index.ts": 669, "src/pages/not-found/index.ts": 850, diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx index eb5104c..1e23361 100644 --- a/src/pages/account/ui/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage.test.tsx @@ -13,6 +13,11 @@ import { AccountPageLoaded, AccountPageLoading, } from "./AccountPage"; +import type { + AccountPageRecentEvents, + AccountPageRecentOperations, +} from "./AccountPage.vm"; +import { toAccountPagePositionSections } from "./AccountPage.vm"; vi.mock("@/features/auth", () => ({ useAuth: () => ({ accessToken: "test-token" }), @@ -73,13 +78,39 @@ const portfolio: Portfolio = { cashBalances: [{ currency: "USD", value: "0.5" }], }; +const emptyRecentOperations: AccountPageRecentOperations = { + state: "empty", + to: "/accounts/account-1/operations", +}; + +const emptyRecentEvents: AccountPageRecentEvents = { + state: "empty", + to: "/accounts/account-1/events", +}; + +function loadedProps(nextPortfolio: Portfolio = portfolio) { + return { + portfolio: nextPortfolio, + positionSections: toAccountPagePositionSections(nextPortfolio), + recentOperations: emptyRecentOperations, + recentEvents: emptyRecentEvents, + }; +} + +function emptyProps() { + return { + recentOperations: emptyRecentOperations, + recentEvents: emptyRecentEvents, + }; +} + describe("AccountPage states", () => { afterEach(cleanup); it("renders loading and empty states", () => { renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); - renderPage(); + renderPage(); expect( screen.getByRole("heading", { name: "Портфель пока пуст" }), ).toBeInTheDocument(); @@ -104,7 +135,7 @@ describe("AccountPage states", () => { }); it("renders a stale portfolio in upstream order with nullable values hidden", () => { - renderPage(); + renderPage(); expect( screen.getByRole("heading", { level: 1, name: "Портфель" }), @@ -138,7 +169,7 @@ describe("AccountPage states", () => { it("renders stock cards with logo fallback and signed unlabeled return", () => { renderPage( { logoName: null, }, ], - }} + })} />, ); expect(screen.getByText("А")).toBeInTheDocument(); @@ -177,7 +208,7 @@ describe("AccountPage states", () => { it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => { renderPage( { logoName: null, }, ], - }} + })} />, ); expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute( @@ -222,10 +253,10 @@ describe("AccountPage states", () => { it("renders the bonds section as a single-column grid on any screen width", () => { renderPage( , ); const singleColumnGrids = Array.from(document.querySelectorAll("style")) @@ -242,7 +273,7 @@ describe("AccountPage states", () => { it("keeps other-position cards compact with labeled return and no logo", () => { renderPage( { logoName: "fond.png", }, ], - }} + })} />, ); expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument(); @@ -267,7 +298,7 @@ describe("AccountPage states", () => { it("uses the cached account title when available", () => { renderPage( { ).toBeInTheDocument(); }); - it("keeps the portfolio visible when recent events fail", async () => { - server.use( - http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => - HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), - ), - ); + it("keeps the portfolio visible when recent operations fail", () => { renderPage( , ); - await waitFor(() => - expect( - screen.getByText("Не удалось загрузить последние события."), - ).toBeInTheDocument(), - ); + expect( + screen.getByText("Не удалось загрузить последние события."), + ).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); }); - it("keeps the portfolio visible when upcoming events fail", async () => { - server.use( - http.get("*/api/v1/brokerage-accounts/:accountId/events", () => - HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }), - ), - ); + it("keeps the portfolio visible when upcoming events fail", () => { renderPage( , ); - await waitFor(() => - expect( - screen.getByText("Не удалось загрузить будущие события."), - ).toBeInTheDocument(), - ); + expect( + screen.getByText("Не удалось загрузить будущие события."), + ).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); }); diff --git a/src/pages/account/ui/AccountPage.tsx b/src/pages/account/ui/AccountPage.tsx index cb2abbc..9024ed7 100644 --- a/src/pages/account/ui/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage.tsx @@ -14,22 +14,23 @@ export function AccountPage() { return ( ); return ( ); } diff --git a/src/pages/account/ui/AccountPage.view.tsx b/src/pages/account/ui/AccountPage.view.tsx index 9730b9c..8847ac2 100644 --- a/src/pages/account/ui/AccountPage.view.tsx +++ b/src/pages/account/ui/AccountPage.view.tsx @@ -1,35 +1,26 @@ -import { - Alert, - Avatar, - Button, - Card, - Group, - Loader, - Paper, - SimpleGrid, - Stack, - Text, - Title, -} from "@mantine/core"; -import { useState } from "react"; +import { Alert, Button, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { useBrokerageOperations } from "@/entities/broker-operation"; import type { BrokerageAccount } from "@/entities/brokerage-account"; -import { accountTypeKey } from "@/entities/brokerage-account"; -import { - type BrokerageEvent, - useBrokerageEvents, -} from "@/entities/brokerage-event"; import type { Portfolio } from "@/entities/portfolio"; -import { - formatMoney, - formatShare, - formatSnapshotTime, - instrumentLogoUrl, -} from "@/entities/portfolio"; +import { formatSnapshotTime } from "@/entities/portfolio"; import { StateView } from "@/shared/ui"; import classes from "./AccountPage.module.css"; +import type { + AccountPagePositionSection, + AccountPageRecentEvents, + AccountPageRecentOperations, +} from "./AccountPage.vm"; +import { + AccountHeading, + CashBalances, + PortfolioRefreshState, + PortfolioTotals, + PositionSection, + RecentEvents, + RecentOperations, + Section, +} from "./components"; export function AccountPageLoading() { const { t } = useTranslation(); @@ -41,6 +32,7 @@ export function AccountPageLoading() { /> ); } + export function AccountPageError({ code, retry, @@ -93,20 +85,21 @@ export function AccountPageError({ /> ); } + export function AccountPageEmpty({ account, - accountId, - accessToken = null, refreshing = false, refreshError = false, retry, + recentOperations, + recentEvents, }: { account?: BrokerageAccount; - accountId?: string; - accessToken?: string | null; refreshing?: boolean; refreshError?: boolean; retry?: () => void; + recentOperations: AccountPageRecentOperations; + recentEvents: AccountPageRecentEvents; }) { const { t } = useTranslation(); return ( @@ -121,43 +114,32 @@ export function AccountPageEmpty({ title={t("portfolio.emptyTitle")} text={t("portfolio.emptyText")} /> - - + + ); } + export function AccountPageLoaded({ account, - accountId, - accessToken, portfolio, + positionSections, refreshing = false, refreshError = false, retry, + recentOperations, + recentEvents, }: { account?: BrokerageAccount; - accountId?: string; - accessToken?: string | null; portfolio: Portfolio; + positionSections: AccountPagePositionSection[]; refreshing?: boolean; refreshError?: boolean; retry?: () => void; + recentOperations: AccountPageRecentOperations; + recentEvents: AccountPageRecentEvents; }) { const { t } = useTranslation(); - const stocks = portfolio.positions.filter((p) => p.type === "STOCK"); - const bonds = portfolio.positions.filter((p) => p.type === "BOND"); - const other = portfolio.positions.filter( - (p) => p.type !== "STOCK" && p.type !== "BOND", - ); - const denominators = new Map(); - for (const position of portfolio.positions) { - if (!position.currentValue) continue; - const currency = position.currentValue.currency; - denominators.set( - currency, - (denominators.get(currency) ?? 0) + Number(position.currentValue.value), - ); - } return ( @@ -174,336 +156,25 @@ export function AccountPageLoaded({ date: formatSnapshotTime(portfolio.lastSuccessfulAt), })} - - {portfolio.totalsByCurrency.map((total) => ( - - - {total.currency} - - - {formatMoney(total)} - - - ))} - - {stocks.length ? ( + + {positionSections.map((section) => ( - ) : null} - {bonds.length ? ( - - ) : null} - {other.length ? ( - - ) : null} + ))}
- {portfolio.cashBalances.map((balance) => ( - - {balance.currency} - {formatMoney(balance)} - - ))} +
- - + +
); } -function PortfolioRefreshState({ - refreshing, - refreshError, - retry, -}: { - refreshing: boolean; - refreshError: boolean; - retry?: () => void; -}) { - const { t } = useTranslation(); - if (!refreshing && !refreshError) return null; - return ( - <> - {refreshing ? ( - - - - {t("portfolio.refreshing")} - - - ) : null} - {refreshError ? ( - - - {t("portfolio.refreshError")} - - - - ) : null} - - ); -} -function RecentOperations({ - accountId, - accessToken, -}: { - accountId: string | undefined; - accessToken: string | null; -}) { - const { t } = useTranslation(); - const operations = useBrokerageOperations(accountId, accessToken, { - limit: 5, - }); - return ( -
- {operations.isLoading ? ( - {t("ledger.recentLoading")} - ) : null} - {operations.isError ? ( - {t("ledger.recentError")} - ) : null} - {operations.data?.items.length === 0 ? ( - {t("ledger.recentEmpty")} - ) : null} - {operations.data?.items.map((operation) => ( - - {operation.description || operation.type} - - {operation.payment - ? formatMoney(operation.payment) - : t("ledger.noMoney")} - - - ))} - -
- ); -} -function RecentEvents({ - accountId, - accessToken, -}: { - accountId: string | undefined; - accessToken: string | null; -}) { - const { t } = useTranslation(); - const events = useBrokerageEvents(accountId, accessToken); - return ( -
- {events.isLoading ? ( - {t("events.recentLoading")} - ) : null} - {events.isError ? ( - {t("events.recentError")} - ) : null} - {events.data?.events.length === 0 ? ( - {t("events.recentEmpty")} - ) : null} - {events.data?.events.slice(0, 5).map((event) => ( - - ))} - -
- ); -} -function RecentEvent({ event }: { event: BrokerageEvent }) { - const { t } = useTranslation(); - return ( - - {`${t(`events.type.${event.type}`)} · ${event.displayName}`} - - {event.amount && event.currency - ? formatMoney({ currency: event.currency, value: event.amount }) - : t("events.noAmount")} - - - ); -} -function PositionSection({ - title, - positions, - denominators, - wide = false, -}: { - title: string; - positions: Portfolio["positions"]; - denominators: Map; - wide?: boolean; -}) { - return ( -
- - {positions.map((position) => ( - - ))} - -
- ); -} -function PositionCard({ - position, - denominators, - wide = false, -}: { - position: Portfolio["positions"][number]; - denominators: Map; - wide?: boolean; -}) { - const { t } = useTranslation(); - const share = position.currentValue?.currency - ? formatShare(position.currentValue, { - currency: position.currentValue.currency, - value: String(denominators.get(position.currentValue.currency) ?? 0), - }) - : null; - const returnClass = position.absoluteReturn - ? Number(position.absoluteReturn.value) > 0 - ? classes.positive - : Number(position.absoluteReturn.value) < 0 - ? classes.negative - : undefined - : undefined; - const returnText = position.absoluteReturn - ? wide - ? `${Number(position.absoluteReturn.value) > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}` - : t("portfolio.return", { - value: formatMoney(position.absoluteReturn), - }) - : null; - const logoSrc = wide ? instrumentLogoUrl(position.logoName) : null; - return ( - - - {wide ? ( - - ) : null} - - - {position.displayName} - - {position.isin ? ( - - {position.isin} - - ) : null} - - {t("portfolio.quantity", { value: position.quantity })} - - - - {position.currentValue ? ( - - {formatMoney(position.currentValue)} - - ) : null} - {returnText ? ( - - {returnText} - - ) : null} - - - {position.averagePurchasePrice || share ? ( - - {position.averagePurchasePrice ? ( - - {t("portfolio.avgPrice", { - value: formatMoney(position.averagePurchasePrice), - })} - - ) : null} - {share ? ( - - {t("portfolio.share", { value: share })} - - ) : null} - - ) : null} - - ); -} -function PositionLogo({ src, label }: { src: string | null; label: string }) { - const [failed, setFailed] = useState(false); - if (src && !failed) - return ( - setFailed(true)} - /> - ); - return ( - - {(label.trim()[0] ?? "?").toUpperCase()} - - ); -} -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} - - ); + +function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) { + if (kind === "stocks") return "portfolio.stocks"; + if (kind === "bonds") return "portfolio.bonds"; + return "portfolio.otherPositions"; } diff --git a/src/pages/account/ui/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage.vm.test.tsx new file mode 100644 index 0000000..9693c76 --- /dev/null +++ b/src/pages/account/ui/AccountPage.vm.test.tsx @@ -0,0 +1,232 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import type { ReactNode } from "react"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { describe, expect, it, vi } from "vitest"; +import type { Portfolio } from "@/entities/portfolio"; +import { server } from "@/test/server"; +import { useAccountPageViewModel } from "./AccountPage.vm"; + +vi.mock("@/features/auth", () => ({ + useAuth: () => ({ accessToken: "test-token" }), +})); + +const portfolio: Portfolio = { + lastSuccessfulAt: "2026-08-13T10:00:00Z", + isStale: true, + totalsByCurrency: [{ currency: "RUB", value: "150" }], + positions: [ + { + figi: "FIGI-STOCK", + displayName: "Акция", + type: "STOCK", + quantity: "2", + currentValue: { currency: "RUB", value: "100" }, + averagePurchasePrice: null, + absoluteReturn: { currency: "RUB", value: "10" }, + isin: null, + logoName: "akciya.png", + }, + { + figi: "FIGI-BOND", + displayName: "Облигация", + type: "BOND", + quantity: "1", + currentValue: { currency: "RUB", value: "50" }, + averagePurchasePrice: { currency: "RUB", value: "45" }, + absoluteReturn: { currency: "RUB", value: "-5" }, + isin: "RU000A0JX0J2", + logoName: null, + }, + { + figi: "FIGI-FUND", + displayName: "Фонд", + type: "FUND", + quantity: "3", + currentValue: null, + averagePurchasePrice: null, + absoluteReturn: null, + isin: null, + logoName: "fond.png", + }, + ], + cashBalances: [{ currency: "USD", value: "1" }], +}; + +function wrapper({ children }: { children: ReactNode }) { + return ( + + + + + + + + + + ); +} + +function mockLoadedAccountPage() { + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json(portfolio), + ), + http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => + HttpResponse.json({ + accountId: "account-1", + items: [ + { + id: "operation-1", + occurredAt: "2026-08-13T10:00:00Z", + type: "Buy", + category: "trade", + description: "Покупка акции", + figi: "FIGI-STOCK", + instrumentUid: null, + ticker: "STK", + instrumentType: "share", + payment: { currency: "RUB", value: "-100" }, + quantity: "1", + price: null, + accruedInterest: null, + commission: null, + }, + ], + nextCursor: null, + hasNext: false, + observedAt: "2026-08-13T10:00:00Z", + }), + ), + http.get("*/api/v1/brokerage-accounts/:accountId/events", () => + HttpResponse.json({ + accountId: "account-1", + calculatedAt: "2026-08-13T10:00:00Z", + events: Array.from({ length: 6 }, (_, index) => ({ + type: "coupon", + date: `2026-09-${String(index + 1).padStart(2, "0")}`, + dateKind: "event", + figi: `FIGI-EVENT-${index}`, + instrumentUid: null, + ticker: `BND${index}`, + displayName: `Купон ${index}`, + currency: "RUB", + amount: String(index + 1), + })), + }), + ), + ); +} + +describe("useAccountPageViewModel", () => { + it("prepares grouped position sections with derived display data", async () => { + mockLoadedAccountPage(); + + const { result } = renderHook(() => useAccountPageViewModel(), { wrapper }); + + await waitFor(() => expect(result.current.state).toBe("loaded")); + const loaded = result.current as Extract< + typeof result.current, + { state: "loaded" } + >; + expect(loaded.positionSections).toMatchObject([ + { + kind: "stocks", + wide: true, + positions: [ + { + displayName: "Акция", + shareText: "66,7 %", + returnText: "+10 ₽", + returnTone: "positive", + logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", + }, + ], + }, + { + kind: "bonds", + wide: true, + positions: [ + { + displayName: "Облигация", + shareText: "33,3 %", + returnText: "-5 ₽", + returnTone: "negative", + logoSrc: null, + }, + ], + }, + { + kind: "other", + wide: false, + positions: [ + { + displayName: "Фонд", + shareText: null, + returnText: null, + returnTone: undefined, + logoSrc: null, + }, + ], + }, + ]); + }); + + it("prepares recent operations and events state for presentation views", async () => { + mockLoadedAccountPage(); + + const { result } = renderHook(() => useAccountPageViewModel(), { wrapper }); + + await waitFor(() => + expect( + (result.current as { recentEvents?: { state: string } }).recentEvents, + )?.toMatchObject({ state: "loaded" }), + ); + const loaded = result.current as Extract< + typeof result.current, + { state: "loaded" } + >; + expect(loaded.recentOperations).toMatchObject({ + state: "loaded", + to: "/accounts/account-1/operations", + items: [{ id: "operation-1", description: "Покупка акции" }], + }); + expect(loaded.recentEvents).toMatchObject({ + state: "loaded", + to: "/accounts/account-1/events", + items: [ + { ticker: "BND0" }, + { ticker: "BND1" }, + { ticker: "BND2" }, + { ticker: "BND3" }, + { ticker: "BND4" }, + ], + }); + }); + + it("keeps recent errors isolated in the ViewModel presentation state", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json(portfolio), + ), + http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => + HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), + ), + http.get("*/api/v1/brokerage-accounts/:accountId/events", () => + HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }), + ), + ); + + const { result } = renderHook(() => useAccountPageViewModel(), { wrapper }); + + await waitFor(() => + expect(result.current).toMatchObject({ + state: "loaded", + recentOperations: { state: "error" }, + recentEvents: { state: "error" }, + }), + ); + }); +}); diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts index 88703fd..fff597d 100644 --- a/src/pages/account/ui/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage.vm.ts @@ -1,19 +1,102 @@ import { useQueryClient } from "@tanstack/react-query"; import { useParams } from "react-router"; +import type { BrokerageOperation } from "@/entities/broker-operation"; +import { useBrokerageOperations } from "@/entities/broker-operation"; import { type BrokerageAccount, brokerageAccountsQueryKey, } from "@/entities/brokerage-account"; -import { portfolioErrorCode, usePortfolio } from "@/entities/portfolio"; +import { + type BrokerageEvent, + useBrokerageEvents, +} from "@/entities/brokerage-event"; +import type { Portfolio } from "@/entities/portfolio"; +import { + formatMoney, + formatShare, + instrumentLogoUrl, + portfolioErrorCode, + usePortfolio, +} from "@/entities/portfolio"; import { useAuth } from "@/features/auth"; -export function useAccountPageViewModel() { +type PositionSectionKind = "stocks" | "bonds" | "other"; +type AccountPageReturnTone = "positive" | "negative"; + +export type AccountPagePosition = Portfolio["positions"][number] & { + shareText: string | null; + returnText: string | null; + returnTone?: AccountPageReturnTone; + logoSrc: string | null; +}; + +export type AccountPagePositionSection = { + kind: PositionSectionKind; + wide: boolean; + positions: AccountPagePosition[]; +}; + +export type AccountPageRecentOperations = + | { + state: "loading" | "error" | "empty"; + to: string; + } + | { + state: "loaded"; + to: string; + items: BrokerageOperation[]; + }; + +export type AccountPageRecentEvents = + | { + state: "loading" | "error" | "empty"; + to: string; + } + | { + state: "loaded"; + to: string; + items: BrokerageEvent[]; + }; + +type AccountPageContent = { + account?: BrokerageAccount; + accountId?: string; + refreshing: boolean; + refreshError: boolean; + retry: () => void; + recentOperations: AccountPageRecentOperations; + recentEvents: AccountPageRecentEvents; +}; + +type AccountPageLoadedViewModel = AccountPageContent & { + state: "loaded"; + portfolio: Portfolio; + positionSections: AccountPagePositionSection[]; +}; + +type AccountPageEmptyViewModel = AccountPageContent & { + state: "empty"; +}; + +export type AccountPageViewModel = + | { state: "loading" } + | { state: "error"; code?: string; retry: () => void } + | AccountPageEmptyViewModel + | AccountPageLoadedViewModel; + +export function useAccountPageViewModel(): AccountPageViewModel { const { accountId } = useParams(); const { accessToken } = useAuth(); const portfolio = usePortfolio(accountId, accessToken); + const operations = useBrokerageOperations(accountId, accessToken, { + limit: 5, + }); + const events = useBrokerageEvents(accountId, accessToken); const account = useQueryClient() .getQueryData(brokerageAccountsQueryKey) ?.find((item) => item.id === accountId); + const recentOperations = toRecentOperations(accountId, operations); + const recentEvents = toRecentEvents(accountId, events); if (!portfolio.data) { if (portfolio.isError) return { @@ -33,15 +116,110 @@ export function useAccountPageViewModel() { state: "empty" as const, account, accountId, - accessToken, + recentOperations, + recentEvents, ...refresh, }; return { state: "loaded" as const, account, accountId, - accessToken, portfolio: portfolio.data, + positionSections: toAccountPagePositionSections(portfolio.data), + recentOperations, + recentEvents, ...refresh, }; } + +function toRecentOperations( + accountId: string | undefined, + operations: ReturnType, +): AccountPageRecentOperations { + const to = `/accounts/${accountId}/operations`; + if (operations.isLoading) return { state: "loading", to }; + if (operations.isError) return { state: "error", to }; + if (!operations.data?.items.length) return { state: "empty", to }; + return { state: "loaded", to, items: operations.data.items }; +} + +function toRecentEvents( + accountId: string | undefined, + events: ReturnType, +): AccountPageRecentEvents { + const to = `/accounts/${accountId}/events`; + if (events.isLoading) return { state: "loading", to }; + if (events.isError) return { state: "error", to }; + const items = events.data?.events.slice(0, 5) ?? []; + if (!items.length) return { state: "empty", to }; + return { state: "loaded", to, items }; +} + +export function toAccountPagePositionSections( + portfolio: Portfolio, +): AccountPagePositionSection[] { + const denominators = new Map(); + for (const position of portfolio.positions) { + if (!position.currentValue) continue; + const currency = position.currentValue.currency; + denominators.set( + currency, + (denominators.get(currency) ?? 0) + Number(position.currentValue.value), + ); + } + const stocks = toSection("stocks", true, portfolio, denominators); + const bonds = toSection("bonds", true, portfolio, denominators); + const other = toSection("other", false, portfolio, denominators); + return [stocks, bonds, other].filter( + (section) => section.positions.length > 0, + ); +} + +function toSection( + kind: PositionSectionKind, + wide: boolean, + portfolio: Portfolio, + denominators: Map, +): AccountPagePositionSection { + return { + kind, + wide, + positions: portfolio.positions + .filter((position) => { + if (kind === "stocks") return position.type === "STOCK"; + if (kind === "bonds") return position.type === "BOND"; + return position.type !== "STOCK" && position.type !== "BOND"; + }) + .map((position) => toPosition(position, denominators, wide)), + }; +} + +function toPosition( + position: Portfolio["positions"][number], + denominators: Map, + wide: boolean, +): AccountPagePosition { + const returnValue = position.absoluteReturn + ? Number(position.absoluteReturn.value) + : null; + const returnTone = + returnValue === null || returnValue === 0 + ? undefined + : returnValue > 0 + ? "positive" + : "negative"; + return { + ...position, + shareText: position.currentValue?.currency + ? formatShare(position.currentValue, { + currency: position.currentValue.currency, + value: String(denominators.get(position.currentValue.currency) ?? 0), + }) + : null, + returnText: position.absoluteReturn + ? `${wide && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}` + : null, + returnTone, + logoSrc: wide ? instrumentLogoUrl(position.logoName) : null, + }; +} diff --git a/src/pages/account/ui/components/AccountHeading.view.tsx b/src/pages/account/ui/components/AccountHeading.view.tsx new file mode 100644 index 0000000..019ec1c --- /dev/null +++ b/src/pages/account/ui/components/AccountHeading.view.tsx @@ -0,0 +1,18 @@ +import { Stack, Text, Title } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import type { BrokerageAccount } from "@/entities/brokerage-account"; +import { accountTypeKey } from "@/entities/brokerage-account"; + +export function AccountHeading({ account }: { account?: BrokerageAccount }) { + const { t } = useTranslation(); + return ( + + {account?.name ?? t("portfolio.title")} + {account ? ( + + {t(accountTypeKey(account.type))} + + ) : null} + + ); +} diff --git a/src/pages/account/ui/components/CashBalances.view.tsx b/src/pages/account/ui/components/CashBalances.view.tsx new file mode 100644 index 0000000..9c8ad44 --- /dev/null +++ b/src/pages/account/ui/components/CashBalances.view.tsx @@ -0,0 +1,21 @@ +import { Paper, Text } from "@mantine/core"; +import type { Portfolio } from "@/entities/portfolio"; +import { formatMoney } from "@/entities/portfolio"; +import classes from "../AccountPage.module.css"; + +export function CashBalances({ + balances, +}: { + balances: Portfolio["cashBalances"]; +}) { + return ( + <> + {balances.map((balance) => ( + + {balance.currency} + {formatMoney(balance)} + + ))} + + ); +} diff --git a/src/pages/account/ui/components/PortfolioRefreshState.view.tsx b/src/pages/account/ui/components/PortfolioRefreshState.view.tsx new file mode 100644 index 0000000..4649690 --- /dev/null +++ b/src/pages/account/ui/components/PortfolioRefreshState.view.tsx @@ -0,0 +1,37 @@ +import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +export function PortfolioRefreshState({ + refreshing, + refreshError, + retry, +}: { + refreshing: boolean; + refreshError: boolean; + retry?: () => void; +}) { + const { t } = useTranslation(); + if (!refreshing && !refreshError) return null; + return ( + <> + {refreshing ? ( + + + + {t("portfolio.refreshing")} + + + ) : null} + {refreshError ? ( + + + {t("portfolio.refreshError")} + + + + ) : null} + + ); +} diff --git a/src/pages/account/ui/components/PortfolioTotals.view.tsx b/src/pages/account/ui/components/PortfolioTotals.view.tsx new file mode 100644 index 0000000..76a71a3 --- /dev/null +++ b/src/pages/account/ui/components/PortfolioTotals.view.tsx @@ -0,0 +1,25 @@ +import { Paper, SimpleGrid, Text } from "@mantine/core"; +import type { Portfolio } from "@/entities/portfolio"; +import { formatMoney } from "@/entities/portfolio"; +import classes from "../AccountPage.module.css"; + +export function PortfolioTotals({ + totals, +}: { + totals: Portfolio["totalsByCurrency"]; +}) { + return ( + + {totals.map((total) => ( + + + {total.currency} + + + {formatMoney(total)} + + + ))} + + ); +} diff --git a/src/pages/account/ui/components/PositionCard.view.tsx b/src/pages/account/ui/components/PositionCard.view.tsx new file mode 100644 index 0000000..a1427a2 --- /dev/null +++ b/src/pages/account/ui/components/PositionCard.view.tsx @@ -0,0 +1,81 @@ +import { Card, Group, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { formatMoney } from "@/entities/portfolio"; +import classes from "../AccountPage.module.css"; +import type { AccountPagePosition } from "../AccountPage.vm"; +import { PositionLogo } from "./PositionLogo.view"; + +export function PositionCard({ + position, + wide = false, +}: { + position: AccountPagePosition; + wide?: boolean; +}) { + const { t } = useTranslation(); + const returnClass = position.returnTone + ? classes[position.returnTone] + : undefined; + const returnText = + position.returnText && wide + ? position.returnText + : position.returnText + ? t("portfolio.return", { + value: position.returnText, + }) + : null; + return ( + + + {wide ? ( + + ) : null} + + + {position.displayName} + + {position.isin ? ( + + {position.isin} + + ) : null} + + {t("portfolio.quantity", { value: position.quantity })} + + + + {position.currentValue ? ( + + {formatMoney(position.currentValue)} + + ) : null} + {returnText ? ( + + {returnText} + + ) : null} + + + {position.averagePurchasePrice || position.shareText ? ( + + {position.averagePurchasePrice ? ( + + {t("portfolio.avgPrice", { + value: formatMoney(position.averagePurchasePrice), + })} + + ) : null} + {position.shareText ? ( + + {t("portfolio.share", { value: position.shareText })} + + ) : null} + + ) : null} + + ); +} diff --git a/src/pages/account/ui/components/PositionLogo.view.tsx b/src/pages/account/ui/components/PositionLogo.view.tsx new file mode 100644 index 0000000..623f4af --- /dev/null +++ b/src/pages/account/ui/components/PositionLogo.view.tsx @@ -0,0 +1,27 @@ +import { Avatar } from "@mantine/core"; +import { useState } from "react"; + +export function PositionLogo({ + src, + label, +}: { + src: string | null; + label: string; +}) { + const [failed, setFailed] = useState(false); + if (src && !failed) + return ( + setFailed(true)} + /> + ); + return ( + + {(label.trim()[0] ?? "?").toUpperCase()} + + ); +} diff --git a/src/pages/account/ui/components/PositionSection.view.tsx b/src/pages/account/ui/components/PositionSection.view.tsx new file mode 100644 index 0000000..5c352f0 --- /dev/null +++ b/src/pages/account/ui/components/PositionSection.view.tsx @@ -0,0 +1,26 @@ +import { SimpleGrid } from "@mantine/core"; +import type { AccountPagePositionSection } from "../AccountPage.vm"; +import { PositionCard } from "./PositionCard.view"; +import { Section } from "./Section.view"; + +export function PositionSection({ + title, + section, +}: { + title: string; + section: AccountPagePositionSection; +}) { + return ( +
+ + {section.positions.map((position) => ( + + ))} + +
+ ); +} diff --git a/src/pages/account/ui/components/RecentEvents.view.tsx b/src/pages/account/ui/components/RecentEvents.view.tsx new file mode 100644 index 0000000..2e84b22 --- /dev/null +++ b/src/pages/account/ui/components/RecentEvents.view.tsx @@ -0,0 +1,50 @@ +import { Alert, Button, Paper, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import type { BrokerageEvent } from "@/entities/brokerage-event"; +import { formatMoney } from "@/entities/portfolio"; +import classes from "../AccountPage.module.css"; +import type { AccountPageRecentEvents } from "../AccountPage.vm"; +import { Section } from "./Section.view"; + +export function RecentEvents({ events }: { events: AccountPageRecentEvents }) { + const { t } = useTranslation(); + return ( +
+ {events.state === "loading" ? ( + {t("events.recentLoading")} + ) : null} + {events.state === "error" ? ( + {t("events.recentError")} + ) : null} + {events.state === "empty" ? ( + {t("events.recentEmpty")} + ) : null} + {events.state === "loaded" + ? events.items.map((event) => ( + + )) + : null} + +
+ ); +} + +function RecentEvent({ event }: { event: BrokerageEvent }) { + const { t } = useTranslation(); + return ( + + {`${t(`events.type.${event.type}`)} · ${event.displayName}`} + + {event.amount && event.currency + ? formatMoney({ currency: event.currency, value: event.amount }) + : t("events.noAmount")} + + + ); +} diff --git a/src/pages/account/ui/components/RecentOperations.view.tsx b/src/pages/account/ui/components/RecentOperations.view.tsx new file mode 100644 index 0000000..9219a85 --- /dev/null +++ b/src/pages/account/ui/components/RecentOperations.view.tsx @@ -0,0 +1,43 @@ +import { Alert, Button, Paper, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { formatMoney } from "@/entities/portfolio"; +import classes from "../AccountPage.module.css"; +import type { AccountPageRecentOperations } from "../AccountPage.vm"; +import { Section } from "./Section.view"; + +export function RecentOperations({ + operations, +}: { + operations: AccountPageRecentOperations; +}) { + const { t } = useTranslation(); + return ( +
+ {operations.state === "loading" ? ( + {t("ledger.recentLoading")} + ) : null} + {operations.state === "error" ? ( + {t("ledger.recentError")} + ) : null} + {operations.state === "empty" ? ( + {t("ledger.recentEmpty")} + ) : null} + {operations.state === "loaded" + ? operations.items.map((operation) => ( + + {operation.description || operation.type} + + {operation.payment + ? formatMoney(operation.payment) + : t("ledger.noMoney")} + + + )) + : null} + +
+ ); +} diff --git a/src/pages/account/ui/components/Section.view.tsx b/src/pages/account/ui/components/Section.view.tsx new file mode 100644 index 0000000..5b0e4a2 --- /dev/null +++ b/src/pages/account/ui/components/Section.view.tsx @@ -0,0 +1,16 @@ +import { Stack, Title } from "@mantine/core"; + +export function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + {title} + {children} + + ); +} diff --git a/src/pages/account/ui/components/index.ts b/src/pages/account/ui/components/index.ts new file mode 100644 index 0000000..8386eb4 --- /dev/null +++ b/src/pages/account/ui/components/index.ts @@ -0,0 +1,8 @@ +export { AccountHeading } from "./AccountHeading.view"; +export { CashBalances } from "./CashBalances.view"; +export { PortfolioRefreshState } from "./PortfolioRefreshState.view"; +export { PortfolioTotals } from "./PortfolioTotals.view"; +export { PositionSection } from "./PositionSection.view"; +export { RecentEvents } from "./RecentEvents.view"; +export { RecentOperations } from "./RecentOperations.view"; +export { Section } from "./Section.view";