diff --git a/src/entities/brokerage-account/model/display.ts b/src/entities/brokerage-account/model/display.ts index e624699..08cb2d2 100644 --- a/src/entities/brokerage-account/model/display.ts +++ b/src/entities/brokerage-account/model/display.ts @@ -8,11 +8,15 @@ const VISIBLE_CURRENCY_LIMIT = 2; type BrokerageAccountCard = { account: BrokerageAccount; - positionCounts: null - | { bonds: number; stocks: number }; + positionCounts: null | PositionCounts; source?: NonNullable[number]; summary: - | { remainingCurrencyCount: number; state: "available"; totals: Money[] } + | { + portfolio: NonNullable; + remainingCurrencyCount: number; + state: "available"; + totals: Money[]; + } | { state: "empty" } | { state: "unavailable" }; }; @@ -21,6 +25,8 @@ type Money = NonNullable< BrokerageAccount["portfolioSummary"] >["totalsByCurrency"][number]; +type PositionCounts = { bonds: number; stocks: number; total: number }; + function accountTypeKey(type: BrokerageAccount["type"]) { return `accounts.types.${type}` as const; } @@ -48,6 +54,7 @@ function brokerageAccountCards( const positionCounts = { bonds: summary.bondPositionCount, stocks: summary.stockPositionCount, + total: summary.totalPositionCount, }; if (summary.totalsByCurrency.length === 0) @@ -58,6 +65,7 @@ function brokerageAccountCards( positionCounts, source, summary: { + portfolio: summary, remainingCurrencyCount: Math.max( summary.totalsByCurrency.length - VISIBLE_CURRENCY_LIMIT, 0, diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css index fdb9a31..99cc82b 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css @@ -1,7 +1,9 @@ .list { + display: grid; width: 100%; margin: 0; padding: 0; + gap: var(--mantine-spacing-sm); list-style: none; } @@ -9,15 +11,16 @@ display: grid; grid-template-areas: "identity balance" - "details balance"; + "details balance" + "structure structure"; grid-template-columns: minmax(0, 1fr) minmax(0, auto); - gap: var(--mantine-spacing-xs) var(--mantine-spacing-xl); + gap: var(--mantine-spacing-sm) var(--mantine-spacing-xl); align-items: center; - padding: var(--mantine-spacing-md); + padding: var(--mantine-spacing-lg); border: 1px solid var(--wl-color-border); - border-left: 3px solid var(--mantine-color-blue-5); + border-left: 3px solid var(--wl-color-primary); border-radius: var(--mantine-radius-md); - background: var(--mantine-color-white); + background: var(--wl-color-surface); color: inherit; text-decoration: none; transition: background-color 160ms ease, border-color 160ms ease; @@ -25,13 +28,12 @@ .row:hover, .row:focus-visible { - border-color: var(--mantine-color-blue-3); - border-left-color: var(--mantine-color-blue-6); - background: var(--mantine-color-blue-0); + border-color: var(--wl-color-primary); + background: var(--wl-color-subtle); } .row:focus-visible { - outline: 2px solid var(--mantine-color-blue-6); + outline: 2px solid var(--wl-color-primary); outline-offset: -2px; } @@ -42,74 +44,101 @@ .name { margin: 0; - color: var(--mantine-color-blue-9); + color: var(--wl-color-text); font-size: var(--mantine-font-size-md); line-height: var(--mantine-line-height-md); overflow-wrap: anywhere; } .accountType { + flex-shrink: 0; align-self: flex-start; padding: 2px var(--mantine-spacing-xs); border-radius: var(--mantine-radius-sm); - background: var(--mantine-color-blue-0); - color: var(--mantine-color-blue-8); } .balance { grid-area: balance; min-width: 0; - padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); - border-radius: var(--mantine-radius-sm); - background: var(--wl-color-subtle); text-align: right; } .summary { - font-variant-numeric: tabular-nums; + font-variant-numeric: tabular-nums lining-nums; + white-space: nowrap; } .total { - color: var(--mantine-color-dark-7); + color: var(--wl-color-text); font-size: var(--mantine-font-size-lg); - line-height: var(--mantine-line-height-md); - overflow-wrap: anywhere; + line-height: var(--mantine-line-height-lg); } -.total + .total { - font-weight: 500; +.return, +.cashValue { + font-variant-numeric: tabular-nums lining-nums; + white-space: nowrap; +} + +.positive { + color: var(--wl-color-positive); +} + +.negative { + color: var(--wl-color-negative); +} + +.neutral { + color: var(--wl-color-muted); +} + +.cash { + font-variant-numeric: tabular-nums lining-nums; } .details { grid-area: details; min-width: 0; + font-variant-numeric: tabular-nums lining-nums; } -.positions { - margin: 0; -} - -.positions > div { - display: flex; +.structure { + grid-area: structure; gap: var(--mantine-spacing-xs); - align-items: baseline; } -.positions dd { - margin: 0; +.structureBar { + display: flex; + width: 100%; + height: 6px; + overflow: hidden; + border-radius: var(--mantine-radius-xs); + background: var(--wl-color-subtle); } -.status { - min-width: 0; - overflow-wrap: anywhere; +.structureSection { + flex: none; + height: 100%; + background: var(--category-color); } -@media (max-width: 48em) { +@media (max-width: 47.9375em) { .row { - column-gap: var(--mantine-spacing-sm); + grid-template-areas: + "identity" + "balance" + "structure" + "details"; + grid-template-columns: minmax(0, 1fr); + gap: var(--mantine-spacing-sm); + padding: var(--mantine-spacing-md); } .balance { - max-width: 50vw; + text-align: left; + } + + .balance > div { + justify-content: flex-start; } } diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx index 0789e22..a7fc647 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx @@ -2,32 +2,86 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { MemoryRouter } from "react-router"; +import { resource } from "@/test/resource"; + import type { BrokerageAccount } from "../../api/useBrokerageAccounts"; import { brokerageAccountCards } from "../../model/display"; import { BrokerageAccountList } from "./BrokerageAccountList"; -const syntheticAccount = { - id: "storybook-account", - name: "Учебный брокерский счёт", - openedOn: "2025-03-15", - portfolioSummary: { - bondPositionCount: 12, - stockPositionCount: 5, - totalsByCurrency: [ - { currency: "RUB", value: "125000.50" }, - { currency: "USD", value: "320" }, - { currency: "EUR", value: "210" }, - ], +type PortfolioSummary = NonNullable; + +const rubReturn = { + absolute: "136900", + costBasis: "1103600.50", + currency: "RUB", + partial: false, + percent: "12.40", +}; + +const portfolioSummary = { + bondPositionCount: 12, + cashBalances: [ + { currency: "RUB", value: "82500.50" }, + { currency: "USD", value: "1500" }, + ], + fundPositionCount: 2, + stockPositionCount: 5, + structure: { + currencies: [{ + categories: [ + { sharePercent: "55.000000", type: "STOCK", value: "682500" }, + { sharePercent: "25.000000", type: "BOND", value: "310500" }, + { sharePercent: "10.000000", type: "OTHER", value: "124200" }, + { sharePercent: "10.000000", type: "CASH", value: "124200" }, + ], + currency: "RUB", + hasNegativeValues: false, + totalValue: "1241400", + }], + unvaluedCategories: [], }, - type: "BROKERAGE", -} satisfies BrokerageAccount; + totalPositionCount: 19, + totalsByCurrency: [{ currency: "RUB", value: "1240500.50" }], + unrealizedReturnByCurrency: [rubReturn], +} satisfies PortfolioSummary; + +function account( + id: string, + name: string, + summary: null | PortfolioSummary = portfolioSummary, + type: BrokerageAccount["type"] = "BROKERAGE", +): BrokerageAccount { + return { + id, + name, + openedOn: "2025-03-15", + portfolioSummary: summary, + type, + }; +} + +const negativeSummary: PortfolioSummary = { + ...portfolioSummary, + unrealizedReturnByCurrency: [{ + ...rubReturn, + absolute: "-4200", + percent: "-3.05", + }], +}; + +const emptySummary: PortfolioSummary = { + ...portfolioSummary, + cashBalances: [], + structure: { currencies: [], unvaluedCategories: [] }, + totalPositionCount: 0, + totalsByCurrency: [], + unrealizedReturnByCurrency: [], +}; const meta = { - args: { accounts: brokerageAccountCards([syntheticAccount]) }, - argTypes: { - accounts: { control: false }, - }, + args: { accounts: brokerageAccountCards([account("normal", "Основной ИИС")]) }, + argTypes: { accounts: { control: false } }, component: BrokerageAccountList, decorators: [ Story => ( @@ -40,7 +94,7 @@ const meta = { docs: { description: { component: - "Компактные строки счетов WealthLedger: название и отличающийся тип слева, текущая стоимость с двумя знаками справа на desktop и под названием на mobile. Видимы не более двух валют и счётчик остальных, позиции и локальный статус источника. Отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.", + "Карточки счетов: стоимость и доходность по валютам, деньги, структура и свежесть источника. Денежные и процентные значения остаются строками. Все примеры содержат вымышленные данные; ссылки работают только внутри MemoryRouter.", }, }, }, @@ -52,188 +106,112 @@ export default meta; type Story = StoryObj; -export const Available: Story = { - name: "Сводка по нескольким валютам", - parameters: { - docs: { - description: { - story: - "Показываются первые две валюты и количество остальных. Суммы разных валют не складываются в общий итог.", - }, - }, +export const Normal: Story = {}; + +export const MultiCurrency: Story = { + args: { + accounts: brokerageAccountCards([account("multi", "Мультивалютный", { + ...portfolioSummary, + cashBalances: [ + { currency: "RUB", value: "82500.50" }, + { currency: "USD", value: "1500" }, + ], + totalsByCurrency: [ + { currency: "RUB", value: "1240500.50" }, + { currency: "USD", value: "3200" }, + { currency: "EUR", value: "900" }, + ], + unrealizedReturnByCurrency: [ + ...portfolioSummary.unrealizedReturnByCurrency, + { + absolute: "20", + costBasis: "3180", + currency: "USD", + partial: false, + percent: "2.10", + }, + { + absolute: "0", + costBasis: "900", + currency: "EUR", + partial: false, + percent: null, + }, + ], + })]), }, }; -export const Unavailable: Story - = { - args: { - accounts: brokerageAccountCards([ - { ...syntheticAccount, openedOn: null, portfolioSummary: null }, - ]), - }, - name: "Сводка недоступна", - parameters: { - docs: { - description: { - story: - "Счёт остаётся доступен для открытия, даже если сводка временно недоступна. Неизвестные суммы не заменяются нулём; отсутствующая дата открытия не отображается.", - }, - }, - }, - }; +export const Negative: Story = { + args: { + accounts: brokerageAccountCards([ + account("negative", "Просевший портфель", negativeSummary), + ]), + }, +}; + +export const Partial: Story = { + args: { + accounts: brokerageAccountCards([account("partial", "Частичные данные", { + ...portfolioSummary, + unrealizedReturnByCurrency: [{ + ...rubReturn, + partial: true, + }], + })]), + }, +}; export const Empty: Story = { args: { accounts: brokerageAccountCards([ - { - ...syntheticAccount, - portfolioSummary: { - bondPositionCount: 0, - stockPositionCount: 0, - totalsByCurrency: [], - }, - }, + account("empty", "Новый пустой счёт", emptySummary), ]), }, - name: "Нет текущих сумм", - parameters: { - docs: { - description: { - story: - "Успешно полученная пустая сводка отличается от ошибки получения данных. Карточка сохраняет название, тип счёта и переход.", - }, - }, +}; + +export const Unavailable: Story = { + args: { + accounts: brokerageAccountCards([ + account("unavailable", "Счёт без сводки", null), + ]), + }, +}; + +export const Stale: Story = { + args: { + accounts: brokerageAccountCards([ + account("stale", "Устаревший портфель"), + ], resource([], { + dependencies: [{ + availability: "available", + completeness: "complete", + dataRevision: "stale-revision", + freshness: "stale", + lastAttempt: { + finishedAt: null, + problems: [], + startedAt: null, + state: "none", + }, + lastSuccessfulAt: "2026-08-13T00:00:00Z", + resourceKey: "portfolio/stale", + }], + freshness: "stale", + lastSuccessfulAt: "2026-08-14T00:00:00Z", + }).meta.dependencies), }, }; export const LongName: Story = { args: { accounts: brokerageAccountCards([ - { - ...syntheticAccount, - name: "Учебный индивидуальный инвестиционный счёт для долгосрочных накоплений", - type: "INDIVIDUAL_INVESTMENT", - }, - ]), - }, - name: "Длинное название счёта", - parameters: { - docs: { - description: { - story: - "Проверьте перенос длинного названия на узком экране и доступность всей карточки с клавиатуры.", - }, - }, - }, -}; - -export const AccountGrid: Story = { - args: { - accounts: brokerageAccountCards([ - { - ...syntheticAccount, - name: "Основной", - portfolioSummary: { - bondPositionCount: 14, - stockPositionCount: 8, - totalsByCurrency: [{ currency: "RUB", value: "1256430.50" }], - }, - }, - { - ...syntheticAccount, - id: "storybook-iis", - name: "Долгосрочные инвестиции", - portfolioSummary: { - bondPositionCount: 6, - stockPositionCount: 0, - totalsByCurrency: [{ currency: "RUB", value: "480250" }], - }, - type: "INDIVIDUAL_INVESTMENT", - }, - ]), - }, - name: "Несколько счетов", -}; - -export const UnvaluedPositions: Story = { - args: { - accounts: brokerageAccountCards([ - { - ...syntheticAccount, - portfolioSummary: { - bondPositionCount: 1, - stockPositionCount: 2, - totalsByCurrency: [], - }, - }, - ]), - }, - name: "Бумаги без текущей оценки", -}; - -export const LargeValues: Story = { - args: { - accounts: brokerageAccountCards([ - { - ...syntheticAccount, - portfolioSummary: { - bondPositionCount: 105, - stockPositionCount: 21, - totalsByCurrency: [ - { currency: "RUB", value: "1234567890123456789.50" }, - { currency: "USD", value: "-9876543210987654321.50" }, - ], - }, - }, - ]), - }, - name: "Длинные суммы", -}; - -export const IndependentFreshness: Story - = { - args: { - accounts: brokerageAccountCards( - [ - syntheticAccount, - { - ...syntheticAccount, - id: "waiting", - name: "Новый счёт", - portfolioSummary: null, - }, - ], - [ - { - availability: "available", - completeness: "complete", - dataRevision: "1", - freshness: "stale", - lastAttempt: { - finishedAt: "2026-09-24T10:31:00Z", - problems: [], - startedAt: "2026-09-24T10:30:00Z", - state: "failed", - }, - lastSuccessfulAt: "2026-09-24T10:30:00Z", - resourceKey: "portfolio/storybook-account", - }, - { - availability: "initial-loading", - completeness: "incomplete", - dataRevision: "0", - freshness: "unknown", - lastAttempt: { - finishedAt: null, - problems: [], - startedAt: "2026-09-24T10:30:00Z", - state: "running", - }, - lastSuccessfulAt: null, - resourceKey: "portfolio/waiting", - }, - ], + account( + "long", + "Очень длинное название индивидуального инвестиционного счёта, которое должно корректно переноситься на узком экране", + portfolioSummary, + "INDIVIDUAL_INVESTMENT", ), - }, - name: "Независимая свежесть и ошибка сводок", - }; + ]), + }, +}; diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx index ec6cdbd..494f200 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx @@ -5,128 +5,202 @@ import { describe, expect, it } from "vitest"; import { resource } from "@/test/resource"; +import type { BrokerageAccount } from "../../api/useBrokerageAccounts"; + import { brokerageAccountCards } from "../../model/display"; import { BrokerageAccountList } from "./BrokerageAccountList"; -describe("BrokerageAccountList", () => { - it("renders account details and an account link", () => { - render( - - - ; - }, - { - id: "account-2", - name: "Брокерский счет", - openedOn: null, - portfolioSummary: null, - type: "BROKERAGE", - }, - { - id: "account-3", - name: "Пустой", - openedOn: null, - portfolioSummary: { - bondPositionCount: 0, - stockPositionCount: 2, - totalsByCurrency: [], - }, - type: "BROKERAGE", - }, - ])} - /> - - , - ); +const portfolioSummary = { + bondPositionCount: 12, + cashBalances: [ + { currency: "RUB", value: "82500.5" }, + { currency: "USD", value: "20" }, + ], + fundPositionCount: 0, + stockPositionCount: 5, + structure: { + currencies: [{ + categories: [ + { sharePercent: "50.000000", type: "STOCK", value: "100" }, + { sharePercent: "30.000000", type: "BOND", value: "60" }, + { sharePercent: "0.000000", type: "OTHER", value: "0" }, + { sharePercent: "20.000000", type: "CASH", value: "40" }, + ], + currency: "RUB", + hasNegativeValues: false, + totalValue: "200", + }], + unvaluedCategories: [], + }, + totalPositionCount: 17, + totalsByCurrency: [ + { currency: "RUB", value: "123456789012345678901.995" }, + { currency: "USD", value: "20" }, + { currency: "EUR", value: "5" }, + ], + unrealizedReturnByCurrency: [ + { + absolute: "136900", + costBasis: "1000000", + currency: "RUB", + partial: true, + percent: "12.40", + }, + { + absolute: "-4.2", + costBasis: "24.2", + currency: "USD", + partial: false, + percent: "-3.05", + }, + { + absolute: "0", + costBasis: "5", + currency: "EUR", + partial: false, + percent: null, + }, + ], +} satisfies PortfolioSummary; - expect(screen.getByRole("link", { name: /Основной/ })).toHaveAttribute( - "href", - "/accounts/account-1", - ); - expect(screen.getAllByText("Брокерский счет")).toHaveLength(3); - expect(screen.getByText("123 456 789 012 345 678 902,00 ₽")) - .toBeInTheDocument(); - expect(screen.getByText("20,00 USD")).toBeInTheDocument(); - expect(screen.getByText("Еще 1 валюта")).toBeInTheDocument(); - expect(screen.getByText("Сводка временно недоступна")).toBeInTheDocument(); - expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument(); +type BrokerageAccountCardInput = Parameters[0][ + "accounts" +]; - const available = within(screen.getByRole("link", { name: /Основной/ })); - expect(available.getAllByRole("definition")[0]).toHaveTextContent("5"); - expect(available.getAllByRole("definition")[1]).toHaveTextContent("12"); +function account( + id: string, + name: string, + summary: null | PortfolioSummary, +): BrokerageAccount { + return { + id, + name, + openedOn: null, + portfolioSummary: summary, + type: "BROKERAGE", + }; +} - const unvalued = within(screen.getByRole("link", { name: /Пустой/ })); - expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent("2"); - expect(unvalued.getAllByRole("definition")[1]).toHaveTextContent("0"); - - const duplicatedName = within(screen.getByRole("link", { - name: /Сводка временно недоступна/, - })); - - expect(duplicatedName.getAllByText("Брокерский счет")).toHaveLength(1); - expect(duplicatedName.queryByText("Акции")).not.toBeInTheDocument(); - }); -}); - -it("shows portfolio summary freshness independently for each account", () => { - const accounts = brokerageAccountCards( - ["fresh", "stale"].map( - - id => - ({ - id, - name: id, - openedOn: null, - portfolioSummary: { - bondPositionCount: 0, - stockPositionCount: 1, - totalsByCurrency: [{ currency: "RUB", value: "10" }], - }, - type: "BROKERAGE" as const, - }), - ), - [ - resource(null, { freshness: "fresh", resourceKey: "portfolio/fresh" }) - .meta, - resource(null, { freshness: "stale", resourceKey: "portfolio/stale" }) - .meta, - ], - ); - - render( +function renderList(accounts: BrokerageAccountCardInput) { + return render( , ); - expect( - within(screen.getByRole("link", { name: /stale/ })).getByText( - "Данные требуют обновления", - ), - ).toBeVisible(); - expect( - within(screen.getByRole("link", { name: /fresh/ })).queryByText( - "Данные требуют обновления", - ), - ).not.toBeInTheDocument(); +} + +describe("BrokerageAccountList", () => { + it("renders returns, cash and structure with precise totals", () => { + const nullReturnSummary: PortfolioSummary = { + ...portfolioSummary, + cashBalances: [], + structure: { currencies: [], unvaluedCategories: [] }, + totalsByCurrency: [{ currency: "EUR", value: "5" }], + unrealizedReturnByCurrency: [{ + absolute: "0", + costBasis: "5", + currency: "EUR", + partial: false, + percent: null, + }], + }; + + renderList(brokerageAccountCards([ + account("account-1", "Основной", portfolioSummary), + account("account-2", "Без доходности", nullReturnSummary), + ])); + + const link = screen.getByRole("link", { name: /Основной/ }); + expect(link).toHaveAttribute("href", "/accounts/account-1"); + expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽")) + .toBeInTheDocument(); + expect(within(link).getByText("Еще 1 валюта")).toBeInTheDocument(); + expect(within(link).getByText("+12,40 %")).toBeInTheDocument(); + expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument(); + expect(within(link).getByText("−3,05 %")).toBeInTheDocument(); + expect(within(link).getByText("−4,2 USD")).toBeInTheDocument(); + const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ }); + expect(within(nullReturnLink).getByText("—")).toBeInTheDocument(); + expect(within(link).getByText("частично")).toBeInTheDocument(); + expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument(); + expect(within(link).getAllByText("20,00 USD")).not.toHaveLength(0); + expect(within(link).getByText("17 позиций")).toBeInTheDocument(); + expect(screen.getByRole("img", { + name: "Структура в RUB: Акции 50 %; Облигации 30 %; " + + "Другие активы 0 %; Свободные деньги 20 %", + })).toBeInTheDocument(); + const stockLegend = within(link).getByText(/Акции 50 %/); + expect(stockLegend).toHaveTextContent("● Акции 50 %"); + // eslint-disable-next-line testing-library/no-node-access -- The legend is aria-hidden, so only DOM traversal reaches its container. + expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true"); + expect(within(link).queryByText(/Другие активы 0 %/)).not.toBeInTheDocument(); + expect(link).toHaveTextContent("По данным брокера"); + expect(link).toHaveTextContent("Структура RUB: Акции 100"); + }); + + it("preserves empty, unavailable and stale states", () => { + const emptySummary: PortfolioSummary = { + ...portfolioSummary, + cashBalances: [], + structure: { currencies: [], unvaluedCategories: [] }, + totalPositionCount: 0, + totalsByCurrency: [], + unrealizedReturnByCurrency: [], + }; + + const cards = brokerageAccountCards([ + account("empty", "Пустой", emptySummary), + account("unavailable", "Недоступный", null), + account("stale", "Устаревший", portfolioSummary), + account("failed", "Ошибка обновления", null), + ], resource([], { + dependencies: [{ + availability: "available", + completeness: "complete", + dataRevision: "stale-revision", + freshness: "stale", + lastAttempt: { + finishedAt: null, + problems: [], + startedAt: null, + state: "none", + }, + lastSuccessfulAt: "2026-08-13T00:00:00Z", + resourceKey: "portfolio/stale", + }, { + availability: "available", + completeness: "complete", + dataRevision: "failed-revision", + freshness: "stale", + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", + state: "failed", + }, + lastSuccessfulAt: "2026-08-13T00:00:00Z", + resourceKey: "portfolio/failed", + }], + freshness: "stale", + lastSuccessfulAt: "2026-08-14T00:00:00Z", + }).meta.dependencies); + + renderList(cards); + + expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument(); + expect(screen.getAllByText("Сводка временно недоступна")) + .toHaveLength(2); + const staleLink = screen.getByRole("link", { name: /Устаревший/ }); + expect(within(staleLink).getByText("Данные требуют обновления")) + .toBeInTheDocument(); + expect(within(staleLink).getByText(/Данные на/)).toBeInTheDocument(); + const failedLink = screen.getByRole("link", { name: /Ошибка обновления/ }); + expect(within(failedLink).getByText( + "Не удалось обновить данные. Показаны последние полученные.", + )).toBeInTheDocument(); + }); }); diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx index 369d9fa..462334f 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx @@ -1,10 +1,10 @@ -import type { FC } from "react"; +import type { CSSProperties, FC } from "react"; -import { Group, Stack, Text } from "@mantine/core"; +import { Badge, Group, Stack, Text, VisuallyHidden } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { formatMoney } from "@/shared/lib"; +import { formatMoney, portfolioCategoryPresentation } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui"; import type { BrokerageAccountCard } from "../../model/display"; @@ -12,12 +12,176 @@ import type { BrokerageAccountCard } from "../../model/display"; import { accountTypeKey } from "../../model/display"; import classes from "./BrokerageAccountList.module.css"; +const DECIMAL_PART_COUNT = 2; +const STRUCTURE_CATEGORY_ORDER = ["STOCK", "BOND", "OTHER", "CASH"] as const; + +type AvailableSummary = Extract< + BrokerageAccountCard["summary"], + { state: "available" } +>; + type BrokerageAccountListProps = { accounts: BrokerageAccountCard[] }; type BrokerageAccountSummaryProps = { summary: BrokerageAccountCard["summary"]; }; +type PortfolioSummary = AvailableSummary["portfolio"]; + +type ReturnValueProps = { + item: PortfolioSummary["unrealizedReturnByCurrency"][number]; +}; + +type StructureCurrency = PortfolioSummary["structure"]["currencies"][number]; + +function formatPercent(value: null | string) { + if (value === null) return "—"; + + const formatted = `${value.replace(".", ",")}\u{A0}%`; + if (isZero(value)) + return value.startsWith("-") ? formatted.slice(1) : formatted; + + return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`; +} + +function formatStructurePercent(value: string) { + let trimmed = value; + while (trimmed.includes(".") && trimmed.endsWith("0")) + trimmed = trimmed.slice(0, -1); + if (trimmed.endsWith(".")) trimmed = trimmed.slice(0, -1); + + return trimmed.replace(".", ","); +} + +function isZero(value: string) { + const unsigned = value.startsWith("-") ? value.slice(1) : value; + const [integer = "", fraction = ""] = unsigned.split(".", DECIMAL_PART_COUNT); + + return integer.replaceAll("0", "") === "" + && fraction.replaceAll("0", "") === ""; +} + +const ReturnValue: FC = (props) => { + const { t } = useTranslation(); + const { item } = props; + + const isNegative = item.percent !== null + && item.percent.startsWith("-") + && !isZero(item.percent); + + const isPositive = item.percent !== null + && !item.percent.startsWith("-") + && !isZero(item.percent); + + let toneClass = classes.neutral; + if (isNegative) toneClass = classes.negative; + else if (isPositive) toneClass = classes.positive; + + return ( + + + {formatPercent(item.percent)} + + + + {formatMoney( + { currency: item.currency, value: item.absolute }, + { withSign: true }, + )} + + + {item.partial && ( + + {t("accounts.partial")} + + )} + + ); +}; + +type StructureBarProps = { item: StructureCurrency }; + +const StructureBar: FC = (props) => { + const { t } = useTranslation(); + + const categories = STRUCTURE_CATEGORY_ORDER.flatMap((type) => { + const category = props.item.categories.find(item => item.type === type); + + return category ? [category] : []; + }); + + const description = categories.map((category) => { + const label = t(portfolioCategoryPresentation[category.type].label); + + const percent = category.sharePercent === null + ? null + : formatStructurePercent(category.sharePercent); + + return percent === null + ? t("accounts.structure.incomplete", { category: label }) + : t("accounts.structure.category", { category: label, percent }); + }).join("; "); + + const label = t("accounts.structure.label", { + currency: props.item.currency, + value: description, + }); + + const legendCategories = categories.filter(category => + category.sharePercent === null || !isZero(category.sharePercent)); + + return ( + +
+ {categories.map((category) => { + const presentation = portfolioCategoryPresentation[category.type]; + + const style = { + "--category-color": `var(--mantine-color-${presentation.color.replace(".", "-")})`, + "width": `${category.sharePercent ?? 0}%`, + } as CSSProperties; + + return ( +
+ ); + })} +
+ + + {legendCategories.map((category) => { + const presentation = portfolioCategoryPresentation[category.type]; + const categoryLabel = t(presentation.label); + + const text = category.sharePercent === null + ? categoryLabel + : t("accounts.structure.category", { + category: categoryLabel, + percent: formatStructurePercent(category.sharePercent), + }); + + return ( + + ● + {" "} + {text} + + ); + })} + + + ); +}; + const BrokerageAccountSummary: FC = (props) => { const { summary } = props; const { t } = useTranslation(); @@ -35,19 +199,51 @@ const BrokerageAccountSummary: FC = (props) => { ); return ( - - {summary.totals.map(total => ( - - {formatMoney(total, { withRoundedCents: true })} - - ))} + + + + {summary.totals.map(total => ( + + {formatMoney(total, { withRoundedCents: true })} + + ))} - {!!summary.remainingCurrencyCount && ( - - {t("accounts.remainingCurrencies", { - count: summary.remainingCurrencyCount, + {!!summary.remainingCurrencyCount && ( + + {t("accounts.remainingCurrencies", { + count: summary.remainingCurrencyCount, + })} + + )} + + + + {summary.totals.map((total) => { + const item = summary.portfolio.unrealizedReturnByCurrency.find( + returnItem => returnItem.currency === total.currency, + ); + + return item + ? + : null; })} - + + + + {summary.portfolio.cashBalances.length > 0 && ( + + + {t("accounts.cash")} + + + + {summary.portfolio.cashBalances.map(cash => ( + + {formatMoney(cash, { withRoundedCents: true })} + + ))} + + )} ); @@ -60,13 +256,13 @@ type BrokerageAccountSourceStatusProps = { const BrokerageAccountSourceStatus: FC = ( props, ) => { - const { source } = props; const { t } = useTranslation(); - + const { source } = props; const isSyncing = source?.lastAttempt.state === "running"; - const errorText - = source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null; + const errorText = source?.lastAttempt.state === "failed" + ? t("accounts.refreshError") + : null; return ( <> @@ -86,80 +282,110 @@ const BrokerageAccountSourceStatus: FC = ( ); }; +type AccountDescriptionProps = { + account: BrokerageAccountCard; +}; + +const AccountDescription: FC = (props) => { + const { t } = useTranslation(); + const { account, summary } = props.account; + if (summary.state !== "available") return null; + + const returns = summary.portfolio.unrealizedReturnByCurrency.map(item => + t("accounts.description.return", { + absolute: formatMoney( + { currency: item.currency, value: item.absolute }, + { withSign: true }, + ), + currency: item.currency, + percent: formatPercent(item.percent), + }), + ); + + const structures = summary.portfolio.structure.currencies.map((item) => { + const categories = item.categories.map(category => + t("accounts.description.structureCategory", { + category: t(portfolioCategoryPresentation[category.type].label), + value: formatMoney( + { currency: item.currency, value: category.value }, + { withRoundedCents: true }, + ), + }), + ).join(", "); + + return t("accounts.description.structure", { + currency: item.currency, + value: categories, + }); + }); + + const hasPartialReturn = summary.portfolio.unrealizedReturnByCurrency.some( + item => item.partial, + ); + + const details = [...returns, ...structures].join(". "); + + return ( + + {t("accounts.description.start", { + name: account.name, + type: t(accountTypeKey(account.type)), + })} + {t("accounts.description.currentValue", { + value: summary.portfolio.totalsByCurrency + .map(total => formatMoney(total)).join(", "), + })} + {details ? `${details}. ` : ""} + {hasPartialReturn ? t("accounts.brokerDescription") : ""} + + ); +}; + const BrokerageAccountList: FC = (props) => { - const { accounts } = props; const { t } = useTranslation(); return ( - - {accounts.map(({ account, positionCounts, source, summary }) => { - const accountType = t(accountTypeKey(account.type)); +
    + {props.accounts.map(({ account, positionCounts, source, summary }) => ( +
  • + + +

    {account.name}

    - return ( -
  • - - - - {account.name} - + + {t(accountTypeKey(account.type))} + + - {account.name !== accountType && ( - - {accountType} - - )} - + - + + {positionCounts && ( - {t("accounts.currentValue")} + {t("accounts.positions", { + count: positionCounts.total, + })} + )} - + + + + {summary.state === "available" && ( + + {summary.portfolio.structure.currencies.map(item => ( + + ))} + )} - - {!!positionCounts && ( - -
    - - {t("accounts.stocks")} - - - - {new Intl.NumberFormat("ru-RU").format( - positionCounts.stocks, - )} - -
    - -
    - - {t("accounts.bonds")} - - - - {new Intl.NumberFormat("ru-RU").format( - positionCounts.bonds, - )} - -
    -
    - )} - - - - -
    - -
  • - ); - })} - + + + + ))} +
); }; diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx index 851d087..6da65ee 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx @@ -31,6 +31,19 @@ function configureTestAuth() { }); } +function portfolioSummary(totals: { currency: string; value: string }[]) { + return { + bondPositionCount: 1, + cashBalances: [], + fundPositionCount: 0, + stockPositionCount: 0, + structure: { currencies: [], unvaluedCategories: [] }, + totalPositionCount: 1, + totalsByCurrency: totals, + unrealizedReturnByCurrency: [], + }; +} + function renderPage(queryClient: QueryClient) { configureTestAuth(); @@ -46,6 +59,65 @@ function renderPage(queryClient: QueryClient) { } describe("AccountsPage", () => { + it("summarizes currencies separately and reports excluded accounts", async () => { + server.use(http.get("*/api/v1/brokerage-accounts", () => + HttpResponse.json(resource([ + { + ...account, + portfolioSummary: portfolioSummary([ + { currency: "RUB", value: "100.10" }, + { currency: "USD", value: "20" }, + ]), + }, + { + ...account, + id: "account-2", + name: "Второй", + portfolioSummary: portfolioSummary([ + { currency: "RUB", value: "50.20" }, + ]), + }, + { + ...account, + id: "account-3", + name: "Без сводки", + portfolioSummary: null, + }, + { + ...account, + id: "account-4", + name: "Только деньги", + portfolioSummary: { + ...portfolioSummary([{ currency: "RUB", value: "25" }]), + bondPositionCount: 0, + cashBalances: [{ currency: "RUB", value: "25" }], + totalPositionCount: 0, + }, + }, + { + ...account, + id: "account-5", + name: "Без сумм", + portfolioSummary: { + ...portfolioSummary([]), + totalPositionCount: 0, + }, + }, + ])), + )); + const client = createTestQueryClient(); + + renderPage(client); + + expect(await screen.findByText("175,30 ₽")).toBeInTheDocument(); + expect(screen.getAllByText("20,00 USD")).not.toHaveLength(0); + expect(screen.getByText("5 счетов")).toBeInTheDocument(); + expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument(); + expect(screen.getByText( + "Ещё 1 счёт не включён: нет актуального портфеля", + )).toBeInTheDocument(); + }); + it("loads accounts after retrying an initial HTTP failure", async () => { const modes: (null | string)[] = []; server.use( diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx index ec58f96..96d5d88 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx @@ -1,6 +1,6 @@ import type { FC } from "react"; -import { Button, Stack, Text, Title } from "@mantine/core"; +import { Button, SimpleGrid, Stack, Text, Title } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; @@ -10,15 +10,82 @@ import { type BrokerageAccountCard, BrokerageAccountList, } from "@/entities/brokerage-account"; +import { formatMoney } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui"; +const DECIMAL_BASE = 10n; +const DECIMAL_PART_COUNT = 2; +const ZERO = 0n; + type AccountsPageLoadedProps = { accounts: BrokerageAccountCard[]; meta: InvestmentResourceMeta; }; +type Money = { currency: string; value: string }; + +function accountTotals(accounts: BrokerageAccountCard[]) { + const values = new Map(); + let excludedCount = 0; + + for (const { summary } of accounts) { + if (summary.state === "empty") continue; + + if (summary.state === "unavailable") { + excludedCount += 1; + continue; + } + + for (const total of summary.portfolio.totalsByCurrency) { + values.set(total.currency, [ + ...values.get(total.currency) ?? [], + total.value, + ]); + } + } + + const totals: Money[] = Array.from(values, ([currency, amounts]) => ({ + currency, + value: sumDecimals(amounts), + })); + + return { excludedCount, totals }; +} + +function sumDecimals(values: string[]) { + const scale = Math.max( + 0, + ...values.map(value => + value.split(".", DECIMAL_PART_COUNT)[1]?.length ?? 0), + ); + + const factor = DECIMAL_BASE ** BigInt(scale); + let sum = ZERO; + + for (const value of values) { + const isNegative = value.startsWith("-"); + + const [integer = "0", fraction = ""] = (isNegative + ? value.slice(1) + : value).split(".", DECIMAL_PART_COUNT); + + const units = BigInt(integer) * factor + + BigInt(fraction.padEnd(scale, "0") || "0"); + + sum += isNegative ? -units : units; + } + + const isNegative = sum < ZERO; + const digits = (isNegative ? -sum : sum).toString().padStart(scale + 1, "0"); + const integer = scale ? digits.slice(0, -scale) : digits; + const fraction = scale ? `.${digits.slice(-scale)}` : ""; + + return `${isNegative ? "-" : ""}${integer}${fraction}`; +} + const AccountsPageLoaded: FC = (props) => { const { t } = useTranslation(); + const { excludedCount, totals } = accountTotals(props.accounts); return ( @@ -28,6 +95,37 @@ const AccountsPageLoaded: FC = (props) => { {t("accounts.subtitle")} + + + + + + + {t("accounts.currentValue")} + + + {totals.length > 0 + ? totals.map(total => ( + + {formatMoney(total, { withRoundedCents: true })} + + )) + : {t("accounts.summaryNoTotals")}} + + + + {t("accounts.count", { + count: props.accounts.length, + })} + + + {excludedCount > 0 && ( + + {t("accounts.summaryExcluded", { count: excludedCount })} + + )} + + { formatMoney({ currency: "RUB", value: "1.5" }, { withZeroCents: true }), ).toBe("1,50 ₽"); expect(formatMoney(null)).toBe("—"); + expect(formatMoney( + { currency: "RUB", value: "136900" }, + { withSign: true }, + )).toBe("+136 900 ₽"); + expect(formatMoney( + { currency: "RUB", value: "-4200" }, + { withSign: true }, + )).toBe("−4 200 ₽"); + expect(formatMoney( + { currency: "RUB", value: "-0.00" }, + { withSign: true }, + )).toBe("0,00 ₽"); }); it("builds the brand logo url from an upstream file name", () => { diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts index d868243..5040211 100644 --- a/src/shared/lib/display.ts +++ b/src/shared/lib/display.ts @@ -3,9 +3,17 @@ const CENTS_PRECISION = 2; type FormatMoneyOptions = { withRoundedCents?: boolean; + withSign?: boolean; withZeroCents?: boolean; }; +const portfolioCategoryPresentation = { + BOND: { color: "grape.6", label: "portfolio.bonds" }, + CASH: { color: "green.6", label: "portfolio.structure.cash" }, + OTHER: { color: "orange.6", label: "portfolio.structure.other" }, + STOCK: { color: "blue.6", label: "portfolio.stocks" }, +} as const; + type Money = { currency: string; value: string }; const dateFormatter = new Intl.DateTimeFormat("ru-RU", { @@ -40,7 +48,11 @@ function formatMoney( money: Money | null | undefined, - { withRoundedCents = false, withZeroCents = false }: FormatMoneyOptions = {}, + { + withRoundedCents = false, + withSign = false, + withZeroCents = false, + }: FormatMoneyOptions = {}, ): string { if (!money) return "—"; @@ -62,8 +74,25 @@ function formatMoney( : integerPart; const currency = money.currency === "RUB" ? "₽" : money.currency; + const formatted = `${formatDecimal(decimal)} ${currency}`; - return `${formatDecimal(decimal)} ${currency}`; + if (!withSign) return formatted; + if (isZeroDecimal(value)) + return value.startsWith("-") ? formatted.slice(1) : formatted; + + return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`; +} + +function isZeroDecimal(value: string) { + const unsigned = value.startsWith("-") ? value.slice(1) : value; + + const [integer = "", fraction = ""] = unsigned.split( + ".", + DECIMAL_PART_COUNT, + ); + + return integer.replaceAll("0", "") === "" + && fraction.replaceAll("0", "") === ""; } function roundDecimalToCents(value: string): string { @@ -116,4 +145,5 @@ export { formatMoney, formatSnapshotTime, instrumentLogoUrl, + portfolioCategoryPresentation, }; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index ec94855..6fbdb67 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -7,6 +7,7 @@ export { formatMoney, formatSnapshotTime, instrumentLogoUrl, + portfolioCategoryPresentation, } from "./display"; export { useInitialAvailability } from "./initial-availability"; export { useUrlPeriod } from "./useUrlPeriod"; diff --git a/src/widgets/portfolio-structure/ui/PortfolioStructure.vm.ts b/src/widgets/portfolio-structure/ui/PortfolioStructure.vm.ts index 2bdc39f..6e6cd7f 100644 --- a/src/widgets/portfolio-structure/ui/PortfolioStructure.vm.ts +++ b/src/widgets/portfolio-structure/ui/PortfolioStructure.vm.ts @@ -5,20 +5,13 @@ import { useTranslation } from "react-i18next"; import type { Portfolio } from "@/entities/portfolio"; -import { formatMoney } from "@/shared/lib"; +import { formatMoney, portfolioCategoryPresentation } from "@/shared/lib"; const MIN_VISIBLE_SHARE_PERCENT = 0.01; // A cached snapshot or an older running server can predate this response field. type PortfolioStructureProps = { structure?: null | Portfolio["structure"] }; -const categoryPresentation = { - BOND: { color: "grape.6", label: "portfolio.bonds" }, - CASH: { color: "green.6", label: "portfolio.structure.cash" }, - OTHER: { color: "orange.6", label: "portfolio.structure.other" }, - STOCK: { color: "blue.6", label: "portfolio.stocks" }, -} as const; - const usePortfolioStructureVm = (props: PortfolioStructureProps) => { const { structure } = props; const { t } = useTranslation(); @@ -45,7 +38,7 @@ const usePortfolioStructureVm = (props: PortfolioStructureProps) => { unvalued: structure?.unvaluedCategories.map(category => t("portfolio.structure.unvaluedCategory", { - category: t(categoryPresentation[category.type].label), + category: t(portfolioCategoryPresentation[category.type].label), count: category.count, }), ) ?? [], @@ -61,7 +54,7 @@ function portfolioRows( if (!selected) return []; return selected.categories.map((category) => { - const presentation = categoryPresentation[category.type]; + const presentation = portfolioCategoryPresentation[category.type]; const percentage = category.sharePercent === null