feat: make brokerage account list compact (#111)

This commit is contained in:
2026-10-27 05:29:11 +03:00
parent 58b4e12633
commit c24704affa
5 changed files with 165 additions and 195 deletions
@@ -1,61 +1,68 @@
.list { .list {
width: 100%; width: 100%;
margin: 0;
padding: 0;
list-style: none;
} }
.card { .row {
min-width: 0; display: grid;
gap: var(--mantine-spacing-xl); 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); background: var(--mantine-color-white);
border-color: var(--mantine-color-gray-3);
box-shadow: 0 4px 16px rgb(15 23 42 / 3%);
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
transition: transition: background-color 160ms ease, border-color 160ms ease;
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
} }
.card:hover, .row:hover,
.card:focus-visible { .row:focus-visible {
border-color: var(--mantine-color-blue-3); border-color: var(--mantine-color-blue-3);
box-shadow: 0 12px 28px rgb(15 23 42 / 8%); border-left-color: var(--mantine-color-blue-6);
transform: translateY(-3px); background: var(--mantine-color-blue-0);
} }
.card:focus-visible { .row:focus-visible {
outline: 2px solid var(--mantine-color-blue-6); outline: 2px solid var(--mantine-color-blue-6);
outline-offset: 3px; outline-offset: -2px;
} }
.identity { .identity {
grid-area: identity;
min-width: 0; 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 { .name {
margin: 0; margin: 0;
font-size: var(--mantine-h3-font-size); color: var(--mantine-color-blue-9);
line-height: 1.3; font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-md);
overflow-wrap: anywhere; 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 { .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 { .summary {
@@ -63,104 +70,46 @@
} }
.total { .total {
font-size: clamp(1.5rem, 2.8vw, 2.25rem); color: var(--mantine-color-dark-7);
letter-spacing: -0.035em; font-size: var(--mantine-font-size-lg);
line-height: 1.2; line-height: var(--mantine-line-height-md);
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.total + .total { .total + .total {
font-size: var(--mantine-font-size-lg);
font-weight: 500; font-weight: 500;
letter-spacing: 0; }
color: var(--mantine-color-gray-7);
.details {
grid-area: details;
min-width: 0;
} }
.positions { .positions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--mantine-spacing-sm);
margin: 0; margin: 0;
} }
.positions > div { .positions > div {
display: flex; display: flex;
flex-wrap: wrap; gap: var(--mantine-spacing-xs);
align-items: center; align-items: baseline;
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;
} }
.positions dd { .positions dd {
margin: 0; margin: 0;
font-size: 1.75rem; }
font-weight: 700;
font-variant-numeric: tabular-nums; .status {
line-height: 1.25; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.footer { @media (max-width: 48em) {
border-top: 1px solid var(--mantine-color-gray-2); .row {
padding-top: var(--mantine-spacing-md); column-gap: var(--mantine-spacing-sm);
}
.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);
} }
.positions > div { .balance {
flex-direction: column; max-width: 50vw;
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;
} }
} }
@@ -40,7 +40,7 @@ const meta = {
docs: { docs: {
description: { description: {
component: component:
"Карточки счетов WealthLedger со сводкой по валютам и числом наименований акций и облигаций. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.", "Компактные строки счетов WealthLedger: название и отличающийся тип слева, текущая стоимость с двумя знаками справа на desktop и под названием на mobile. Видимы не более двух валют и счётчик остальных, позиции и локальный статус источника. Отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.",
}, },
}, },
}, },
@@ -23,7 +23,10 @@ describe("BrokerageAccountList", () => {
bondPositionCount: 12, bondPositionCount: 12,
stockPositionCount: 5, stockPositionCount: 5,
totalsByCurrency: [ totalsByCurrency: [
{ currency: "RUB", value: "1500" }, {
currency: "RUB",
value: "123456789012345678901.995",
},
{ currency: "USD", value: "20" }, { currency: "USD", value: "20" },
{ currency: "EUR", value: "5" }, { currency: "EUR", value: "5" },
], ],
@@ -33,7 +36,7 @@ describe("BrokerageAccountList", () => {
}, },
{ {
id: "account-2", id: "account-2",
name: "Недоступный", name: "Брокерский счет",
openedOn: null, openedOn: null,
portfolioSummary: null, portfolioSummary: null,
type: "BROKERAGE", type: "BROKERAGE",
@@ -60,24 +63,27 @@ describe("BrokerageAccountList", () => {
"/accounts/account-1", "/accounts/account-1",
); );
expect(screen.getAllByText("Брокерский счет")).toHaveLength(3); expect(screen.getAllByText("Брокерский счет")).toHaveLength(3);
expect(screen.getByText(/^Открыт /)).toBeInTheDocument(); expect(screen.getByText("123 456 789 012 345 678 902,00 ₽"))
expect(screen.getByText("1 500 ₽")).toBeInTheDocument(); .toBeInTheDocument();
expect(screen.getByText("20 USD")).toBeInTheDocument(); expect(screen.getByText("20,00 USD")).toBeInTheDocument();
expect(screen.getByText("Еще 1 валюта")).toBeInTheDocument(); expect(screen.getByText("Еще 1 валюта")).toBeInTheDocument();
expect(screen.getByText("Сводка временно недоступна")).toBeInTheDocument(); expect(screen.getByText("Сводка временно недоступна")).toBeInTheDocument();
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument(); expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getAllByText("Открыть счет")).toHaveLength(3);
const available = within(screen.getByRole("link", { name: /Основной/ })); const available = within(screen.getByRole("link", { name: /Основной/ }));
expect(available.getAllByRole("definition")[0]).toHaveTextContent("5"); expect(available.getAllByRole("definition")[0]).toHaveTextContent("5");
expect(available.getAllByRole("definition")[1]).toHaveTextContent("12"); expect(available.getAllByRole("definition")[1]).toHaveTextContent("12");
const unvalued = within(screen.getByRole("link", { name: /Пустой/ })); const unvalued = within(screen.getByRole("link", { name: /Пустой/ }));
expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent("2"); expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent("2");
expect(unvalued.getAllByRole("definition")[1]).toHaveTextContent("0"); expect(unvalued.getAllByRole("definition")[1]).toHaveTextContent("0");
expect(
within(screen.getByRole("link", { name: /Недоступный/ })).queryByText( const duplicatedName = within(screen.getByRole("link", {
"Акции", name: /Сводка временно недоступна/,
), }));
).not.toBeInTheDocument();
expect(duplicatedName.getAllByText("Брокерский счет")).toHaveLength(1);
expect(duplicatedName.queryByText("Акции")).not.toBeInTheDocument();
}); });
}); });
@@ -1,11 +1,10 @@
import type { FC } from "react"; import type { FC } from "react";
import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { Group, Stack, Text } from "@mantine/core";
import { WalletIcon } from "@wealthledger/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatDate, formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui"; import { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageAccountCard } from "../../model/display"; import type { BrokerageAccountCard } from "../../model/display";
@@ -39,7 +38,7 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
<Stack className={classes.summary} gap={4}> <Stack className={classes.summary} gap={4}>
{summary.totals.map(total => ( {summary.totals.map(total => (
<Text className={classes.total} fw={700} key={total.currency}> <Text className={classes.total} fw={700} key={total.currency}>
{formatMoney(total)} {formatMoney(total, { withRoundedCents: true })}
</Text> </Text>
))} ))}
@@ -92,81 +91,75 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<SimpleGrid className={classes.list} cols={1} spacing="lg"> <Stack className={classes.list} component="ul" gap="sm">
{accounts.map(({ account, positionCounts, source, summary }) => ( {accounts.map(({ account, positionCounts, source, summary }) => {
<Card const accountType = t(accountTypeKey(account.type));
className={classes.card}
component={Link}
key={account.id}
padding="xl"
radius="lg"
to={`/accounts/${account.id}`}
withBorder
>
<Stack className={classes.identity} gap="sm">
<Group gap="sm" justify="space-between" wrap="nowrap">
<Text className={classes.kicker} fw={700} size="xs">
{t(accountTypeKey(account.type))}
</Text>
<WalletIcon className={classes.accountIcon} /> return (
</Group> <li key={account.id}>
<Link className={classes.row} to={`/accounts/${account.id}`}>
<Stack className={classes.identity} gap={2}>
<Text className={classes.name} component="h2" fw={600}>
{account.name}
</Text>
<Text className={classes.name} component="h2" fw={700}> {account.name !== accountType && (
{account.name} <Text className={classes.accountType} size="xs">
</Text> {accountType}
</Stack> </Text>
)}
</Stack>
<Stack className={classes.balance} gap="xs"> <Stack className={classes.balance} gap={2}>
<Text c="dimmed" size="sm"> <Text c="dimmed" size="xs">
{t("accounts.currentValue")} {t("accounts.currentValue")}
</Text> </Text>
<BrokerageAccountSummary summary={summary} /> <BrokerageAccountSummary summary={summary} />
</Stack>
<BrokerageAccountSourceStatus source={source} /> <Group className={classes.details} gap="md">
</Stack> {!!positionCounts && (
<Group
className={classes.positions}
component="dl"
gap="md"
>
<div>
<Text c="dimmed" component="dt" size="sm">
{t("accounts.stocks")}
</Text>
{!!positionCounts && ( <Text component="dd" fw={600} size="sm">
<dl className={classes.positions}> {new Intl.NumberFormat("ru-RU").format(
<div className={classes.stocks}> positionCounts.stocks,
<dt>{t("accounts.stocks")}</dt> )}
</Text>
</div>
<dd> <div>
{new Intl.NumberFormat("ru-RU").format(positionCounts.stocks)} <Text c="dimmed" component="dt" size="sm">
</dd> {t("accounts.bonds")}
</div> </Text>
<div className={classes.bonds}> <Text component="dd" fw={600} size="sm">
<dt>{t("accounts.bonds")}</dt> {new Intl.NumberFormat("ru-RU").format(
positionCounts.bonds,
)}
</Text>
</div>
</Group>
)}
<dd> <Stack className={classes.status} gap={2}>
{new Intl.NumberFormat("ru-RU").format(positionCounts.bonds)} <BrokerageAccountSourceStatus source={source} />
</dd> </Stack>
</div> </Group>
</dl> </Link>
)} </li>
);
<Group className={classes.footer} gap="sm" justify="space-between"> })}
{!!account.openedOn && ( </Stack>
<Text c="dimmed" size="xs">
{t("accounts.openedOn", {
date: formatDate(account.openedOn),
})}
</Text>
)}
<Text className={classes.open} component="span" fw={600} size="sm">
{t("accounts.openAccount")}
<span aria-hidden="true" className={classes.arrow}>
→
</span>
</Text>
</Group>
</Card>
))}
</SimpleGrid>
); );
}; };
+25 -3
View File
@@ -2,6 +2,7 @@ const DECIMAL_PART_COUNT = 2;
const CENTS_PRECISION = 2; const CENTS_PRECISION = 2;
type FormatMoneyOptions = { type FormatMoneyOptions = {
withRoundedCents?: boolean;
withZeroCents?: boolean; withZeroCents?: boolean;
}; };
@@ -39,16 +40,20 @@ function formatMoney(
money: Money money: Money
| null | null
| undefined, | undefined,
{ withZeroCents = false }: FormatMoneyOptions = {}, { withRoundedCents = false, withZeroCents = false }: FormatMoneyOptions = {},
): string { ): string {
if (!money) return "—"; 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, DECIMAL_PART_COUNT,
); );
const normalizedFraction = withZeroCents const normalizedFraction = withRoundedCents || withZeroCents
? fractionPart.padEnd(CENTS_PRECISION, "0") ? fractionPart.padEnd(CENTS_PRECISION, "0")
: fractionPart; : fractionPart;
@@ -61,6 +66,23 @@ function formatMoney(
return `${formatDecimal(decimal)} ${currency}`; 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 берёт часовой пояс браузера пользователя. // Без timeZone Intl берёт часовой пояс браузера пользователя.
const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", { const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short", dateStyle: "short",