feat: make brokerage account list compact (#111)
This commit is contained in:
+59
-110
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Карточки счетов WealthLedger со сводкой по валютам и числом наименований акций и облигаций. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.",
|
||||
"Компактные строки счетов WealthLedger: название и отличающийся тип слева, текущая стоимость с двумя знаками справа на desktop и под названием на mobile. Видимы не более двух валют и счётчик остальных, позиции и локальный статус источника. Отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+17
-11
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<BrokerageAccountSummaryProps> = (props) => {
|
||||
<Stack className={classes.summary} gap={4}>
|
||||
{summary.totals.map(total => (
|
||||
<Text className={classes.total} fw={700} key={total.currency}>
|
||||
{formatMoney(total)}
|
||||
{formatMoney(total, { withRoundedCents: true })}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
@@ -92,81 +91,75 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SimpleGrid className={classes.list} cols={1} spacing="lg">
|
||||
{accounts.map(({ account, positionCounts, source, summary }) => (
|
||||
<Card
|
||||
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>
|
||||
<Stack className={classes.list} component="ul" gap="sm">
|
||||
{accounts.map(({ account, positionCounts, source, summary }) => {
|
||||
const accountType = t(accountTypeKey(account.type));
|
||||
|
||||
<WalletIcon className={classes.accountIcon} />
|
||||
</Group>
|
||||
return (
|
||||
<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}
|
||||
</Text>
|
||||
</Stack>
|
||||
{account.name !== accountType && (
|
||||
<Text className={classes.accountType} size="xs">
|
||||
{accountType}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack className={classes.balance} gap="xs">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("accounts.currentValue")}
|
||||
</Text>
|
||||
<Stack className={classes.balance} gap={2}>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("accounts.currentValue")}
|
||||
</Text>
|
||||
|
||||
<BrokerageAccountSummary summary={summary} />
|
||||
<BrokerageAccountSummary summary={summary} />
|
||||
</Stack>
|
||||
|
||||
<BrokerageAccountSourceStatus source={source} />
|
||||
</Stack>
|
||||
<Group className={classes.details} gap="md">
|
||||
{!!positionCounts && (
|
||||
<Group
|
||||
className={classes.positions}
|
||||
component="dl"
|
||||
gap="md"
|
||||
>
|
||||
<div>
|
||||
<Text c="dimmed" component="dt" size="sm">
|
||||
{t("accounts.stocks")}
|
||||
</Text>
|
||||
|
||||
{!!positionCounts && (
|
||||
<dl className={classes.positions}>
|
||||
<div className={classes.stocks}>
|
||||
<dt>{t("accounts.stocks")}</dt>
|
||||
<Text component="dd" fw={600} size="sm">
|
||||
{new Intl.NumberFormat("ru-RU").format(
|
||||
positionCounts.stocks,
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<dd>
|
||||
{new Intl.NumberFormat("ru-RU").format(positionCounts.stocks)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<Text c="dimmed" component="dt" size="sm">
|
||||
{t("accounts.bonds")}
|
||||
</Text>
|
||||
|
||||
<div className={classes.bonds}>
|
||||
<dt>{t("accounts.bonds")}</dt>
|
||||
<Text component="dd" fw={600} size="sm">
|
||||
{new Intl.NumberFormat("ru-RU").format(
|
||||
positionCounts.bonds,
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<dd>
|
||||
{new Intl.NumberFormat("ru-RU").format(positionCounts.bonds)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<Group className={classes.footer} gap="sm" justify="space-between">
|
||||
{!!account.openedOn && (
|
||||
<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>
|
||||
<Stack className={classes.status} gap={2}>
|
||||
<BrokerageAccountSourceStatus source={source} />
|
||||
</Stack>
|
||||
</Group>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user