From 24e53c713e3fb328a3ad57295d7f2a6d30ae554b Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 26 Oct 2026 21:00:24 +0300 Subject: [PATCH] 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",