From c24704affacbe7fbc08f5e2b61bb6e7ffa6053b9 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 26 Oct 2026 21:00:24 +0300 Subject: [PATCH 1/3] feat: make brokerage account list compact (#111) --- .../BrokerageAccountList.module.css | 169 ++++++------------ .../BrokerageAccountList.stories.tsx | 2 +- .../BrokerageAccountList.test.tsx | 28 +-- .../BrokerageAccountList.tsx | 133 +++++++------- src/shared/lib/display.ts | 28 ++- 5 files changed, 165 insertions(+), 195 deletions(-) diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css index 80afbf0..fdb9a31 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css @@ -1,61 +1,68 @@ .list { width: 100%; + margin: 0; + padding: 0; + list-style: none; } -.card { - min-width: 0; - gap: var(--mantine-spacing-xl); +.row { + display: grid; + grid-template-areas: + "identity balance" + "details balance"; + grid-template-columns: minmax(0, 1fr) minmax(0, auto); + gap: var(--mantine-spacing-xs) var(--mantine-spacing-xl); + align-items: center; + padding: var(--mantine-spacing-md); + border: 1px solid var(--wl-color-border); + border-left: 3px solid var(--mantine-color-blue-5); + border-radius: var(--mantine-radius-md); background: var(--mantine-color-white); - border-color: var(--mantine-color-gray-3); - box-shadow: 0 4px 16px rgb(15 23 42 / 3%); color: inherit; text-decoration: none; - transition: - transform 160ms ease, - box-shadow 160ms ease, - border-color 160ms ease; + transition: background-color 160ms ease, border-color 160ms ease; } -.card:hover, -.card:focus-visible { +.row:hover, +.row:focus-visible { border-color: var(--mantine-color-blue-3); - box-shadow: 0 12px 28px rgb(15 23 42 / 8%); - transform: translateY(-3px); + border-left-color: var(--mantine-color-blue-6); + background: var(--mantine-color-blue-0); } -.card:focus-visible { +.row:focus-visible { outline: 2px solid var(--mantine-color-blue-6); - outline-offset: 3px; + outline-offset: -2px; } .identity { + grid-area: identity; min-width: 0; } -.kicker { - padding: 5px 10px; - border-radius: var(--mantine-radius-sm); - background: var(--mantine-color-blue-0); - color: var(--mantine-color-blue-8); - letter-spacing: 0.02em; -} - -.accountIcon { - width: 28px; - height: 28px; - flex-shrink: 0; - color: var(--mantine-color-blue-5); -} - .name { margin: 0; - font-size: var(--mantine-h3-font-size); - line-height: 1.3; + color: var(--mantine-color-blue-9); + font-size: var(--mantine-font-size-md); + line-height: var(--mantine-line-height-md); overflow-wrap: anywhere; } +.accountType { + 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 { - flex: 1; + 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 { @@ -63,104 +70,46 @@ } .total { - font-size: clamp(1.5rem, 2.8vw, 2.25rem); - letter-spacing: -0.035em; - line-height: 1.2; + color: var(--mantine-color-dark-7); + font-size: var(--mantine-font-size-lg); + line-height: var(--mantine-line-height-md); overflow-wrap: anywhere; } .total + .total { - font-size: var(--mantine-font-size-lg); font-weight: 500; - letter-spacing: 0; - color: var(--mantine-color-gray-7); +} + +.details { + grid-area: details; + min-width: 0; } .positions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--mantine-spacing-sm); margin: 0; } .positions > div { display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 4px var(--mantine-spacing-sm); - padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); - border-radius: var(--mantine-radius-md); -} - -.stocks { - background: var(--mantine-color-blue-0); - color: var(--mantine-color-blue-8); -} - -.bonds { - background: var(--mantine-color-grape-0); - color: var(--mantine-color-grape-8); -} - -.positions dt { - font-size: var(--mantine-font-size-sm); - font-weight: 500; + gap: var(--mantine-spacing-xs); + align-items: baseline; } .positions dd { margin: 0; - font-size: 1.75rem; - font-weight: 700; - font-variant-numeric: tabular-nums; - line-height: 1.25; +} + +.status { + min-width: 0; overflow-wrap: anywhere; } -.footer { - border-top: 1px solid var(--mantine-color-gray-2); - padding-top: var(--mantine-spacing-md); -} - -.open { - display: inline-flex; - align-items: center; - gap: var(--mantine-spacing-xs); - margin-left: auto; - color: var(--mantine-color-blue-7); -} - -.arrow { - font-size: var(--mantine-font-size-lg); - transition: transform 160ms ease; -} - -.card:hover .arrow { - transform: translateX(3px); -} - -@media (max-width: 36em) { - .card { - padding: var(--mantine-spacing-md); - gap: var(--mantine-spacing-lg); +@media (max-width: 48em) { + .row { + column-gap: var(--mantine-spacing-sm); } - .positions > div { - flex-direction: column; - align-items: flex-start; - padding: var(--mantine-spacing-sm); - } -} - -@media (prefers-reduced-motion: reduce) { - .card, - .arrow { - transition: none; - } - - .card:hover, - .card:focus-visible, - .card:hover .arrow { - transform: none; + .balance { + max-width: 50vw; } } diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx index 255e46c..0789e22 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx @@ -40,7 +40,7 @@ const meta = { docs: { description: { component: - "Карточки счетов WealthLedger со сводкой по валютам и числом наименований акций и облигаций. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.", + "Компактные строки счетов WealthLedger: название и отличающийся тип слева, текущая стоимость с двумя знаками справа на desktop и под названием на mobile. Видимы не более двух валют и счётчик остальных, позиции и локальный статус источника. Отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.", }, }, }, diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx index 8464fb7..ec6cdbd 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx @@ -23,7 +23,10 @@ describe("BrokerageAccountList", () => { bondPositionCount: 12, stockPositionCount: 5, totalsByCurrency: [ - { currency: "RUB", value: "1500" }, + { + currency: "RUB", + value: "123456789012345678901.995", + }, { currency: "USD", value: "20" }, { currency: "EUR", value: "5" }, ], @@ -33,7 +36,7 @@ describe("BrokerageAccountList", () => { }, { id: "account-2", - name: "Недоступный", + name: "Брокерский счет", openedOn: null, portfolioSummary: null, type: "BROKERAGE", @@ -60,24 +63,27 @@ describe("BrokerageAccountList", () => { "/accounts/account-1", ); expect(screen.getAllByText("Брокерский счет")).toHaveLength(3); - expect(screen.getByText(/^Открыт /)).toBeInTheDocument(); - expect(screen.getByText("1 500 ₽")).toBeInTheDocument(); - expect(screen.getByText("20 USD")).toBeInTheDocument(); + 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(); - expect(screen.getAllByText("Открыть счет")).toHaveLength(3); + const available = within(screen.getByRole("link", { name: /Основной/ })); expect(available.getAllByRole("definition")[0]).toHaveTextContent("5"); expect(available.getAllByRole("definition")[1]).toHaveTextContent("12"); + const unvalued = within(screen.getByRole("link", { name: /Пустой/ })); expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent("2"); expect(unvalued.getAllByRole("definition")[1]).toHaveTextContent("0"); - expect( - within(screen.getByRole("link", { name: /Недоступный/ })).queryByText( - "Акции", - ), - ).not.toBeInTheDocument(); + + const duplicatedName = within(screen.getByRole("link", { + name: /Сводка временно недоступна/, + })); + + expect(duplicatedName.getAllByText("Брокерский счет")).toHaveLength(1); + expect(duplicatedName.queryByText("Акции")).not.toBeInTheDocument(); }); }); diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx index dd8dd3b..369d9fa 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx @@ -1,11 +1,10 @@ import type { FC } from "react"; -import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; -import { WalletIcon } from "@wealthledger/icons"; +import { Group, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { formatDate, formatMoney } from "@/shared/lib"; +import { formatMoney } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui"; import type { BrokerageAccountCard } from "../../model/display"; @@ -39,7 +38,7 @@ const BrokerageAccountSummary: FC = (props) => { {summary.totals.map(total => ( - {formatMoney(total)} + {formatMoney(total, { withRoundedCents: true })} ))} @@ -92,81 +91,75 @@ const BrokerageAccountList: FC = (props) => { const { t } = useTranslation(); return ( - - {accounts.map(({ account, positionCounts, source, summary }) => ( - - - - - {t(accountTypeKey(account.type))} - + + {accounts.map(({ account, positionCounts, source, summary }) => { + const accountType = t(accountTypeKey(account.type)); - - + return ( +
  • + + + + {account.name} + - - {account.name} - - + {account.name !== accountType && ( + + {accountType} + + )} + - - - {t("accounts.currentValue")} - + + + {t("accounts.currentValue")} + - + + - - + + {!!positionCounts && ( + +
    + + {t("accounts.stocks")} + - {!!positionCounts && ( -
    -
    -
    {t("accounts.stocks")}
    + + {new Intl.NumberFormat("ru-RU").format( + positionCounts.stocks, + )} + +
    -
    - {new Intl.NumberFormat("ru-RU").format(positionCounts.stocks)} -
    -
    +
    + + {t("accounts.bonds")} + -
    -
    {t("accounts.bonds")}
    + + {new Intl.NumberFormat("ru-RU").format( + positionCounts.bonds, + )} + +
    + + )} -
    - {new Intl.NumberFormat("ru-RU").format(positionCounts.bonds)} -
    -
    - - )} - - - {!!account.openedOn && ( - - {t("accounts.openedOn", { - date: formatDate(account.openedOn), - })} - - )} - - - {t("accounts.openAccount")} - - - - - - ))} - + + + +
    + +
  • + ); + })} +
    ); }; diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts index db44e99..d868243 100644 --- a/src/shared/lib/display.ts +++ b/src/shared/lib/display.ts @@ -2,6 +2,7 @@ const DECIMAL_PART_COUNT = 2; const CENTS_PRECISION = 2; type FormatMoneyOptions = { + withRoundedCents?: boolean; withZeroCents?: boolean; }; @@ -39,16 +40,20 @@ function formatMoney( money: Money | null | undefined, - { withZeroCents = false }: FormatMoneyOptions = {}, + { withRoundedCents = false, withZeroCents = false }: FormatMoneyOptions = {}, ): string { if (!money) return "—"; - const [integerPart = "", fractionPart = ""] = money.value.split( + const value = withRoundedCents + ? roundDecimalToCents(money.value) + : money.value; + + const [integerPart = "", fractionPart = ""] = value.split( ".", DECIMAL_PART_COUNT, ); - const normalizedFraction = withZeroCents + const normalizedFraction = withRoundedCents || withZeroCents ? fractionPart.padEnd(CENTS_PRECISION, "0") : fractionPart; @@ -61,6 +66,23 @@ function formatMoney( return `${formatDecimal(decimal)} ${currency}`; } +function roundDecimalToCents(value: string): string { + const [integer = "", fraction = ""] = value.split(".", DECIMAL_PART_COUNT); + const sign = integer.startsWith("-") ? "-" : ""; + const digits = sign ? integer.slice(1) : integer; + const roundedFraction = fraction.padEnd(CENTS_PRECISION + 1, "0"); + const cents = `${digits}${roundedFraction.slice(0, CENTS_PRECISION)}`; + const roundingDigit = roundedFraction.at(CENTS_PRECISION) ?? "0"; + + const rounded = roundingDigit >= "5" + ? (BigInt(cents) + 1n).toString().padStart(cents.length, "0") + : cents; + + const roundedInteger = rounded.slice(0, -CENTS_PRECISION) || "0"; + + return `${sign}${roundedInteger}.${rounded.slice(-CENTS_PRECISION)}`; +} + // Без timeZone Intl берёт часовой пояс браузера пользователя. const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", -- 2.54.0 From 0e4bbec2a7b814579095ea7ef6db68a70508fa29 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 26 Oct 2026 22:19:55 +0300 Subject: [PATCH 2/3] chore: sync brokerage accounts contract (WealthLedger/backend#138) --- src/shared/api/generated/schema.d.ts | 67 ++++--- src/shared/api/spec/openapi.json | 259 +++++++++++++++++---------- 2 files changed, 202 insertions(+), 124 deletions(-) diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index ba8d0ce..b73622d 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -909,12 +909,54 @@ export interface components { version: number; archived?: boolean; }; + PortfolioStructureCategoryDto: { + /** @enum {string} */ + type: "STOCK" | "BOND" | "OTHER" | "CASH"; + /** @description Exact known value as a decimal string. */ + value: string; + /** @description Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions. */ + sharePercent: string | null; + }; + PortfolioStructureCurrencyDto: { + currency: string; + totalValue: string; + /** @description Any negative constituent, even if netted positive. */ + hasNegativeValues: boolean; + categories: components["schemas"]["PortfolioStructureCategoryDto"][]; + }; + PortfolioUnvaluedCategoryDto: { + /** @enum {string} */ + type: "STOCK" | "BOND" | "OTHER"; + count: number; + }; + PortfolioStructureDto: { + currencies: components["schemas"]["PortfolioStructureCurrencyDto"][]; + /** @description Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty. */ + unvaluedCategories: components["schemas"]["PortfolioUnvaluedCategoryDto"][]; + }; + UnrealizedReturnDto: { + currency: string; + /** @example 12.345678901 */ + absolute: string; + /** @example 100.00 */ + costBasis: string; + /** @example 12.34 */ + percent: string | null; + partial: boolean; + }; PortfolioSummaryDto: { totalsByCurrency: components["schemas"]["MoneyDto"][]; + cashBalances: components["schemas"]["MoneyDto"][]; + structure: components["schemas"]["PortfolioStructureDto"]; + unrealizedReturnByCurrency: components["schemas"]["UnrealizedReturnDto"][]; /** @description Number of non-zero stock positions, not the number of units. */ stockPositionCount: number; /** @description Number of non-zero bond positions, not the number of units. */ bondPositionCount: number; + /** @description Number of non-zero fund positions, not the number of units. */ + fundPositionCount: number; + /** @description Number of all non-zero positions, not the number of units. */ + totalPositionCount: number; }; BrokerageAccountDto: { id: string; @@ -1026,31 +1068,6 @@ export interface components { ticker: string | null; logoName: string | null; }; - PortfolioStructureCategoryDto: { - /** @enum {string} */ - type: "STOCK" | "BOND" | "OTHER" | "CASH"; - /** @description Exact known value as a decimal string. */ - value: string; - /** @description Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions. */ - sharePercent: string | null; - }; - PortfolioStructureCurrencyDto: { - currency: string; - totalValue: string; - /** @description Any negative constituent, even if netted positive. */ - hasNegativeValues: boolean; - categories: components["schemas"]["PortfolioStructureCategoryDto"][]; - }; - PortfolioUnvaluedCategoryDto: { - /** @enum {string} */ - type: "STOCK" | "BOND" | "OTHER"; - count: number; - }; - PortfolioStructureDto: { - currencies: components["schemas"]["PortfolioStructureCurrencyDto"][]; - /** @description Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty. */ - unvaluedCategories: components["schemas"]["PortfolioUnvaluedCategoryDto"][]; - }; PortfolioDto: { /** Format: date-time */ lastSuccessfulAt: string; diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index 8ed9d81..eba7e13 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -5819,6 +5819,135 @@ "version" ] }, + "PortfolioStructureCategoryDto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "STOCK", + "BOND", + "OTHER", + "CASH" + ] + }, + "value": { + "type": "string", + "description": "Exact known value as a decimal string." + }, + "sharePercent": { + "type": "string", + "nullable": true, + "description": "Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions." + } + }, + "required": [ + "type", + "value", + "sharePercent" + ] + }, + "PortfolioStructureCurrencyDto": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "totalValue": { + "type": "string" + }, + "hasNegativeValues": { + "type": "boolean", + "description": "Any negative constituent, even if netted positive." + }, + "categories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PortfolioStructureCategoryDto" + } + } + }, + "required": [ + "currency", + "totalValue", + "hasNegativeValues", + "categories" + ] + }, + "PortfolioUnvaluedCategoryDto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "STOCK", + "BOND", + "OTHER" + ] + }, + "count": { + "type": "number", + "minimum": 1 + } + }, + "required": [ + "type", + "count" + ] + }, + "PortfolioStructureDto": { + "type": "object", + "properties": { + "currencies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PortfolioStructureCurrencyDto" + } + }, + "unvaluedCategories": { + "description": "Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.", + "type": "array", + "items": { + "$ref": "#/components/schemas/PortfolioUnvaluedCategoryDto" + } + } + }, + "required": [ + "currencies", + "unvaluedCategories" + ] + }, + "UnrealizedReturnDto": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "absolute": { + "type": "string", + "example": "12.345678901" + }, + "costBasis": { + "type": "string", + "example": "100.00" + }, + "percent": { + "type": "string", + "nullable": true, + "example": "12.34" + }, + "partial": { + "type": "boolean" + } + }, + "required": [ + "currency", + "absolute", + "costBasis", + "percent", + "partial" + ] + }, "PortfolioSummaryDto": { "type": "object", "properties": { @@ -5828,6 +5957,21 @@ "$ref": "#/components/schemas/MoneyDto" } }, + "cashBalances": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MoneyDto" + } + }, + "structure": { + "$ref": "#/components/schemas/PortfolioStructureDto" + }, + "unrealizedReturnByCurrency": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UnrealizedReturnDto" + } + }, "stockPositionCount": { "type": "integer", "minimum": 0, @@ -5837,12 +5981,27 @@ "type": "integer", "minimum": 0, "description": "Number of non-zero bond positions, not the number of units." + }, + "fundPositionCount": { + "type": "integer", + "minimum": 0, + "description": "Number of non-zero fund positions, not the number of units." + }, + "totalPositionCount": { + "type": "integer", + "minimum": 0, + "description": "Number of all non-zero positions, not the number of units." } }, "required": [ "totalsByCurrency", + "cashBalances", + "structure", + "unrealizedReturnByCurrency", "stockPositionCount", - "bondPositionCount" + "bondPositionCount", + "fundPositionCount", + "totalPositionCount" ] }, "BrokerageAccountDto": { @@ -6314,104 +6473,6 @@ "logoName" ] }, - "PortfolioStructureCategoryDto": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "STOCK", - "BOND", - "OTHER", - "CASH" - ] - }, - "value": { - "type": "string", - "description": "Exact known value as a decimal string." - }, - "sharePercent": { - "type": "string", - "nullable": true, - "description": "Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions." - } - }, - "required": [ - "type", - "value", - "sharePercent" - ] - }, - "PortfolioStructureCurrencyDto": { - "type": "object", - "properties": { - "currency": { - "type": "string" - }, - "totalValue": { - "type": "string" - }, - "hasNegativeValues": { - "type": "boolean", - "description": "Any negative constituent, even if netted positive." - }, - "categories": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PortfolioStructureCategoryDto" - } - } - }, - "required": [ - "currency", - "totalValue", - "hasNegativeValues", - "categories" - ] - }, - "PortfolioUnvaluedCategoryDto": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "STOCK", - "BOND", - "OTHER" - ] - }, - "count": { - "type": "number", - "minimum": 1 - } - }, - "required": [ - "type", - "count" - ] - }, - "PortfolioStructureDto": { - "type": "object", - "properties": { - "currencies": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PortfolioStructureCurrencyDto" - } - }, - "unvaluedCategories": { - "description": "Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.", - "type": "array", - "items": { - "$ref": "#/components/schemas/PortfolioUnvaluedCategoryDto" - } - } - }, - "required": [ - "currencies", - "unvaluedCategories" - ] - }, "PortfolioDto": { "type": "object", "properties": { -- 2.54.0 From 431559be0fca9eb37f6a09d930d502f939884e39 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 26 Oct 2026 22:19:58 +0300 Subject: [PATCH 3/3] feat: show return, cash and structure on account cards (#114) --- .../brokerage-account/model/display.ts | 14 +- .../BrokerageAccountList.module.css | 101 +++-- .../BrokerageAccountList.stories.tsx | 354 ++++++++-------- .../BrokerageAccountList.test.tsx | 296 +++++++++----- .../BrokerageAccountList.tsx | 386 ++++++++++++++---- .../ui/AccountsPage/AccountsPage.test.tsx | 72 ++++ .../components/AccountsPageLoaded.view.tsx | 100 ++++- src/shared/i18n/locales/ru.ts | 32 ++ src/shared/lib/display.test.ts | 12 + src/shared/lib/display.ts | 34 +- src/shared/lib/index.ts | 1 + .../ui/PortfolioStructure.vm.ts | 13 +- 12 files changed, 984 insertions(+), 431 deletions(-) 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 -- 2.54.0