diff --git a/scripts/lazy-route-bundle-baseline.json b/scripts/lazy-route-bundle-baseline.json
index 9c3cff3..c0de285 100644
--- a/scripts/lazy-route-bundle-baseline.json
+++ b/scripts/lazy-route-bundle-baseline.json
@@ -1,11 +1,11 @@
{
"src/app/routes/ProtectedLayout.tsx": 3057,
- "src/pages/account-events/index.ts": 4364,
- "src/pages/account-operations/index.ts": 13854,
- "src/pages/account/index.ts": 32466,
- "src/pages/accounts/index.ts": 6145,
+ "src/pages/account-events/index.ts": 4331,
+ "src/pages/account-operations/index.ts": 13473,
+ "src/pages/account/index.ts": 31277,
+ "src/pages/accounts/index.ts": 6205,
"src/pages/login/index.ts": 662,
"src/pages/not-found/index.ts": 877,
"src/pages/register/index.ts": 668,
- "src/pages/settings/index.ts": 38979
+ "src/pages/settings/index.ts": 38980
}
diff --git a/src/entities/brokerage-account/index.ts b/src/entities/brokerage-account/index.ts
index 4e43036..fb1163e 100644
--- a/src/entities/brokerage-account/index.ts
+++ b/src/entities/brokerage-account/index.ts
@@ -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";
diff --git a/src/entities/brokerage-account/model/display.ts b/src/entities/brokerage-account/model/display.ts
index b2c5d83..61c45aa 100644
--- a/src/entities/brokerage-account/model/display.ts
+++ b/src/entities/brokerage-account/model/display.ts
@@ -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,
+};
diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.module.css b/src/entities/brokerage-account/ui/BrokerageAccountList.module.css
index 6e06345..b1b2578 100644
--- a/src/entities/brokerage-account/ui/BrokerageAccountList.module.css
+++ b/src/entities/brokerage-account/ui/BrokerageAccountList.module.css
@@ -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;
+ }
+}
diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx
index 8a13a49..b8ce98c 100644
--- a/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx
+++ b/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx
@@ -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", () => {
,
@@ -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);
});
});
diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList.tsx
index 76d4c53..abfb332 100644
--- a/src/entities/brokerage-account/ui/BrokerageAccountList.tsx
+++ b/src/entities/brokerage-account/ui/BrokerageAccountList.tsx
@@ -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 = (props) => {
+ const { summary } = props;
+ const { t } = useTranslation();
+ if (summary.state === "unavailable")
+ return (
+
+ {t("accounts.summaryUnavailable")}
+
+ );
+ if (summary.state === "empty")
+ return (
+
+ {t("accounts.summaryEmpty")}
+
+ );
+
+ return (
+
+ {summary.totals.map((total) => (
+
+ {formatMoney(total)}
+
+ ))}
+ {summary.remainingCurrencyCount ? (
+
+ {t("accounts.remainingCurrencies", {
+ count: summary.remainingCurrencyCount,
+ })}
+
+ ) : null}
+
+ );
+};
const BrokerageAccountList: FC = (props) => {
const { accounts } = props;
const { t } = useTranslation();
return (
-
- {accounts.map((account) => (
+
+ {accounts.map(({ account, summary }) => (
= (props) => {
{account.name}
+
{account.openedOn ? (
{t("accounts.openedOn", {
@@ -41,10 +81,13 @@ const BrokerageAccountList: FC = (props) => {
})}
) : null}
+
+ {t("accounts.openAccount")}
+
))}
-
+
);
};
diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx
index a6295bb..fc6f0f8 100644
--- a/src/pages/account/ui/AccountPage.test.tsx
+++ b/src/pages/account/ui/AccountPage.test.tsx
@@ -374,6 +374,7 @@ describe("AccountPage states", () => {
name: "Основной",
type: "BROKERAGE",
openedOn: null,
+ portfolioSummary: null,
}}
/>,
);
diff --git a/src/pages/accounts/ui/AccountsPage.view.tsx b/src/pages/accounts/ui/AccountsPage.view.tsx
index 3e56022..2847832 100644
--- a/src/pages/accounts/ui/AccountsPage.view.tsx
+++ b/src/pages/accounts/ui/AccountsPage.view.tsx
@@ -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;
diff --git a/src/pages/accounts/ui/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage.vm.ts
index 9274128..d27243b 100644
--- a/src/pages/accounts/ui/AccountsPage.vm.ts
+++ b/src/pages/accounts/ui/AccountsPage.vm.ts
@@ -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(),
diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts
index efe5ba3..7bffb18 100644
--- a/src/shared/api/generated/schema.d.ts
+++ b/src/shared/api/generated/schema.d.ts
@@ -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;
diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json
index c00765b..6f9ac72 100644
--- a/src/shared/api/spec/openapi.json
+++ b/src/shared/api/spec/openapi.json
@@ -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": {
diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts
index 91f822e..47111ec 100644
--- a/src/shared/i18n/locales/ru.ts
+++ b/src/shared/i18n/locales/ru.ts
@@ -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: