refactor: enforce account page view model boundary
This commit is contained in:
@@ -30,7 +30,7 @@ Gate non-mutating и проверяет runtime, OpenAPI snapshot, hygiene, stri
|
|||||||
- Code style и i18n: [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md), исполняются Biome и TypeScript.
|
- Code style и i18n: [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md), исполняются Biome и TypeScript.
|
||||||
- FSD и public API: [`docs/STRUCTURE.md`](docs/STRUCTURE.md), исполняются dependency-cruiser и Knip; направление `app → pages → features → entities → shared`.
|
- FSD и public API: [`docs/STRUCTURE.md`](docs/STRUCTURE.md), исполняются dependency-cruiser и Knip; направление `app → pages → features → entities → shared`.
|
||||||
- HTTP и types: [`docs/API-LAYER.md`](docs/API-LAYER.md); только typed client и generated OpenAPI `paths[...]`, без ручных transport DTO и прямых T-Bank calls.
|
- HTTP и types: [`docs/API-LAYER.md`](docs/API-LAYER.md); только typed client и generated OpenAPI `paths[...]`, без ручных transport DTO и прямых T-Bank calls.
|
||||||
- UI states: [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md); route runtime failures обрабатывает единый route-keyed ErrorBoundary, expected API states остаются локальными.
|
- UI states и VM/view boundary: [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md); container вызывает page VM hook, VM владеет data/state/derived presentation data, `*.view.tsx` получает props и рендерит. View files дробить по semantic responsibility, когда section/state можно назвать отдельно.
|
||||||
- Тесты: [`docs/TESTING.md`](docs/TESTING.md); новый тест обязателен по risk trigger, а не из-за появления файла или coverage процента.
|
- Тесты: [`docs/TESTING.md`](docs/TESTING.md); новый тест обязателен по risk trigger, а не из-за появления файла или coverage процента.
|
||||||
|
|
||||||
Непроверяемые эстетические предпочтения считаются guidance. Не добавлять Storybook, Playwright, CI, browser MSW worker или mock development mode без утверждённого change.
|
Непроверяемые эстетические предпочтения считаются guidance. Не добавлять Storybook, Playwright, CI, browser MSW worker или mock development mode без утверждённого change.
|
||||||
|
|||||||
+7
-3
@@ -13,8 +13,12 @@
|
|||||||
|
|
||||||
## File roles
|
## File roles
|
||||||
|
|
||||||
- `Component.tsx` - контейнер или публичный компонент.
|
- `Component.tsx` - контейнер или публичный компонент; для data-driven pages вызывает page VM hook и выбирает top-level state view.
|
||||||
- `Component.vm.ts` - hook с данными и действиями.
|
- `Component.vm.ts` - hook с данными, действиями, state selection и derived presentation data.
|
||||||
- `Component.view.tsx` - чистое представление.
|
- `Component.view.tsx` - чистое представление: получает props, рендерит JSX и не вызывает data/auth/router/navigation/VM hooks.
|
||||||
- `Component.types.ts` - props и локальные типы.
|
- `Component.types.ts` - props и локальные типы.
|
||||||
- `Component.module.css` - локальные стили.
|
- `Component.module.css` - локальные стили.
|
||||||
|
|
||||||
|
Большие view files дробить по ответственности. Если section/state можно
|
||||||
|
самостоятельно назвать, вынести его в соседний presentation component вместо
|
||||||
|
накопления смысловой нагрузки в одном файле.
|
||||||
|
|||||||
+26
-1
@@ -6,4 +6,29 @@
|
|||||||
|
|
||||||
Запросные экраны без успешных данных выбирают состояние в порядке loading, error, empty, loaded. Если query уже содержит данные, они остаются видимыми при фоновом refetch и его ошибке: рядом показываются неблокирующий progress и локальное предупреждение с retry. Такое состояние называется Client Cached Snapshot; оно не заменяет доменное server-side stale/fallback состояние.
|
Запросные экраны без успешных данных выбирают состояние в порядке loading, error, empty, loaded. Если query уже содержит данные, они остаются видимыми при фоновом refetch и его ошибке: рядом показываются неблокирующий progress и локальное предупреждение с retry. Такое состояние называется Client Cached Snapshot; оно не заменяет доменное server-side stale/fallback состояние.
|
||||||
|
|
||||||
Для finance-экранов container вызывает query hooks, VM выбирает состояние и передает только presentation props в Loading/Error/Empty/Loaded view. Known API errors, retry и stale snapshot остаются local views, а непойманные route errors обрабатывает общий route-keyed ErrorBoundary.
|
Для finance-экранов используется граница `container -> ViewModel -> view`.
|
||||||
|
`*.tsx` без суффикса — container: он вызывает единственный page VM hook и
|
||||||
|
выбирает top-level Loading/Error/Empty/Loaded view. `*.vm.ts` владеет data
|
||||||
|
fetching, auth/route inputs, state selection, retry callbacks, domain grouping
|
||||||
|
и derived presentation data. `*.view.tsx` и соседние presentation components
|
||||||
|
получают props и рендерят JSX.
|
||||||
|
|
||||||
|
View modules не вызывают data/auth/router/navigation/VM hooks: никаких
|
||||||
|
`useBrokerage*`, `usePortfolio`, `useAuth`, `useParams`, `useNavigate` или
|
||||||
|
page VM hooks внутри `*.view.tsx` и `components/*.view.tsx`. View modules также
|
||||||
|
не группируют domain collections и не считают derived collection values вроде
|
||||||
|
долей Portfolio Position. Эти значения готовит VM.
|
||||||
|
|
||||||
|
Допустимые hooks во view: `useTranslation()` для labels, form-context hooks в
|
||||||
|
form views и локальный UI-only state для rendering fallback, например failed
|
||||||
|
image state. Простое форматирование одного переданного значения через
|
||||||
|
существующий formatter остаётся presentation concern; вычисления из всей
|
||||||
|
коллекции остаются во VM.
|
||||||
|
|
||||||
|
View files дробятся по смысловой нагрузке, а не только по количеству строк. Если
|
||||||
|
section или state имеет самостоятельный смысл и может быть назван, его нужно
|
||||||
|
выносить в nearby presentation component вместо накопления внутри одного
|
||||||
|
большого `*.view.tsx`.
|
||||||
|
|
||||||
|
Known API errors, retry и stale snapshot остаются local views, а непойманные
|
||||||
|
route errors обрабатывает общий route-keyed ErrorBoundary.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"src/app/routes/ProtectedLayout.tsx": 3137,
|
"src/app/routes/ProtectedLayout.tsx": 3137,
|
||||||
"src/pages/account-events/index.ts": 4468,
|
"src/pages/account-events/index.ts": 4468,
|
||||||
"src/pages/account-operations/index.ts": 13861,
|
"src/pages/account-operations/index.ts": 13861,
|
||||||
"src/pages/account/index.ts": 21872,
|
"src/pages/account/index.ts": 22366,
|
||||||
"src/pages/accounts/index.ts": 6174,
|
"src/pages/accounts/index.ts": 6174,
|
||||||
"src/pages/login/index.ts": 669,
|
"src/pages/login/index.ts": 669,
|
||||||
"src/pages/not-found/index.ts": 850,
|
"src/pages/not-found/index.ts": 850,
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
AccountPageLoaded,
|
AccountPageLoaded,
|
||||||
AccountPageLoading,
|
AccountPageLoading,
|
||||||
} from "./AccountPage";
|
} from "./AccountPage";
|
||||||
|
import type {
|
||||||
|
AccountPageRecentEvents,
|
||||||
|
AccountPageRecentOperations,
|
||||||
|
} from "./AccountPage.vm";
|
||||||
|
import { toAccountPagePositionSections } from "./AccountPage.vm";
|
||||||
|
|
||||||
vi.mock("@/features/auth", () => ({
|
vi.mock("@/features/auth", () => ({
|
||||||
useAuth: () => ({ accessToken: "test-token" }),
|
useAuth: () => ({ accessToken: "test-token" }),
|
||||||
@@ -73,13 +78,39 @@ const portfolio: Portfolio = {
|
|||||||
cashBalances: [{ currency: "USD", value: "0.5" }],
|
cashBalances: [{ currency: "USD", value: "0.5" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyRecentOperations: AccountPageRecentOperations = {
|
||||||
|
state: "empty",
|
||||||
|
to: "/accounts/account-1/operations",
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyRecentEvents: AccountPageRecentEvents = {
|
||||||
|
state: "empty",
|
||||||
|
to: "/accounts/account-1/events",
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadedProps(nextPortfolio: Portfolio = portfolio) {
|
||||||
|
return {
|
||||||
|
portfolio: nextPortfolio,
|
||||||
|
positionSections: toAccountPagePositionSections(nextPortfolio),
|
||||||
|
recentOperations: emptyRecentOperations,
|
||||||
|
recentEvents: emptyRecentEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyProps() {
|
||||||
|
return {
|
||||||
|
recentOperations: emptyRecentOperations,
|
||||||
|
recentEvents: emptyRecentEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("AccountPage states", () => {
|
describe("AccountPage states", () => {
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
it("renders loading and empty states", () => {
|
it("renders loading and empty states", () => {
|
||||||
renderPage(<AccountPageLoading />);
|
renderPage(<AccountPageLoading />);
|
||||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||||
|
|
||||||
renderPage(<AccountPageEmpty />);
|
renderPage(<AccountPageEmpty {...emptyProps()} />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { name: "Портфель пока пуст" }),
|
screen.getByRole("heading", { name: "Портфель пока пуст" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -104,7 +135,7 @@ describe("AccountPage states", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
|
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
|
||||||
renderPage(<AccountPageLoaded portfolio={portfolio} />);
|
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { level: 1, name: "Портфель" }),
|
screen.getByRole("heading", { level: 1, name: "Портфель" }),
|
||||||
@@ -138,7 +169,7 @@ describe("AccountPage states", () => {
|
|||||||
it("renders stock cards with logo fallback and signed unlabeled return", () => {
|
it("renders stock cards with logo fallback and signed unlabeled return", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
portfolio={{
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
@@ -164,7 +195,7 @@ describe("AccountPage states", () => {
|
|||||||
logoName: null,
|
logoName: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}}
|
})}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("А")).toBeInTheDocument();
|
expect(screen.getByText("А")).toBeInTheDocument();
|
||||||
@@ -177,7 +208,7 @@ describe("AccountPage states", () => {
|
|||||||
it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => {
|
it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
portfolio={{
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
@@ -203,7 +234,7 @@ describe("AccountPage states", () => {
|
|||||||
logoName: null,
|
logoName: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}}
|
})}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute(
|
expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute(
|
||||||
@@ -222,10 +253,10 @@ describe("AccountPage states", () => {
|
|||||||
it("renders the bonds section as a single-column grid on any screen width", () => {
|
it("renders the bonds section as a single-column grid on any screen width", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
portfolio={{
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [portfolio.positions[1]],
|
positions: [portfolio.positions[1]],
|
||||||
}}
|
})}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const singleColumnGrids = Array.from(document.querySelectorAll("style"))
|
const singleColumnGrids = Array.from(document.querySelectorAll("style"))
|
||||||
@@ -242,7 +273,7 @@ describe("AccountPage states", () => {
|
|||||||
it("keeps other-position cards compact with labeled return and no logo", () => {
|
it("keeps other-position cards compact with labeled return and no logo", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
portfolio={{
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
@@ -257,7 +288,7 @@ describe("AccountPage states", () => {
|
|||||||
logoName: "fond.png",
|
logoName: "fond.png",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}}
|
})}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
|
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
|
||||||
@@ -267,7 +298,7 @@ describe("AccountPage states", () => {
|
|||||||
it("uses the cached account title when available", () => {
|
it("uses the cached account title when available", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
portfolio={portfolio}
|
{...loadedProps()}
|
||||||
account={{
|
account={{
|
||||||
id: "account-1",
|
id: "account-1",
|
||||||
name: "Основной",
|
name: "Основной",
|
||||||
@@ -281,47 +312,37 @@ describe("AccountPage states", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the portfolio visible when recent events fail", async () => {
|
it("keeps the portfolio visible when recent operations fail", () => {
|
||||||
server.use(
|
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
|
||||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
accountId="account-1"
|
{...loadedProps()}
|
||||||
accessToken="token"
|
recentOperations={{
|
||||||
portfolio={portfolio}
|
state: "error",
|
||||||
|
to: "/accounts/account-1/operations",
|
||||||
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() =>
|
expect(
|
||||||
expect(
|
screen.getByText("Не удалось загрузить последние события."),
|
||||||
screen.getByText("Не удалось загрузить последние события."),
|
).toBeInTheDocument();
|
||||||
).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the portfolio visible when upcoming events fail", async () => {
|
it("keeps the portfolio visible when upcoming events fail", () => {
|
||||||
server.use(
|
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
|
||||||
HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
accountId="account-1"
|
{...loadedProps()}
|
||||||
accessToken="token"
|
recentEvents={{
|
||||||
portfolio={portfolio}
|
state: "error",
|
||||||
|
to: "/accounts/account-1/events",
|
||||||
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() =>
|
expect(
|
||||||
expect(
|
screen.getByText("Не удалось загрузить будущие события."),
|
||||||
screen.getByText("Не удалось загрузить будущие события."),
|
).toBeInTheDocument();
|
||||||
).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,22 +14,23 @@ export function AccountPage() {
|
|||||||
return (
|
return (
|
||||||
<AccountPageEmpty
|
<AccountPageEmpty
|
||||||
account={viewModel.account}
|
account={viewModel.account}
|
||||||
accountId={viewModel.accountId}
|
|
||||||
accessToken={viewModel.accessToken}
|
|
||||||
refreshing={viewModel.refreshing}
|
refreshing={viewModel.refreshing}
|
||||||
refreshError={viewModel.refreshError}
|
refreshError={viewModel.refreshError}
|
||||||
retry={viewModel.retry}
|
retry={viewModel.retry}
|
||||||
|
recentOperations={viewModel.recentOperations}
|
||||||
|
recentEvents={viewModel.recentEvents}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
account={viewModel.account}
|
account={viewModel.account}
|
||||||
accountId={viewModel.accountId}
|
|
||||||
accessToken={viewModel.accessToken}
|
|
||||||
portfolio={viewModel.portfolio}
|
portfolio={viewModel.portfolio}
|
||||||
|
positionSections={viewModel.positionSections}
|
||||||
refreshing={viewModel.refreshing}
|
refreshing={viewModel.refreshing}
|
||||||
refreshError={viewModel.refreshError}
|
refreshError={viewModel.refreshError}
|
||||||
retry={viewModel.retry}
|
retry={viewModel.retry}
|
||||||
|
recentOperations={viewModel.recentOperations}
|
||||||
|
recentEvents={viewModel.recentEvents}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,26 @@
|
|||||||
import {
|
import { Alert, Button, Stack, Text } from "@mantine/core";
|
||||||
Alert,
|
|
||||||
Avatar,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Paper,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { useBrokerageOperations } from "@/entities/broker-operation";
|
|
||||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||||
import { accountTypeKey } from "@/entities/brokerage-account";
|
|
||||||
import {
|
|
||||||
type BrokerageEvent,
|
|
||||||
useBrokerageEvents,
|
|
||||||
} from "@/entities/brokerage-event";
|
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
import {
|
import { formatSnapshotTime } from "@/entities/portfolio";
|
||||||
formatMoney,
|
|
||||||
formatShare,
|
|
||||||
formatSnapshotTime,
|
|
||||||
instrumentLogoUrl,
|
|
||||||
} from "@/entities/portfolio";
|
|
||||||
import { StateView } from "@/shared/ui";
|
import { StateView } from "@/shared/ui";
|
||||||
import classes from "./AccountPage.module.css";
|
import classes from "./AccountPage.module.css";
|
||||||
|
import type {
|
||||||
|
AccountPagePositionSection,
|
||||||
|
AccountPageRecentEvents,
|
||||||
|
AccountPageRecentOperations,
|
||||||
|
} from "./AccountPage.vm";
|
||||||
|
import {
|
||||||
|
AccountHeading,
|
||||||
|
CashBalances,
|
||||||
|
PortfolioRefreshState,
|
||||||
|
PortfolioTotals,
|
||||||
|
PositionSection,
|
||||||
|
RecentEvents,
|
||||||
|
RecentOperations,
|
||||||
|
Section,
|
||||||
|
} from "./components";
|
||||||
|
|
||||||
export function AccountPageLoading() {
|
export function AccountPageLoading() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -41,6 +32,7 @@ export function AccountPageLoading() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountPageError({
|
export function AccountPageError({
|
||||||
code,
|
code,
|
||||||
retry,
|
retry,
|
||||||
@@ -93,20 +85,21 @@ export function AccountPageError({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountPageEmpty({
|
export function AccountPageEmpty({
|
||||||
account,
|
account,
|
||||||
accountId,
|
|
||||||
accessToken = null,
|
|
||||||
refreshing = false,
|
refreshing = false,
|
||||||
refreshError = false,
|
refreshError = false,
|
||||||
retry,
|
retry,
|
||||||
|
recentOperations,
|
||||||
|
recentEvents,
|
||||||
}: {
|
}: {
|
||||||
account?: BrokerageAccount;
|
account?: BrokerageAccount;
|
||||||
accountId?: string;
|
|
||||||
accessToken?: string | null;
|
|
||||||
refreshing?: boolean;
|
refreshing?: boolean;
|
||||||
refreshError?: boolean;
|
refreshError?: boolean;
|
||||||
retry?: () => void;
|
retry?: () => void;
|
||||||
|
recentOperations: AccountPageRecentOperations;
|
||||||
|
recentEvents: AccountPageRecentEvents;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
@@ -121,43 +114,32 @@ export function AccountPageEmpty({
|
|||||||
title={t("portfolio.emptyTitle")}
|
title={t("portfolio.emptyTitle")}
|
||||||
text={t("portfolio.emptyText")}
|
text={t("portfolio.emptyText")}
|
||||||
/>
|
/>
|
||||||
<RecentOperations accountId={accountId} accessToken={accessToken} />
|
<RecentOperations operations={recentOperations} />
|
||||||
<RecentEvents accountId={accountId} accessToken={accessToken} />
|
<RecentEvents events={recentEvents} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountPageLoaded({
|
export function AccountPageLoaded({
|
||||||
account,
|
account,
|
||||||
accountId,
|
|
||||||
accessToken,
|
|
||||||
portfolio,
|
portfolio,
|
||||||
|
positionSections,
|
||||||
refreshing = false,
|
refreshing = false,
|
||||||
refreshError = false,
|
refreshError = false,
|
||||||
retry,
|
retry,
|
||||||
|
recentOperations,
|
||||||
|
recentEvents,
|
||||||
}: {
|
}: {
|
||||||
account?: BrokerageAccount;
|
account?: BrokerageAccount;
|
||||||
accountId?: string;
|
|
||||||
accessToken?: string | null;
|
|
||||||
portfolio: Portfolio;
|
portfolio: Portfolio;
|
||||||
|
positionSections: AccountPagePositionSection[];
|
||||||
refreshing?: boolean;
|
refreshing?: boolean;
|
||||||
refreshError?: boolean;
|
refreshError?: boolean;
|
||||||
retry?: () => void;
|
retry?: () => void;
|
||||||
|
recentOperations: AccountPageRecentOperations;
|
||||||
|
recentEvents: AccountPageRecentEvents;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const stocks = portfolio.positions.filter((p) => p.type === "STOCK");
|
|
||||||
const bonds = portfolio.positions.filter((p) => p.type === "BOND");
|
|
||||||
const other = portfolio.positions.filter(
|
|
||||||
(p) => p.type !== "STOCK" && p.type !== "BOND",
|
|
||||||
);
|
|
||||||
const denominators = new Map<string, number>();
|
|
||||||
for (const position of portfolio.positions) {
|
|
||||||
if (!position.currentValue) continue;
|
|
||||||
const currency = position.currentValue.currency;
|
|
||||||
denominators.set(
|
|
||||||
currency,
|
|
||||||
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Stack className={classes.page} gap="lg">
|
<Stack className={classes.page} gap="lg">
|
||||||
<AccountHeading account={account} />
|
<AccountHeading account={account} />
|
||||||
@@ -174,336 +156,25 @@ export function AccountPageLoaded({
|
|||||||
date: formatSnapshotTime(portfolio.lastSuccessfulAt),
|
date: formatSnapshotTime(portfolio.lastSuccessfulAt),
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
|
<PortfolioTotals totals={portfolio.totalsByCurrency} />
|
||||||
{portfolio.totalsByCurrency.map((total) => (
|
{positionSections.map((section) => (
|
||||||
<Paper className={classes.total} key={total.currency} withBorder>
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{total.currency}
|
|
||||||
</Text>
|
|
||||||
<Text fw={800} size="xl">
|
|
||||||
{formatMoney(total)}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
{stocks.length ? (
|
|
||||||
<PositionSection
|
<PositionSection
|
||||||
title={t("portfolio.stocks")}
|
key={section.kind}
|
||||||
positions={stocks}
|
title={t(positionSectionTitleKey(section.kind))}
|
||||||
denominators={denominators}
|
section={section}
|
||||||
wide
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
))}
|
||||||
{bonds.length ? (
|
|
||||||
<PositionSection
|
|
||||||
title={t("portfolio.bonds")}
|
|
||||||
positions={bonds}
|
|
||||||
denominators={denominators}
|
|
||||||
wide
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{other.length ? (
|
|
||||||
<PositionSection
|
|
||||||
title={t("portfolio.otherPositions")}
|
|
||||||
positions={other}
|
|
||||||
denominators={denominators}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Section title={t("portfolio.cashBalances")}>
|
<Section title={t("portfolio.cashBalances")}>
|
||||||
{portfolio.cashBalances.map((balance) => (
|
<CashBalances balances={portfolio.cashBalances} />
|
||||||
<Paper className={classes.cash} key={balance.currency} withBorder>
|
|
||||||
<Text>{balance.currency}</Text>
|
|
||||||
<Text fw={700}>{formatMoney(balance)}</Text>
|
|
||||||
</Paper>
|
|
||||||
))}
|
|
||||||
</Section>
|
</Section>
|
||||||
<RecentOperations
|
<RecentOperations operations={recentOperations} />
|
||||||
accountId={accountId}
|
<RecentEvents events={recentEvents} />
|
||||||
accessToken={accessToken ?? null}
|
|
||||||
/>
|
|
||||||
<RecentEvents accountId={accountId} accessToken={accessToken ?? null} />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function PortfolioRefreshState({
|
|
||||||
refreshing,
|
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
|
||||||
refreshError,
|
if (kind === "stocks") return "portfolio.stocks";
|
||||||
retry,
|
if (kind === "bonds") return "portfolio.bonds";
|
||||||
}: {
|
return "portfolio.otherPositions";
|
||||||
refreshing: boolean;
|
|
||||||
refreshError: boolean;
|
|
||||||
retry?: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
if (!refreshing && !refreshError) return null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{refreshing ? (
|
|
||||||
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
|
|
||||||
<Loader size="sm" />
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{t("portfolio.refreshing")}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
) : null}
|
|
||||||
{refreshError ? (
|
|
||||||
<Alert color="orange">
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text>{t("portfolio.refreshError")}</Text>
|
|
||||||
<Button onClick={retry} variant="light">
|
|
||||||
{t("accounts.retry")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function RecentOperations({
|
|
||||||
accountId,
|
|
||||||
accessToken,
|
|
||||||
}: {
|
|
||||||
accountId: string | undefined;
|
|
||||||
accessToken: string | null;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const operations = useBrokerageOperations(accountId, accessToken, {
|
|
||||||
limit: 5,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<Section title={t("ledger.recentTitle")}>
|
|
||||||
{operations.isLoading ? (
|
|
||||||
<Text c="dimmed">{t("ledger.recentLoading")}</Text>
|
|
||||||
) : null}
|
|
||||||
{operations.isError ? (
|
|
||||||
<Alert color="red">{t("ledger.recentError")}</Alert>
|
|
||||||
) : null}
|
|
||||||
{operations.data?.items.length === 0 ? (
|
|
||||||
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
|
|
||||||
) : null}
|
|
||||||
{operations.data?.items.map((operation) => (
|
|
||||||
<Paper className={classes.cash} key={operation.id} withBorder>
|
|
||||||
<Text>{operation.description || operation.type}</Text>
|
|
||||||
<Text fw={700}>
|
|
||||||
{operation.payment
|
|
||||||
? formatMoney(operation.payment)
|
|
||||||
: t("ledger.noMoney")}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
component={Link}
|
|
||||||
to={`/accounts/${accountId}/operations`}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
{t("ledger.open")}
|
|
||||||
</Button>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function RecentEvents({
|
|
||||||
accountId,
|
|
||||||
accessToken,
|
|
||||||
}: {
|
|
||||||
accountId: string | undefined;
|
|
||||||
accessToken: string | null;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const events = useBrokerageEvents(accountId, accessToken);
|
|
||||||
return (
|
|
||||||
<Section title={t("events.recentTitle")}>
|
|
||||||
{events.isLoading ? (
|
|
||||||
<Text c="dimmed">{t("events.recentLoading")}</Text>
|
|
||||||
) : null}
|
|
||||||
{events.isError ? (
|
|
||||||
<Alert color="red">{t("events.recentError")}</Alert>
|
|
||||||
) : null}
|
|
||||||
{events.data?.events.length === 0 ? (
|
|
||||||
<Text c="dimmed">{t("events.recentEmpty")}</Text>
|
|
||||||
) : null}
|
|
||||||
{events.data?.events.slice(0, 5).map((event) => (
|
|
||||||
<RecentEvent
|
|
||||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
|
||||||
event={event}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
component={Link}
|
|
||||||
to={`/accounts/${accountId}/events`}
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
{t("events.open")}
|
|
||||||
</Button>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function RecentEvent({ event }: { event: BrokerageEvent }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<Paper className={classes.cash} withBorder>
|
|
||||||
<Text>{`${t(`events.type.${event.type}`)} · ${event.displayName}`}</Text>
|
|
||||||
<Text fw={700}>
|
|
||||||
{event.amount && event.currency
|
|
||||||
? formatMoney({ currency: event.currency, value: event.amount })
|
|
||||||
: t("events.noAmount")}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function PositionSection({
|
|
||||||
title,
|
|
||||||
positions,
|
|
||||||
denominators,
|
|
||||||
wide = false,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
positions: Portfolio["positions"];
|
|
||||||
denominators: Map<string, number>;
|
|
||||||
wide?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Section title={title}>
|
|
||||||
<SimpleGrid cols={wide ? 1 : { base: 1, sm: 2, md: 3 }}>
|
|
||||||
{positions.map((position) => (
|
|
||||||
<PositionCard
|
|
||||||
key={`${position.figi}-${position.displayName}-${position.quantity}`}
|
|
||||||
position={position}
|
|
||||||
denominators={denominators}
|
|
||||||
wide={wide}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function PositionCard({
|
|
||||||
position,
|
|
||||||
denominators,
|
|
||||||
wide = false,
|
|
||||||
}: {
|
|
||||||
position: Portfolio["positions"][number];
|
|
||||||
denominators: Map<string, number>;
|
|
||||||
wide?: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const share = position.currentValue?.currency
|
|
||||||
? formatShare(position.currentValue, {
|
|
||||||
currency: position.currentValue.currency,
|
|
||||||
value: String(denominators.get(position.currentValue.currency) ?? 0),
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const returnClass = position.absoluteReturn
|
|
||||||
? Number(position.absoluteReturn.value) > 0
|
|
||||||
? classes.positive
|
|
||||||
: Number(position.absoluteReturn.value) < 0
|
|
||||||
? classes.negative
|
|
||||||
: undefined
|
|
||||||
: undefined;
|
|
||||||
const returnText = position.absoluteReturn
|
|
||||||
? wide
|
|
||||||
? `${Number(position.absoluteReturn.value) > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
|
|
||||||
: t("portfolio.return", {
|
|
||||||
value: formatMoney(position.absoluteReturn),
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const logoSrc = wide ? instrumentLogoUrl(position.logoName) : null;
|
|
||||||
return (
|
|
||||||
<Card className={classes.tile} withBorder>
|
|
||||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
|
||||||
{wide ? (
|
|
||||||
<PositionLogo
|
|
||||||
key={logoSrc ?? "no-logo"}
|
|
||||||
src={logoSrc}
|
|
||||||
label={position.displayName}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Stack gap={2} miw={0}>
|
|
||||||
<Text fw={700} lineClamp={1}>
|
|
||||||
{position.displayName}
|
|
||||||
</Text>
|
|
||||||
{position.isin ? (
|
|
||||||
<Text c="dimmed" size="xs" className={classes.mono}>
|
|
||||||
{position.isin}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("portfolio.quantity", { value: position.quantity })}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack gap={2} align="flex-end">
|
|
||||||
{position.currentValue ? (
|
|
||||||
<Text fw={800} size="lg">
|
|
||||||
{formatMoney(position.currentValue)}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
{returnText ? (
|
|
||||||
<Text size="sm" fw={600} className={returnClass}>
|
|
||||||
{returnText}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
{position.averagePurchasePrice || share ? (
|
|
||||||
<Group justify="space-between" mt="xs">
|
|
||||||
{position.averagePurchasePrice ? (
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("portfolio.avgPrice", {
|
|
||||||
value: formatMoney(position.averagePurchasePrice),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
{share ? (
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("portfolio.share", { value: share })}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
) : null}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function PositionLogo({ src, label }: { src: string | null; label: string }) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
if (src && !failed)
|
|
||||||
return (
|
|
||||||
<Avatar
|
|
||||||
src={src}
|
|
||||||
alt={label}
|
|
||||||
size={40}
|
|
||||||
radius="md"
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<Avatar size={40} radius="md">
|
|
||||||
{(label.trim()[0] ?? "?").toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function AccountHeading({ account }: { account?: BrokerageAccount }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
|
|
||||||
{account ? (
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{t(accountTypeKey(account.type))}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function Section({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Title order={2}>{title}</Title>
|
|
||||||
{children}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { HttpResponse, http } from "msw";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
import { server } from "@/test/server";
|
||||||
|
import { useAccountPageViewModel } from "./AccountPage.vm";
|
||||||
|
|
||||||
|
vi.mock("@/features/auth", () => ({
|
||||||
|
useAuth: () => ({ accessToken: "test-token" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const portfolio: Portfolio = {
|
||||||
|
lastSuccessfulAt: "2026-08-13T10:00:00Z",
|
||||||
|
isStale: true,
|
||||||
|
totalsByCurrency: [{ currency: "RUB", value: "150" }],
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
figi: "FIGI-STOCK",
|
||||||
|
displayName: "Акция",
|
||||||
|
type: "STOCK",
|
||||||
|
quantity: "2",
|
||||||
|
currentValue: { currency: "RUB", value: "100" },
|
||||||
|
averagePurchasePrice: null,
|
||||||
|
absoluteReturn: { currency: "RUB", value: "10" },
|
||||||
|
isin: null,
|
||||||
|
logoName: "akciya.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
figi: "FIGI-BOND",
|
||||||
|
displayName: "Облигация",
|
||||||
|
type: "BOND",
|
||||||
|
quantity: "1",
|
||||||
|
currentValue: { currency: "RUB", value: "50" },
|
||||||
|
averagePurchasePrice: { currency: "RUB", value: "45" },
|
||||||
|
absoluteReturn: { currency: "RUB", value: "-5" },
|
||||||
|
isin: "RU000A0JX0J2",
|
||||||
|
logoName: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
figi: "FIGI-FUND",
|
||||||
|
displayName: "Фонд",
|
||||||
|
type: "FUND",
|
||||||
|
quantity: "3",
|
||||||
|
currentValue: null,
|
||||||
|
averagePurchasePrice: null,
|
||||||
|
absoluteReturn: null,
|
||||||
|
isin: null,
|
||||||
|
logoName: "fond.png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cashBalances: [{ currency: "USD", value: "1" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter initialEntries={["/accounts/account-1"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/accounts/:accountId" element={children} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockLoadedAccountPage() {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
|
HttpResponse.json(portfolio),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
accountId: "account-1",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "operation-1",
|
||||||
|
occurredAt: "2026-08-13T10:00:00Z",
|
||||||
|
type: "Buy",
|
||||||
|
category: "trade",
|
||||||
|
description: "Покупка акции",
|
||||||
|
figi: "FIGI-STOCK",
|
||||||
|
instrumentUid: null,
|
||||||
|
ticker: "STK",
|
||||||
|
instrumentType: "share",
|
||||||
|
payment: { currency: "RUB", value: "-100" },
|
||||||
|
quantity: "1",
|
||||||
|
price: null,
|
||||||
|
accruedInterest: null,
|
||||||
|
commission: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
hasNext: false,
|
||||||
|
observedAt: "2026-08-13T10:00:00Z",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
accountId: "account-1",
|
||||||
|
calculatedAt: "2026-08-13T10:00:00Z",
|
||||||
|
events: Array.from({ length: 6 }, (_, index) => ({
|
||||||
|
type: "coupon",
|
||||||
|
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
|
||||||
|
dateKind: "event",
|
||||||
|
figi: `FIGI-EVENT-${index}`,
|
||||||
|
instrumentUid: null,
|
||||||
|
ticker: `BND${index}`,
|
||||||
|
displayName: `Купон ${index}`,
|
||||||
|
currency: "RUB",
|
||||||
|
amount: String(index + 1),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useAccountPageViewModel", () => {
|
||||||
|
it("prepares grouped position sections with derived display data", async () => {
|
||||||
|
mockLoadedAccountPage();
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.state).toBe("loaded"));
|
||||||
|
const loaded = result.current as Extract<
|
||||||
|
typeof result.current,
|
||||||
|
{ state: "loaded" }
|
||||||
|
>;
|
||||||
|
expect(loaded.positionSections).toMatchObject([
|
||||||
|
{
|
||||||
|
kind: "stocks",
|
||||||
|
wide: true,
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
displayName: "Акция",
|
||||||
|
shareText: "66,7 %",
|
||||||
|
returnText: "+10 ₽",
|
||||||
|
returnTone: "positive",
|
||||||
|
logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "bonds",
|
||||||
|
wide: true,
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
displayName: "Облигация",
|
||||||
|
shareText: "33,3 %",
|
||||||
|
returnText: "-5 ₽",
|
||||||
|
returnTone: "negative",
|
||||||
|
logoSrc: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "other",
|
||||||
|
wide: false,
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
displayName: "Фонд",
|
||||||
|
shareText: null,
|
||||||
|
returnText: null,
|
||||||
|
returnTone: undefined,
|
||||||
|
logoSrc: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prepares recent operations and events state for presentation views", async () => {
|
||||||
|
mockLoadedAccountPage();
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
(result.current as { recentEvents?: { state: string } }).recentEvents,
|
||||||
|
)?.toMatchObject({ state: "loaded" }),
|
||||||
|
);
|
||||||
|
const loaded = result.current as Extract<
|
||||||
|
typeof result.current,
|
||||||
|
{ state: "loaded" }
|
||||||
|
>;
|
||||||
|
expect(loaded.recentOperations).toMatchObject({
|
||||||
|
state: "loaded",
|
||||||
|
to: "/accounts/account-1/operations",
|
||||||
|
items: [{ id: "operation-1", description: "Покупка акции" }],
|
||||||
|
});
|
||||||
|
expect(loaded.recentEvents).toMatchObject({
|
||||||
|
state: "loaded",
|
||||||
|
to: "/accounts/account-1/events",
|
||||||
|
items: [
|
||||||
|
{ ticker: "BND0" },
|
||||||
|
{ ticker: "BND1" },
|
||||||
|
{ ticker: "BND2" },
|
||||||
|
{ ticker: "BND3" },
|
||||||
|
{ ticker: "BND4" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps recent errors isolated in the ViewModel presentation state", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
|
HttpResponse.json(portfolio),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||||
|
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||||
|
HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current).toMatchObject({
|
||||||
|
state: "loaded",
|
||||||
|
recentOperations: { state: "error" },
|
||||||
|
recentEvents: { state: "error" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,102 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
|
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||||
|
import { useBrokerageOperations } from "@/entities/broker-operation";
|
||||||
import {
|
import {
|
||||||
type BrokerageAccount,
|
type BrokerageAccount,
|
||||||
brokerageAccountsQueryKey,
|
brokerageAccountsQueryKey,
|
||||||
} from "@/entities/brokerage-account";
|
} from "@/entities/brokerage-account";
|
||||||
import { portfolioErrorCode, usePortfolio } from "@/entities/portfolio";
|
import {
|
||||||
|
type BrokerageEvent,
|
||||||
|
useBrokerageEvents,
|
||||||
|
} from "@/entities/brokerage-event";
|
||||||
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
import {
|
||||||
|
formatMoney,
|
||||||
|
formatShare,
|
||||||
|
instrumentLogoUrl,
|
||||||
|
portfolioErrorCode,
|
||||||
|
usePortfolio,
|
||||||
|
} from "@/entities/portfolio";
|
||||||
import { useAuth } from "@/features/auth";
|
import { useAuth } from "@/features/auth";
|
||||||
|
|
||||||
export function useAccountPageViewModel() {
|
type PositionSectionKind = "stocks" | "bonds" | "other";
|
||||||
|
type AccountPageReturnTone = "positive" | "negative";
|
||||||
|
|
||||||
|
export type AccountPagePosition = Portfolio["positions"][number] & {
|
||||||
|
shareText: string | null;
|
||||||
|
returnText: string | null;
|
||||||
|
returnTone?: AccountPageReturnTone;
|
||||||
|
logoSrc: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountPagePositionSection = {
|
||||||
|
kind: PositionSectionKind;
|
||||||
|
wide: boolean;
|
||||||
|
positions: AccountPagePosition[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountPageRecentOperations =
|
||||||
|
| {
|
||||||
|
state: "loading" | "error" | "empty";
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
state: "loaded";
|
||||||
|
to: string;
|
||||||
|
items: BrokerageOperation[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountPageRecentEvents =
|
||||||
|
| {
|
||||||
|
state: "loading" | "error" | "empty";
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
state: "loaded";
|
||||||
|
to: string;
|
||||||
|
items: BrokerageEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountPageContent = {
|
||||||
|
account?: BrokerageAccount;
|
||||||
|
accountId?: string;
|
||||||
|
refreshing: boolean;
|
||||||
|
refreshError: boolean;
|
||||||
|
retry: () => void;
|
||||||
|
recentOperations: AccountPageRecentOperations;
|
||||||
|
recentEvents: AccountPageRecentEvents;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountPageLoadedViewModel = AccountPageContent & {
|
||||||
|
state: "loaded";
|
||||||
|
portfolio: Portfolio;
|
||||||
|
positionSections: AccountPagePositionSection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountPageEmptyViewModel = AccountPageContent & {
|
||||||
|
state: "empty";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountPageViewModel =
|
||||||
|
| { state: "loading" }
|
||||||
|
| { state: "error"; code?: string; retry: () => void }
|
||||||
|
| AccountPageEmptyViewModel
|
||||||
|
| AccountPageLoadedViewModel;
|
||||||
|
|
||||||
|
export function useAccountPageViewModel(): AccountPageViewModel {
|
||||||
const { accountId } = useParams();
|
const { accountId } = useParams();
|
||||||
const { accessToken } = useAuth();
|
const { accessToken } = useAuth();
|
||||||
const portfolio = usePortfolio(accountId, accessToken);
|
const portfolio = usePortfolio(accountId, accessToken);
|
||||||
|
const operations = useBrokerageOperations(accountId, accessToken, {
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
const events = useBrokerageEvents(accountId, accessToken);
|
||||||
const account = useQueryClient()
|
const account = useQueryClient()
|
||||||
.getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey)
|
.getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey)
|
||||||
?.find((item) => item.id === accountId);
|
?.find((item) => item.id === accountId);
|
||||||
|
const recentOperations = toRecentOperations(accountId, operations);
|
||||||
|
const recentEvents = toRecentEvents(accountId, events);
|
||||||
if (!portfolio.data) {
|
if (!portfolio.data) {
|
||||||
if (portfolio.isError)
|
if (portfolio.isError)
|
||||||
return {
|
return {
|
||||||
@@ -33,15 +116,110 @@ export function useAccountPageViewModel() {
|
|||||||
state: "empty" as const,
|
state: "empty" as const,
|
||||||
account,
|
account,
|
||||||
accountId,
|
accountId,
|
||||||
accessToken,
|
recentOperations,
|
||||||
|
recentEvents,
|
||||||
...refresh,
|
...refresh,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
state: "loaded" as const,
|
state: "loaded" as const,
|
||||||
account,
|
account,
|
||||||
accountId,
|
accountId,
|
||||||
accessToken,
|
|
||||||
portfolio: portfolio.data,
|
portfolio: portfolio.data,
|
||||||
|
positionSections: toAccountPagePositionSections(portfolio.data),
|
||||||
|
recentOperations,
|
||||||
|
recentEvents,
|
||||||
...refresh,
|
...refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toRecentOperations(
|
||||||
|
accountId: string | undefined,
|
||||||
|
operations: ReturnType<typeof useBrokerageOperations>,
|
||||||
|
): AccountPageRecentOperations {
|
||||||
|
const to = `/accounts/${accountId}/operations`;
|
||||||
|
if (operations.isLoading) return { state: "loading", to };
|
||||||
|
if (operations.isError) return { state: "error", to };
|
||||||
|
if (!operations.data?.items.length) return { state: "empty", to };
|
||||||
|
return { state: "loaded", to, items: operations.data.items };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecentEvents(
|
||||||
|
accountId: string | undefined,
|
||||||
|
events: ReturnType<typeof useBrokerageEvents>,
|
||||||
|
): AccountPageRecentEvents {
|
||||||
|
const to = `/accounts/${accountId}/events`;
|
||||||
|
if (events.isLoading) return { state: "loading", to };
|
||||||
|
if (events.isError) return { state: "error", to };
|
||||||
|
const items = events.data?.events.slice(0, 5) ?? [];
|
||||||
|
if (!items.length) return { state: "empty", to };
|
||||||
|
return { state: "loaded", to, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAccountPagePositionSections(
|
||||||
|
portfolio: Portfolio,
|
||||||
|
): AccountPagePositionSection[] {
|
||||||
|
const denominators = new Map<string, number>();
|
||||||
|
for (const position of portfolio.positions) {
|
||||||
|
if (!position.currentValue) continue;
|
||||||
|
const currency = position.currentValue.currency;
|
||||||
|
denominators.set(
|
||||||
|
currency,
|
||||||
|
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const stocks = toSection("stocks", true, portfolio, denominators);
|
||||||
|
const bonds = toSection("bonds", true, portfolio, denominators);
|
||||||
|
const other = toSection("other", false, portfolio, denominators);
|
||||||
|
return [stocks, bonds, other].filter(
|
||||||
|
(section) => section.positions.length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSection(
|
||||||
|
kind: PositionSectionKind,
|
||||||
|
wide: boolean,
|
||||||
|
portfolio: Portfolio,
|
||||||
|
denominators: Map<string, number>,
|
||||||
|
): AccountPagePositionSection {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
wide,
|
||||||
|
positions: portfolio.positions
|
||||||
|
.filter((position) => {
|
||||||
|
if (kind === "stocks") return position.type === "STOCK";
|
||||||
|
if (kind === "bonds") return position.type === "BOND";
|
||||||
|
return position.type !== "STOCK" && position.type !== "BOND";
|
||||||
|
})
|
||||||
|
.map((position) => toPosition(position, denominators, wide)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPosition(
|
||||||
|
position: Portfolio["positions"][number],
|
||||||
|
denominators: Map<string, number>,
|
||||||
|
wide: boolean,
|
||||||
|
): AccountPagePosition {
|
||||||
|
const returnValue = position.absoluteReturn
|
||||||
|
? Number(position.absoluteReturn.value)
|
||||||
|
: null;
|
||||||
|
const returnTone =
|
||||||
|
returnValue === null || returnValue === 0
|
||||||
|
? undefined
|
||||||
|
: returnValue > 0
|
||||||
|
? "positive"
|
||||||
|
: "negative";
|
||||||
|
return {
|
||||||
|
...position,
|
||||||
|
shareText: position.currentValue?.currency
|
||||||
|
? formatShare(position.currentValue, {
|
||||||
|
currency: position.currentValue.currency,
|
||||||
|
value: String(denominators.get(position.currentValue.currency) ?? 0),
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
returnText: position.absoluteReturn
|
||||||
|
? `${wide && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
|
||||||
|
: null,
|
||||||
|
returnTone,
|
||||||
|
logoSrc: wide ? instrumentLogoUrl(position.logoName) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||||
|
import { accountTypeKey } from "@/entities/brokerage-account";
|
||||||
|
|
||||||
|
export function AccountHeading({ account }: { account?: BrokerageAccount }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
|
||||||
|
{account ? (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{t(accountTypeKey(account.type))}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Paper, Text } from "@mantine/core";
|
||||||
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
import { formatMoney } from "@/entities/portfolio";
|
||||||
|
import classes from "../AccountPage.module.css";
|
||||||
|
|
||||||
|
export function CashBalances({
|
||||||
|
balances,
|
||||||
|
}: {
|
||||||
|
balances: Portfolio["cashBalances"];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{balances.map((balance) => (
|
||||||
|
<Paper className={classes.cash} key={balance.currency} withBorder>
|
||||||
|
<Text>{balance.currency}</Text>
|
||||||
|
<Text fw={700}>{formatMoney(balance)}</Text>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function PortfolioRefreshState({
|
||||||
|
refreshing,
|
||||||
|
refreshError,
|
||||||
|
retry,
|
||||||
|
}: {
|
||||||
|
refreshing: boolean;
|
||||||
|
refreshError: boolean;
|
||||||
|
retry?: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!refreshing && !refreshError) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{refreshing ? (
|
||||||
|
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
|
||||||
|
<Loader size="sm" />
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{t("portfolio.refreshing")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
{refreshError ? (
|
||||||
|
<Alert color="orange">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text>{t("portfolio.refreshError")}</Text>
|
||||||
|
<Button onClick={retry} variant="light">
|
||||||
|
{t("accounts.retry")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Paper, SimpleGrid, Text } from "@mantine/core";
|
||||||
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
import { formatMoney } from "@/entities/portfolio";
|
||||||
|
import classes from "../AccountPage.module.css";
|
||||||
|
|
||||||
|
export function PortfolioTotals({
|
||||||
|
totals,
|
||||||
|
}: {
|
||||||
|
totals: Portfolio["totalsByCurrency"];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
|
||||||
|
{totals.map((total) => (
|
||||||
|
<Paper className={classes.total} key={total.currency} withBorder>
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{total.currency}
|
||||||
|
</Text>
|
||||||
|
<Text fw={800} size="xl">
|
||||||
|
{formatMoney(total)}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Card, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatMoney } from "@/entities/portfolio";
|
||||||
|
import classes from "../AccountPage.module.css";
|
||||||
|
import type { AccountPagePosition } from "../AccountPage.vm";
|
||||||
|
import { PositionLogo } from "./PositionLogo.view";
|
||||||
|
|
||||||
|
export function PositionCard({
|
||||||
|
position,
|
||||||
|
wide = false,
|
||||||
|
}: {
|
||||||
|
position: AccountPagePosition;
|
||||||
|
wide?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const returnClass = position.returnTone
|
||||||
|
? classes[position.returnTone]
|
||||||
|
: undefined;
|
||||||
|
const returnText =
|
||||||
|
position.returnText && wide
|
||||||
|
? position.returnText
|
||||||
|
: position.returnText
|
||||||
|
? t("portfolio.return", {
|
||||||
|
value: position.returnText,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<Card className={classes.tile} withBorder>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
|
{wide ? (
|
||||||
|
<PositionLogo
|
||||||
|
key={position.logoSrc ?? "no-logo"}
|
||||||
|
src={position.logoSrc}
|
||||||
|
label={position.displayName}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Stack gap={2} miw={0}>
|
||||||
|
<Text fw={700} lineClamp={1}>
|
||||||
|
{position.displayName}
|
||||||
|
</Text>
|
||||||
|
{position.isin ? (
|
||||||
|
<Text c="dimmed" size="xs" className={classes.mono}>
|
||||||
|
{position.isin}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("portfolio.quantity", { value: position.quantity })}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={2} align="flex-end">
|
||||||
|
{position.currentValue ? (
|
||||||
|
<Text fw={800} size="lg">
|
||||||
|
{formatMoney(position.currentValue)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{returnText ? (
|
||||||
|
<Text size="sm" fw={600} className={returnClass}>
|
||||||
|
{returnText}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
{position.averagePurchasePrice || position.shareText ? (
|
||||||
|
<Group justify="space-between" mt="xs">
|
||||||
|
{position.averagePurchasePrice ? (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("portfolio.avgPrice", {
|
||||||
|
value: formatMoney(position.averagePurchasePrice),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{position.shareText ? (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("portfolio.share", { value: position.shareText })}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Avatar } from "@mantine/core";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function PositionLogo({
|
||||||
|
src,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
src: string | null;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
if (src && !failed)
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
src={src}
|
||||||
|
alt={label}
|
||||||
|
size={40}
|
||||||
|
radius="md"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Avatar size={40} radius="md">
|
||||||
|
{(label.trim()[0] ?? "?").toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { SimpleGrid } from "@mantine/core";
|
||||||
|
import type { AccountPagePositionSection } from "../AccountPage.vm";
|
||||||
|
import { PositionCard } from "./PositionCard.view";
|
||||||
|
import { Section } from "./Section.view";
|
||||||
|
|
||||||
|
export function PositionSection({
|
||||||
|
title,
|
||||||
|
section,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
section: AccountPagePositionSection;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Section title={title}>
|
||||||
|
<SimpleGrid cols={section.wide ? 1 : { base: 1, sm: 2, md: 3 }}>
|
||||||
|
{section.positions.map((position) => (
|
||||||
|
<PositionCard
|
||||||
|
key={`${position.figi}-${position.displayName}-${position.quantity}`}
|
||||||
|
position={position}
|
||||||
|
wide={section.wide}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Alert, Button, Paper, Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||||
|
import { formatMoney } from "@/entities/portfolio";
|
||||||
|
import classes from "../AccountPage.module.css";
|
||||||
|
import type { AccountPageRecentEvents } from "../AccountPage.vm";
|
||||||
|
import { Section } from "./Section.view";
|
||||||
|
|
||||||
|
export function RecentEvents({ events }: { events: AccountPageRecentEvents }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Section title={t("events.recentTitle")}>
|
||||||
|
{events.state === "loading" ? (
|
||||||
|
<Text c="dimmed">{t("events.recentLoading")}</Text>
|
||||||
|
) : null}
|
||||||
|
{events.state === "error" ? (
|
||||||
|
<Alert color="red">{t("events.recentError")}</Alert>
|
||||||
|
) : null}
|
||||||
|
{events.state === "empty" ? (
|
||||||
|
<Text c="dimmed">{t("events.recentEmpty")}</Text>
|
||||||
|
) : null}
|
||||||
|
{events.state === "loaded"
|
||||||
|
? events.items.map((event) => (
|
||||||
|
<RecentEvent
|
||||||
|
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||||
|
event={event}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
<Button component={Link} to={events.to} variant="subtle">
|
||||||
|
{t("events.open")}
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentEvent({ event }: { event: BrokerageEvent }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Paper className={classes.cash} withBorder>
|
||||||
|
<Text>{`${t(`events.type.${event.type}`)} · ${event.displayName}`}</Text>
|
||||||
|
<Text fw={700}>
|
||||||
|
{event.amount && event.currency
|
||||||
|
? formatMoney({ currency: event.currency, value: event.amount })
|
||||||
|
: t("events.noAmount")}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Alert, Button, Paper, Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { formatMoney } from "@/entities/portfolio";
|
||||||
|
import classes from "../AccountPage.module.css";
|
||||||
|
import type { AccountPageRecentOperations } from "../AccountPage.vm";
|
||||||
|
import { Section } from "./Section.view";
|
||||||
|
|
||||||
|
export function RecentOperations({
|
||||||
|
operations,
|
||||||
|
}: {
|
||||||
|
operations: AccountPageRecentOperations;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Section title={t("ledger.recentTitle")}>
|
||||||
|
{operations.state === "loading" ? (
|
||||||
|
<Text c="dimmed">{t("ledger.recentLoading")}</Text>
|
||||||
|
) : null}
|
||||||
|
{operations.state === "error" ? (
|
||||||
|
<Alert color="red">{t("ledger.recentError")}</Alert>
|
||||||
|
) : null}
|
||||||
|
{operations.state === "empty" ? (
|
||||||
|
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
|
||||||
|
) : null}
|
||||||
|
{operations.state === "loaded"
|
||||||
|
? operations.items.map((operation) => (
|
||||||
|
<Paper className={classes.cash} key={operation.id} withBorder>
|
||||||
|
<Text>{operation.description || operation.type}</Text>
|
||||||
|
<Text fw={700}>
|
||||||
|
{operation.payment
|
||||||
|
? formatMoney(operation.payment)
|
||||||
|
: t("ledger.noMoney")}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
<Button component={Link} to={operations.to} variant="subtle">
|
||||||
|
{t("ledger.open")}
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
export function Section({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Title order={2}>{title}</Title>
|
||||||
|
{children}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { AccountHeading } from "./AccountHeading.view";
|
||||||
|
export { CashBalances } from "./CashBalances.view";
|
||||||
|
export { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
||||||
|
export { PortfolioTotals } from "./PortfolioTotals.view";
|
||||||
|
export { PositionSection } from "./PositionSection.view";
|
||||||
|
export { RecentEvents } from "./RecentEvents.view";
|
||||||
|
export { RecentOperations } from "./RecentOperations.view";
|
||||||
|
export { Section } from "./Section.view";
|
||||||
Reference in New Issue
Block a user