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
+5 -5
View File
@@ -1,11 +1,11 @@
{ {
"src/app/routes/ProtectedLayout.tsx": 3057, "src/app/routes/ProtectedLayout.tsx": 3057,
"src/pages/account-events/index.ts": 4364, "src/pages/account-events/index.ts": 4331,
"src/pages/account-operations/index.ts": 13854, "src/pages/account-operations/index.ts": 13473,
"src/pages/account/index.ts": 32466, "src/pages/account/index.ts": 31277,
"src/pages/accounts/index.ts": 6145, "src/pages/accounts/index.ts": 6205,
"src/pages/login/index.ts": 662, "src/pages/login/index.ts": 662,
"src/pages/not-found/index.ts": 877, "src/pages/not-found/index.ts": 877,
"src/pages/register/index.ts": 668, "src/pages/register/index.ts": 668,
"src/pages/settings/index.ts": 38979 "src/pages/settings/index.ts": 38980
} }
+6 -1
View File
@@ -3,5 +3,10 @@ export {
brokerageAccountsQueryKey, brokerageAccountsQueryKey,
useBrokerageAccounts, useBrokerageAccounts,
} from "./api/useBrokerageAccounts"; } from "./api/useBrokerageAccounts";
export { accountTypeKey, errorCode } from "./model/display"; export {
accountTypeKey,
type BrokerageAccountCard,
brokerageAccountCards,
errorCode,
} from "./model/display";
export { BrokerageAccountList } from "./ui/BrokerageAccountList"; export { BrokerageAccountList } from "./ui/BrokerageAccountList";
@@ -1,5 +1,17 @@
import type { BrokerageAccount } from "../api/useBrokerageAccounts"; 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"]) { function accountTypeKey(type: BrokerageAccount["type"]) {
return `accounts.types.${type}` as const; return `accounts.types.${type}` as const;
} }
@@ -10,4 +22,31 @@ function errorCode(error: unknown): string | undefined {
: 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 { .card {
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 72%); background: linear-gradient(
border-color: #bfdbfe; 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%); box-shadow: 0 10px 28px rgb(15 23 42 / 6%);
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
@@ -16,7 +24,30 @@
} }
.kicker { .kicker {
color: #2563eb; color: var(--mantine-color-blue-7);
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-transform: uppercase; 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 { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { brokerageAccountCards } from "../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList"; import { BrokerageAccountList } from "./BrokerageAccountList";
describe("BrokerageAccountList", () => { describe("BrokerageAccountList", () => {
@@ -10,14 +11,35 @@ describe("BrokerageAccountList", () => {
<MantineProvider> <MantineProvider>
<MemoryRouter> <MemoryRouter>
<BrokerageAccountList <BrokerageAccountList
accounts={[ accounts={brokerageAccountCards([
{ {
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
type: "BROKERAGE", type: "BROKERAGE",
openedOn: "2024-08-13", 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> </MemoryRouter>
</MantineProvider>, </MantineProvider>,
@@ -27,7 +49,13 @@ describe("BrokerageAccountList", () => {
"href", "href",
"/accounts/account-1", "/accounts/account-1",
); );
expect(screen.getByText("Брокерский счет")).toBeInTheDocument(); expect(screen.getAllByText("Брокерский счет")).toHaveLength(3);
expect(screen.getByText(/Открыт/)).toBeInTheDocument(); 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 type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; 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 { accountTypeKey } from "../model/display";
import classes from "./BrokerageAccountList.module.css"; 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 BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { accounts } = props; const { accounts } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md"> <Stack className={classes.list} gap="md">
{accounts.map((account) => ( {accounts.map(({ account, summary }) => (
<Card <Card
component={Link} component={Link}
to={`/accounts/${account.id}`} to={`/accounts/${account.id}`}
@@ -31,6 +70,7 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
<Text fw={800} size="lg"> <Text fw={800} size="lg">
{account.name} {account.name}
</Text> </Text>
<BrokerageAccountSummary summary={summary} />
{account.openedOn ? ( {account.openedOn ? (
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("accounts.openedOn", { {t("accounts.openedOn", {
@@ -41,10 +81,13 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
})} })}
</Text> </Text>
) : null} ) : null}
<Text className={classes.open} size="sm" fw={700}>
{t("accounts.openAccount")}
</Text>
</Stack> </Stack>
</Card> </Card>
))} ))}
</SimpleGrid> </Stack>
); );
}; };
@@ -374,6 +374,7 @@ describe("AccountPage states", () => {
name: "Основной", name: "Основной",
type: "BROKERAGE", type: "BROKERAGE",
openedOn: null, openedOn: null,
portfolioSummary: null,
}} }}
/>, />,
); );
+2 -2
View File
@@ -10,7 +10,7 @@ import {
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; 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 { BrokerageAccountList } from "@/entities/brokerage-account";
import { StateView } from "@/shared/ui"; import { StateView } from "@/shared/ui";
@@ -26,7 +26,7 @@ type AccountsPageEmptyProps = {
}; };
type AccountsPageLoadedProps = { type AccountsPageLoadedProps = {
accounts: BrokerageAccount[]; accounts: BrokerageAccountCard[];
refreshing?: boolean; refreshing?: boolean;
refreshError?: boolean; refreshError?: boolean;
retry?: () => void; 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() { function useAccountsPageViewModel() {
const accounts = useBrokerageAccounts(); const accounts = useBrokerageAccounts();
@@ -20,7 +24,7 @@ function useAccountsPageViewModel() {
}; };
return { return {
state: "loaded" as const, state: "loaded" as const,
accounts: accounts.data, accounts: brokerageAccountCards(accounts.data),
refreshing: accounts.isFetching, refreshing: accounts.isFetching,
refreshError: accounts.isError, refreshError: accounts.isError,
retry: () => accounts.refetch(), retry: () => accounts.refetch(),
+8 -4
View File
@@ -205,6 +205,13 @@ export interface components {
PutTbankConnectionDto: { PutTbankConnectionDto: {
token: string; token: string;
}; };
MoneyDto: {
currency: string;
value: string;
};
PortfolioSummaryDto: {
totalsByCurrency: components["schemas"]["MoneyDto"][];
};
BrokerageAccountDto: { BrokerageAccountDto: {
id: string; id: string;
name: string; name: string;
@@ -212,10 +219,7 @@ export interface components {
type: "BROKERAGE" | "INDIVIDUAL_INVESTMENT" | "INVEST_BOX" | "MONEY_MARKET_FUND"; type: "BROKERAGE" | "INDIVIDUAL_INVESTMENT" | "INVEST_BOX" | "MONEY_MARKET_FUND";
/** Format: date */ /** Format: date */
openedOn: string | null; openedOn: string | null;
}; portfolioSummary: components["schemas"]["PortfolioSummaryDto"] | null;
MoneyDto: {
currency: string;
value: string;
}; };
BrokerageOperationDto: { BrokerageOperationDto: {
id: string; id: string;
+40 -16
View File
@@ -1109,6 +1109,35 @@
"token" "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": { "BrokerageAccountDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1131,28 +1160,23 @@
"type": "string", "type": "string",
"nullable": true, "nullable": true,
"format": "date" "format": "date"
},
"portfolioSummary": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/PortfolioSummaryDto"
}
]
} }
}, },
"required": [ "required": [
"id", "id",
"name", "name",
"type", "type",
"openedOn" "openedOn",
] "portfolioSummary"
},
"MoneyDto": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": [
"currency",
"value"
] ]
}, },
"BrokerageOperationDto": { "BrokerageOperationDto": {
+7
View File
@@ -32,6 +32,13 @@ const ru = {
emptyText: emptyText:
"Откройте инвестиционный счет в T-Bank, чтобы он появился здесь.", "Откройте инвестиционный счет в T-Bank, чтобы он появился здесь.",
openedOn: "Открыт {{date}}", openedOn: "Открыт {{date}}",
openAccount: "Открыть счет",
summaryUnavailable: "Сводка временно недоступна",
summaryEmpty: "Нет текущих сумм",
remainingCurrencies_one: "Еще {{count}} валюта",
remainingCurrencies_few: "Еще {{count}} валюты",
remainingCurrencies_many: "Еще {{count}} валют",
remainingCurrencies_other: "Еще {{count}} валюты",
openSettings: "Открыть настройки T-Bank", openSettings: "Открыть настройки T-Bank",
notConnectedTitle: "Подключите T-Bank", notConnectedTitle: "Подключите T-Bank",
notConnectedText: notConnectedText: