feat(accounts): enrich brokerage account list

This commit is contained in:
2026-08-26 20:57:55 +03:00
parent 488ccad540
commit fd93133cfc
12 changed files with 230 additions and 44 deletions
+6 -1
View File
@@ -3,5 +3,10 @@ export {
brokerageAccountsQueryKey,
useBrokerageAccounts,
} from "./api/useBrokerageAccounts";
export { accountTypeKey, errorCode } from "./model/display";
export {
accountTypeKey,
type BrokerageAccountCard,
brokerageAccountCards,
errorCode,
} from "./model/display";
export { BrokerageAccountList } from "./ui/BrokerageAccountList";
@@ -1,5 +1,17 @@
import type { BrokerageAccount } from "../api/useBrokerageAccounts";
type Money = NonNullable<
BrokerageAccount["portfolioSummary"]
>["totalsByCurrency"][number];
type BrokerageAccountCard = {
account: BrokerageAccount;
summary:
| { state: "unavailable" }
| { state: "empty" }
| { state: "available"; totals: Money[]; remainingCurrencyCount: number };
};
function accountTypeKey(type: BrokerageAccount["type"]) {
return `accounts.types.${type}` as const;
}
@@ -10,4 +22,31 @@ function errorCode(error: unknown): string | undefined {
: undefined;
}
export { accountTypeKey, errorCode };
function brokerageAccountCards(
accounts: BrokerageAccount[],
): BrokerageAccountCard[] {
return accounts.map((account) => {
const summary = account.portfolioSummary;
if (!summary) return { account, summary: { state: "unavailable" } };
if (!summary.totalsByCurrency.length)
return { account, summary: { state: "empty" } };
return {
account,
summary: {
state: "available",
totals: summary.totalsByCurrency.slice(0, 2),
remainingCurrencyCount: Math.max(
summary.totalsByCurrency.length - 2,
0,
),
},
};
});
}
export {
accountTypeKey,
type BrokerageAccountCard,
brokerageAccountCards,
errorCode,
};
@@ -1,6 +1,14 @@
.list {
width: 100%;
}
.card {
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 72%);
border-color: #bfdbfe;
background: linear-gradient(
135deg,
var(--mantine-color-blue-0) 0%,
var(--mantine-color-body) 72%
);
border-color: var(--mantine-color-blue-2);
box-shadow: 0 10px 28px rgb(15 23 42 / 6%);
color: inherit;
text-decoration: none;
@@ -16,7 +24,30 @@
}
.kicker {
color: #2563eb;
color: var(--mantine-color-blue-7);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.summary {
font-variant-numeric: tabular-nums;
}
.total {
white-space: nowrap;
}
.open {
color: var(--mantine-color-blue-7);
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card:hover,
.card:focus-visible {
transform: none;
}
}
@@ -2,6 +2,7 @@ import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { brokerageAccountCards } from "../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList";
describe("BrokerageAccountList", () => {
@@ -10,14 +11,35 @@ describe("BrokerageAccountList", () => {
<MantineProvider>
<MemoryRouter>
<BrokerageAccountList
accounts={[
accounts={brokerageAccountCards([
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
portfolioSummary: {
totalsByCurrency: [
{ currency: "RUB", value: "1500" },
{ currency: "USD", value: "20" },
{ currency: "EUR", value: "5" },
],
},
},
]}
{
id: "account-2",
name: "Недоступный",
type: "BROKERAGE",
openedOn: null,
portfolioSummary: null,
},
{
id: "account-3",
name: "Пустой",
type: "BROKERAGE",
openedOn: null,
portfolioSummary: { totalsByCurrency: [] },
},
])}
/>
</MemoryRouter>
</MantineProvider>,
@@ -27,7 +49,13 @@ describe("BrokerageAccountList", () => {
"href",
"/accounts/account-1",
);
expect(screen.getByText("Брокерский счет")).toBeInTheDocument();
expect(screen.getByText(/Открыт/)).toBeInTheDocument();
expect(screen.getAllByText("Брокерский счет")).toHaveLength(3);
expect(screen.getByText(/^Открыт /)).toBeInTheDocument();
expect(screen.getByText("1 500 ₽")).toBeInTheDocument();
expect(screen.getByText("20 USD")).toBeInTheDocument();
expect(screen.getByText("Еще 1 валюта")).toBeInTheDocument();
expect(screen.getByText("Сводка временно недоступна")).toBeInTheDocument();
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getAllByText("Открыть счет")).toHaveLength(3);
});
});
@@ -1,20 +1,59 @@
import { Card, SimpleGrid, Stack, Text } from "@mantine/core";
import { Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageAccount } from "../api/useBrokerageAccounts";
import { formatMoney } from "@/entities/portfolio";
import type { BrokerageAccountCard } from "../model/display";
import { accountTypeKey } from "../model/display";
import classes from "./BrokerageAccountList.module.css";
type BrokerageAccountListProps = { accounts: BrokerageAccount[] };
type BrokerageAccountListProps = { accounts: BrokerageAccountCard[] };
type BrokerageAccountSummaryProps = {
summary: BrokerageAccountCard["summary"];
};
const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
const { summary } = props;
const { t } = useTranslation();
if (summary.state === "unavailable")
return (
<Text c="dimmed" size="sm">
{t("accounts.summaryUnavailable")}
</Text>
);
if (summary.state === "empty")
return (
<Text c="dimmed" size="sm">
{t("accounts.summaryEmpty")}
</Text>
);
return (
<Group className={classes.summary} gap="sm" wrap="wrap">
{summary.totals.map((total) => (
<Text className={classes.total} fw={700} key={total.currency}>
{formatMoney(total)}
</Text>
))}
{summary.remainingCurrencyCount ? (
<Text c="dimmed" size="sm">
{t("accounts.remainingCurrencies", {
count: summary.remainingCurrencyCount,
})}
</Text>
) : null}
</Group>
);
};
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { accounts } = props;
const { t } = useTranslation();
return (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{accounts.map((account) => (
<Stack className={classes.list} gap="md">
{accounts.map(({ account, summary }) => (
<Card
component={Link}
to={`/accounts/${account.id}`}
@@ -31,6 +70,7 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
<Text fw={800} size="lg">
{account.name}
</Text>
<BrokerageAccountSummary summary={summary} />
{account.openedOn ? (
<Text c="dimmed" size="sm">
{t("accounts.openedOn", {
@@ -41,10 +81,13 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
})}
</Text>
) : null}
<Text className={classes.open} size="sm" fw={700}>
{t("accounts.openAccount")}
</Text>
</Stack>
</Card>
))}
</SimpleGrid>
</Stack>
);
};
@@ -374,6 +374,7 @@ describe("AccountPage states", () => {
name: "Основной",
type: "BROKERAGE",
openedOn: null,
portfolioSummary: null,
}}
/>,
);
+2 -2
View File
@@ -10,7 +10,7 @@ import {
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { BrokerageAccountCard } from "@/entities/brokerage-account";
import { BrokerageAccountList } from "@/entities/brokerage-account";
import { StateView } from "@/shared/ui";
@@ -26,7 +26,7 @@ type AccountsPageEmptyProps = {
};
type AccountsPageLoadedProps = {
accounts: BrokerageAccount[];
accounts: BrokerageAccountCard[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
+6 -2
View File
@@ -1,4 +1,8 @@
import { errorCode, useBrokerageAccounts } from "@/entities/brokerage-account";
import {
brokerageAccountCards,
errorCode,
useBrokerageAccounts,
} from "@/entities/brokerage-account";
function useAccountsPageViewModel() {
const accounts = useBrokerageAccounts();
@@ -20,7 +24,7 @@ function useAccountsPageViewModel() {
};
return {
state: "loaded" as const,
accounts: accounts.data,
accounts: brokerageAccountCards(accounts.data),
refreshing: accounts.isFetching,
refreshError: accounts.isError,
retry: () => accounts.refetch(),
+8 -4
View File
@@ -205,6 +205,13 @@ export interface components {
PutTbankConnectionDto: {
token: string;
};
MoneyDto: {
currency: string;
value: string;
};
PortfolioSummaryDto: {
totalsByCurrency: components["schemas"]["MoneyDto"][];
};
BrokerageAccountDto: {
id: string;
name: string;
@@ -212,10 +219,7 @@ export interface components {
type: "BROKERAGE" | "INDIVIDUAL_INVESTMENT" | "INVEST_BOX" | "MONEY_MARKET_FUND";
/** Format: date */
openedOn: string | null;
};
MoneyDto: {
currency: string;
value: string;
portfolioSummary: components["schemas"]["PortfolioSummaryDto"] | null;
};
BrokerageOperationDto: {
id: string;
+40 -16
View File
@@ -1109,6 +1109,35 @@
"token"
]
},
"MoneyDto": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": [
"currency",
"value"
]
},
"PortfolioSummaryDto": {
"type": "object",
"properties": {
"totalsByCurrency": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MoneyDto"
}
}
},
"required": [
"totalsByCurrency"
]
},
"BrokerageAccountDto": {
"type": "object",
"properties": {
@@ -1131,28 +1160,23 @@
"type": "string",
"nullable": true,
"format": "date"
},
"portfolioSummary": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/PortfolioSummaryDto"
}
]
}
},
"required": [
"id",
"name",
"type",
"openedOn"
]
},
"MoneyDto": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": [
"currency",
"value"
"openedOn",
"portfolioSummary"
]
},
"BrokerageOperationDto": {
+7
View File
@@ -32,6 +32,13 @@ const ru = {
emptyText:
"Откройте инвестиционный счет в T-Bank, чтобы он появился здесь.",
openedOn: "Открыт {{date}}",
openAccount: "Открыть счет",
summaryUnavailable: "Сводка временно недоступна",
summaryEmpty: "Нет текущих сумм",
remainingCurrencies_one: "Еще {{count}} валюта",
remainingCurrencies_few: "Еще {{count}} валюты",
remainingCurrencies_many: "Еще {{count}} валют",
remainingCurrencies_other: "Еще {{count}} валюты",
openSettings: "Открыть настройки T-Bank",
notConnectedTitle: "Подключите T-Bank",
notConnectedText: