feat: add all-accounts portfolio pages

Reuse account sections and group securities with per-account holdings.

Use Mantine Select to preserve filters and reset pagination on scope changes.

Document component reuse guardrails and cover routes, grouping and scope switching.
This commit is contained in:
2026-09-09 20:36:49 +03:00
parent c906dfa2e2
commit daf1dca666
36 changed files with 1962 additions and 117 deletions
+10 -2
View File
@@ -2,8 +2,16 @@
## Выбор компонента и state ## Выбор компонента и state
Используй существующий Mantine component и общую тему, если они выражают нужное Сначала переиспользуй подходящий component проекта; иначе используй готовый
поведение. Wrapper нужен для проектной ответственности, а не ради нового имени Mantine component и общую тему. Для выпадающего выбора используй `Select` или
`MultiSelect`, а не браузерный `<select>` / `NativeSelect` по умолчанию.
Нативный control или собственная реализация допустимы, когда готовые components
не покрывают конкретное продуктовое требование или доступность; укажи причину
в результате работы. Краткость кода и удобство тестирования не являются причиной:
ограничения JSDOM решай в test setup, сохраняя production UI.
Семантический HTML для структуры страницы остаётся уместным.
Wrapper нужен для проектной ответственности, а не ради нового имени
для Button. Локальный CSS module принадлежит component; общий token вводи для для Button. Локальный CSS module принадлежит component; общий token вводи для
реально общей роли через [design system](../../../docs/development/design-system.md). реально общей роли через [design system](../../../docs/development/design-system.md).
+309
View File
@@ -0,0 +1,309 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC } from "react";
import { MemoryRouter, useLocation } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation";
import type { IncomeAnalytics } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio";
import { AuthProvider } from "@/features/auth";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes";
const accounts = [
{ id: "a", name: "Основной" },
{ id: "b", name: "ИИС" },
];
const position: Portfolio["positions"][number] = {
figi: "SBER",
displayName: "Сбербанк",
type: "STOCK",
quantity: "2",
currentValue: { currency: "RUB", value: "600" },
absoluteReturn: null,
averagePurchasePrice: null,
isin: null,
ticker: "SBER",
logoName: null,
};
const portfolio: Portfolio = {
lastSuccessfulAt: "2026-08-15T12:00:00Z",
isStale: false,
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
cashBalances: [],
positions: accounts.map((account) => ({ ...position, account })),
};
const receipt: BrokerageOperation = {
id: "same",
occurredAt: "2026-08-15T12:00:00Z",
type: "OPERATION_TYPE_COUPON",
category: "income",
description: "Выплата купона",
figi: null,
instrumentUid: null,
ticker: "BOND",
instrumentType: "bond",
logoName: null,
payment: { currency: "RUB", value: "1" },
quantity: null,
price: null,
accruedInterest: null,
commission: null,
};
const items = accounts.map((account) => ({ ...receipt, account }));
const analytics: IncomeAnalytics = {
accountId: "all",
period: { from: "2026-08-01", to: "2026-08-31" },
calculatedAt: "2026-09-01T12:00:00Z",
totalsByCurrency: [
{
currency: "RUB",
coupon: { currency: "RUB", value: "2" },
dividend: { currency: "RUB", value: "0" },
total: { currency: "RUB", value: "2" },
},
],
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [],
},
yearComparison: null,
bucketGranularity: "day",
buckets: [],
instruments: [
{
key: "bond:RUB",
figi: null,
instrumentUid: null,
ticker: "BOND",
instrumentType: "bond",
displayName: "Облигация",
coupon: { currency: "RUB", value: "2" },
dividend: { currency: "RUB", value: "0" },
total: { currency: "RUB", value: "2" },
receiptCount: 2,
},
],
entries: items.map((entry) => ({ ...entry, instrumentKey: "bond:RUB" })),
};
const CurrentLocation: FC = () => {
const location = useLocation();
return (
<output aria-label="Текущий путь">
{location.pathname}
{location.search}
</output>
);
};
function renderPage(path: string) {
const client = createTestQueryClient();
render(
<QueryClientProvider client={client}>
<MantineProvider env="test">
<MemoryRouter initialEntries={[path]}>
<AuthProvider>
<AppRoutes />
<CurrentLocation />
</AuthProvider>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return client;
}
beforeEach(() => {
server.use(
http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({
accessToken: "test",
user: { id: "user", email: "user@example.com" },
}),
),
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(
accounts.map((account) => ({
...account,
type: "BROKERAGE",
openedOn: null,
portfolioSummary: null,
})),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(portfolio),
),
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ params }) =>
HttpResponse.json({
accountId: params.accountId,
items,
hasNext: false,
nextCursor: null,
observedAt: receipt.occurredAt,
}),
),
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
HttpResponse.json(analytics),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json({
accountId: "all",
calculatedAt: receipt.occurredAt,
incomeTotalsByCurrency: [],
events: accounts.map((account) => ({
account,
type: "coupon",
date: "2026-09-15",
dateKind: "event",
figi: null,
instrumentUid: null,
ticker: "BOND",
displayName: "Облигация",
isin: null,
logoName: null,
projectedAmount: { currency: "RUB", value: "2" },
})),
}),
),
);
});
describe("All accounts routes", () => {
it.each([
["/accounts/all", "Все счета"],
["/accounts/all/operations", "Журнал операций"],
["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"],
["/accounts/all/events", "Будущие события"],
])("opens %s directly", async (path, title) => {
renderPage(path);
expect(
await screen.findByRole("heading", { level: 1, name: title }),
).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Счёт" })).toHaveValue(
"Все счета",
);
});
it("groups the same security and links its accounts and overview sections", async () => {
renderPage("/accounts/all");
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
for (const account of accounts)
expect(screen.getByRole("link", { name: account.name })).toHaveAttribute(
"href",
`/accounts/${account.id}`,
);
for (const suffix of ["operations", "income", "events"])
expect(
document.querySelector(`a[href="/accounts/all/${suffix}"]`),
).toBeInTheDocument();
});
it("switches accounts within operations with applied filters and a fresh cursor", async () => {
const requests: URL[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request, params }) => {
const url = new URL(request.url);
requests.push(url);
return HttpResponse.json({
accountId: params.accountId,
items: [
{
...receipt,
description: url.searchParams.has("cursor")
? "Вторая страница"
: "Первая страница",
},
],
hasNext: !url.searchParams.has("cursor"),
nextCursor: url.searchParams.has("cursor") ? null : "next",
observedAt: receipt.occurredAt,
});
},
),
);
renderPage(
"/accounts/all/operations?from=2026-08-01&to=2026-08-31&categories=income",
);
await screen.findByText("Первая страница");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await screen.findByText("Вторая страница");
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
await waitFor(() =>
expect(
requests.some((url) => url.pathname.includes("/b/operations")),
).toBe(true),
);
const selected = requests.find((url) =>
url.pathname.includes("/b/operations"),
);
expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z");
expect(selected?.searchParams.get("to")).toBe("2026-08-31T23:59:59.999Z");
expect(selected?.searchParams.get("categories")).toBe("income");
expect(selected?.searchParams.has("cursor")).toBe(false);
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
"/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income",
);
});
it("restarts an expired aggregate cursor without losing filters", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) =>
new URL(request.url).searchParams.has("cursor")
? HttpResponse.json(
{ code: "INVALID_OPERATIONS_CURSOR" },
{ status: 400 },
)
: HttpResponse.json({
accountId: "all",
items,
hasNext: true,
nextCursor: "expired",
observedAt: receipt.occurredAt,
}),
),
);
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
await screen.findAllByText("Выплата купона");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
expect(await screen.findAllByText("Выплата купона")).toHaveLength(2);
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
"from=2026-08-01&to=2026-08-31",
);
});
it("retains the complete portfolio during a failed refresh", async () => {
const client = renderPage("/accounts/all");
await screen.findAllByText("Сбербанк");
server.use(
http.get("*/api/v1/brokerage-accounts/all/portfolio", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
);
await act(() =>
client.invalidateQueries({
predicate: ({ queryKey }) => String(queryKey[1]).endsWith("/portfolio"),
}),
);
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
expect(
await screen.findByRole("button", {
name: "Повторить",
}),
).toBeInTheDocument();
});
});
+32
View File
@@ -85,6 +85,38 @@ const AppRoutes: FC = () => (
</LazyPage> </LazyPage>
} }
/> />
<Route
path="/accounts/all"
element={
<LazyPage variant="content">
<AccountPage accountId="all" />
</LazyPage>
}
/>
<Route
path="/accounts/all/operations"
element={
<LazyPage variant="content">
<AccountOperationsPage accountId="all" />
</LazyPage>
}
/>
<Route
path="/accounts/all/income"
element={
<LazyPage variant="content">
<AccountIncomePage accountId="all" />
</LazyPage>
}
/>
<Route
path="/accounts/all/events"
element={
<LazyPage variant="content">
<AccountEventsPage accountId="all" />
</LazyPage>
}
/>
<Route <Route
path="/accounts/:accountId" path="/accounts/:accountId"
element={ element={
+4 -2
View File
@@ -13,8 +13,10 @@ const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => {
const { data: accounts } = useBrokerageAccounts(); const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation(); const { t } = useTranslation();
const accountName = const accountName =
accounts?.find((account) => account.id === accountId)?.name ?? accountId === "all"
t("breadcrumbs.accountFallback"); ? t("accounts.all")
: (accounts?.find((account) => account.id === accountId)?.name ??
t("breadcrumbs.accountFallback"));
const accountPath = `/accounts/${accountId}`; const accountPath = `/accounts/${accountId}`;
const items = [ const items = [
{ label: t("breadcrumbs.accounts"), to: "/accounts" }, { label: t("breadcrumbs.accounts"), to: "/accounts" },
+45
View File
@@ -0,0 +1,45 @@
import { Select } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
type AccountScopeSwitchProps = { accountId: string; section?: string };
const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
const { accountId, section } = props;
const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation();
const navigate = useNavigate();
const { search } = useLocation();
const data = [
{ value: "all", label: t("accounts.all") },
...(accounts ?? []).map((account) => ({
value: account.id,
label: account.name,
})),
];
if (!data.some((item) => item.value === accountId))
data.push({ value: accountId, label: t("breadcrumbs.accountFallback") });
return (
<Select
label={t("accounts.scope")}
value={accountId}
data={data}
allowDeselect={false}
maw={360}
w="100%"
onChange={(value) => {
if (!value || value === accountId) return;
const filters = new URLSearchParams(search);
filters.delete("cursor");
navigate({
pathname: `/accounts/${encodeURIComponent(value)}${section ? `/${section}` : ""}`,
search: filters.toString(),
});
}}
/>
);
};
export { AccountScopeSwitch };
+4 -2
View File
@@ -1,5 +1,5 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { AppBreadcrumbs } from "./AppBreadcrumbs"; import { AppBreadcrumbs } from "./AppBreadcrumbs";
@@ -77,7 +77,9 @@ describe("AppBreadcrumbs", () => {
it("uses the account fallback without exposing the identifier", () => { it("uses the account fallback without exposing the identifier", () => {
renderBreadcrumbs("/accounts/account-1"); renderBreadcrumbs("/accounts/account-1");
expect(screen.getByText("Счет")).toHaveAttribute("aria-current", "page"); expect(
within(screen.getByRole("navigation")).getByText("Счет"),
).toHaveAttribute("aria-current", "page");
expect(screen.queryByText("account-1")).not.toBeInTheDocument(); expect(screen.queryByText("account-1")).not.toBeInTheDocument();
}); });
+26 -1
View File
@@ -1,7 +1,9 @@
import { Stack } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMatch } from "react-router"; import { useMatch } from "react-router";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
const AppBreadcrumbs: FC = () => { const AppBreadcrumbs: FC = () => {
@@ -18,27 +20,50 @@ const AppBreadcrumbs: FC = () => {
if (operations) if (operations)
return ( return (
<Stack gap="sm" mb="lg">
<AccountBreadcrumbs <AccountBreadcrumbs
accountId={operations.params.accountId ?? ""} accountId={operations.params.accountId ?? ""}
section="operations" section="operations"
/> />
<AccountScopeSwitch
accountId={operations.params.accountId ?? ""}
section="operations"
/>
</Stack>
); );
if (events) if (events)
return ( return (
<Stack gap="sm" mb="lg">
<AccountBreadcrumbs <AccountBreadcrumbs
accountId={events.params.accountId ?? ""} accountId={events.params.accountId ?? ""}
section="events" section="events"
/> />
<AccountScopeSwitch
accountId={events.params.accountId ?? ""}
section="events"
/>
</Stack>
); );
if (income) if (income)
return ( return (
<Stack gap="sm" mb="lg">
<AccountBreadcrumbs <AccountBreadcrumbs
accountId={income.params.accountId ?? ""} accountId={income.params.accountId ?? ""}
section="income" section="income"
/> />
<AccountScopeSwitch
accountId={income.params.accountId ?? ""}
section="income"
/>
</Stack>
); );
if (account) if (account)
return <AccountBreadcrumbs accountId={account.params.accountId ?? ""} />; return (
<Stack gap="sm" mb="lg">
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
<AccountScopeSwitch accountId={account.params.accountId ?? ""} />
</Stack>
);
if (accounts) if (accounts)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />; return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
if (settings) if (settings)
@@ -15,9 +15,13 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
const instrument = const instrument =
operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument"); operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument");
const payment = paymentPresentation(operation); const payment = paymentPresentation(operation);
const metadata = [formatSnapshotTime(operation.occurredAt), instrument].join( const metadata = [
" · ", formatSnapshotTime(operation.occurredAt),
); instrument,
operation.account?.name,
]
.filter(Boolean)
.join(" · ");
return ( return (
<EntryCard <EntryCard
@@ -19,6 +19,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
date, date,
event.dateKind === "registry" ? t("events.dateKind.registry") : null, event.dateKind === "registry" ? t("events.dateKind.registry") : null,
event.isin, event.isin,
event.account?.name,
] ]
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
@@ -36,7 +36,15 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
{events.state === "loaded" {events.state === "loaded"
? events.items.map((event) => ( ? events.items.map((event) => (
<BrokerageEventCard <BrokerageEventCard
key={`${event.type}-${event.date}-${event.ticker}`} key={JSON.stringify([
event.account?.id,
event.type,
event.date,
event.instrumentUid,
event.figi,
event.ticker,
event.logoName,
])}
event={event} event={event}
/> />
)) ))
@@ -5,13 +5,16 @@ import { AccountEventsPageError } from "./components/AccountEventsPageError.view
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view"; import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view"; import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
const AccountEventsPage: FC = () => { type AccountEventsPageProps = { accountId?: string };
const viewModel = useAccountEventsPageViewModel();
const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountEventsPageLoading />; if (viewModel.state === "loading") return <AccountEventsPageLoading />;
if (viewModel.state === "error") if (viewModel.state === "error")
return <AccountEventsPageError retry={viewModel.retry} />; return <AccountEventsPageError retry={viewModel.retry} />;
if (viewModel.state === "empty") return <AccountEventsPageEmpty />; if (viewModel.state === "empty")
return <AccountEventsPageLoaded events={viewModel.events} />; return <AccountEventsPageEmpty {...viewModel} />;
return <AccountEventsPageLoaded {...viewModel} />;
}; };
export { AccountEventsPage }; export { AccountEventsPage };
@@ -4,20 +4,37 @@ import {
useBrokerageEvents, useBrokerageEvents,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
type EventsRefreshState = {
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
};
type AccountEventsPageViewModel = type AccountEventsPageViewModel =
| { state: "loading" } | { state: "loading" }
| { state: "empty" } | ({ state: "empty" } & EventsRefreshState)
| { state: "error"; retry: () => void } | { state: "error"; retry: () => void }
| { state: "loaded"; events: BrokerageEvent[] }; | ({ state: "loaded"; events: BrokerageEvent[] } & EventsRefreshState);
function useAccountEventsPageViewModel(): AccountEventsPageViewModel { function useAccountEventsPageViewModel(
const { accountId } = useParams(); scopeId?: string,
): AccountEventsPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const events = useBrokerageEvents(accountId); const events = useBrokerageEvents(accountId);
if (events.isLoading) return { state: "loading" }; if (events.isLoading) return { state: "loading" };
if (events.isError) return { state: "error", retry: () => events.refetch() }; if (events.isError && !events.data)
return { state: "error", retry: () => events.refetch() };
if (!events.data) return { state: "loading" }; if (!events.data) return { state: "loading" };
if (!events.data.events.length) return { state: "empty" }; const refresh = {
return { state: "loaded", events: events.data.events }; refreshing: events.isFetching,
refreshError: events.isError,
retry: () => {
void events.refetch();
},
};
if (!events.data.events.length) return { state: "empty", ...refresh };
return { state: "loaded", events: events.data.events, ...refresh };
} }
export { useAccountEventsPageViewModel }; export { type EventsRefreshState, useAccountEventsPageViewModel };
@@ -1,13 +1,25 @@
import { Stack, Text, Title } from "@mantine/core"; import { Alert, Button, Progress, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui"; import { StateView } from "@/shared/ui";
import type { EventsRefreshState } from "../AccountEventsPage.vm";
const AccountEventsPageEmpty: FC = () => { const AccountEventsPageEmpty: FC<EventsRefreshState> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack maw={960} gap="lg"> <Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title> <Title order={1}>{t("events.title")}</Title>
{props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null}
{props.refreshError ? (
<Alert color="yellow">
{t("accounts.refreshError")}
<Button variant="subtle" onClick={props.retry}>
{t("accounts.retry")}
</Button>
</Alert>
) : null}
<Text c="dimmed">{t("events.hint")}</Text> <Text c="dimmed">{t("events.hint")}</Text>
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} /> <StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
</Stack> </Stack>
@@ -1,24 +1,46 @@
import { Stack, Text, Title } from "@mantine/core"; import { Alert, Button, Progress, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
type BrokerageEvent, type BrokerageEvent,
BrokerageEventCard, BrokerageEventCard,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import type { EventsRefreshState } from "../AccountEventsPage.vm";
type AccountEventsPageLoadedProps = { events: BrokerageEvent[] }; type AccountEventsPageLoadedProps = EventsRefreshState & {
events: BrokerageEvent[];
};
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => { const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack maw={960} gap="lg"> <Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title> <Title order={1}>{t("events.title")}</Title>
{props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null}
{props.refreshError ? (
<Alert color="yellow">
{t("accounts.refreshError")}
<Button variant="subtle" onClick={props.retry}>
{t("accounts.retry")}
</Button>
</Alert>
) : null}
<Text c="dimmed">{t("events.hint")}</Text> <Text c="dimmed">{t("events.hint")}</Text>
<Stack gap="sm"> <Stack gap="sm">
{props.events.map((event) => ( {props.events.map((event) => (
<BrokerageEventCard <BrokerageEventCard
event={event} event={event}
key={`${event.type}-${event.date}-${event.ticker}`} key={JSON.stringify([
event.account?.id,
event.type,
event.date,
event.instrumentUid,
event.figi,
event.ticker,
event.logoName,
])}
/> />
))} ))}
</Stack> </Stack>
@@ -4,8 +4,10 @@ import { AccountIncomePageError } from "./components/AccountIncomePageError.view
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view"; import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view"; import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
const AccountIncomePage: FC = () => { type AccountIncomePageProps = { accountId?: string };
const viewModel = useAccountIncomePageViewModel();
const AccountIncomePage: FC<AccountIncomePageProps> = (props) => {
const viewModel = useAccountIncomePageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountIncomePageLoading />; if (viewModel.state === "loading") return <AccountIncomePageLoading />;
if (viewModel.state === "error") if (viewModel.state === "error")
return <AccountIncomePageError {...viewModel} />; return <AccountIncomePageError {...viewModel} />;
@@ -58,8 +58,11 @@ const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine(
{ path: ["to"], ...validationMessage("income.validation.period")({}) }, { path: ["to"], ...validationMessage("income.validation.period")({}) },
); );
const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { const useAccountIncomePageViewModel = (
const { accountId } = useParams(); scopeId?: string,
): AccountIncomePageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const requested = useMemo( const requested = useMemo(
() => ({ () => ({
@@ -4,8 +4,10 @@ import { AccountOperationsPageError } from "./components/AccountOperationsPageEr
import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view"; import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view";
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view"; import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
const AccountOperationsPage: FC = () => { type AccountOperationsPageProps = { accountId?: string };
const viewModel = useAccountOperationsPageVm();
const AccountOperationsPage: FC<AccountOperationsPageProps> = (props) => {
const viewModel = useAccountOperationsPageVm(props.accountId);
if (viewModel.state === "loading") { if (viewModel.state === "loading") {
return <AccountOperationsPageLoading />; return <AccountOperationsPageLoading />;
@@ -1,18 +1,24 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "react-router"; import { useParams, useSearchParams } from "react-router";
import { import {
type BrokerageOperation, type BrokerageOperation,
brokerageOperationsQueryOptions, brokerageOperationsQueryOptions,
type OperationCategory, type OperationCategory,
operationCategories,
useBrokerageOperations, useBrokerageOperations,
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { apiErrorCode } from "@/shared/api";
type AccountOperationsPageViewModel = type AccountOperationsPageViewModel =
| { state: "loading" } | { state: "loading" }
| { state: "error"; retry: () => void } | { state: "error"; retry: () => void }
| { | {
state: "loaded"; state: "loaded";
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
draftFrom: string | null; draftFrom: string | null;
draftTo: string | null; draftTo: string | null;
draftCategories: OperationCategory[]; draftCategories: OperationCategory[];
@@ -29,37 +35,76 @@ type AccountOperationsPageViewModel =
onPrevious: () => void; onPrevious: () => void;
}; };
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { const useAccountOperationsPageVm = (
const { accountId } = useParams(); scopeId?: string,
): AccountOperationsPageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [appliedFrom, setAppliedFrom] = useState<string | null>(null); const [searchParams, setSearchParams] = useSearchParams();
const [appliedTo, setAppliedTo] = useState<string | null>(null); const appliedFrom = searchParams.get("from");
const [appliedCategories, setAppliedCategories] = useState< const appliedTo = searchParams.get("to");
OperationCategory[] const appliedCategories = operationCategories.filter((category) =>
>([]); (searchParams.get("categories") ?? "").split(",").includes(category),
const [draftFrom, setDraftFrom] = useState<string | null>(null);
const [draftTo, setDraftTo] = useState<string | null>(null);
const [draftCategories, setDraftCategories] = useState<OperationCategory[]>(
[],
); );
const [cursors, setCursors] = useState<string[]>([]); const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
const [draftCategories, setDraftCategories] =
useState<OperationCategory[]>(appliedCategories);
const scopeKey = JSON.stringify([
accountId,
appliedFrom,
appliedTo,
appliedCategories,
]);
const [pagination, setPagination] = useState<{
scopeKey: string;
cursors: string[];
}>({ scopeKey, cursors: [] });
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
const cursor = cursors.at(-1); const cursor = cursors.at(-1);
const operations = useBrokerageOperations(accountId, { useEffect(() => {
setDraftFrom(searchParams.get("from"));
setDraftTo(searchParams.get("to"));
setDraftCategories(
operationCategories.filter((category) =>
(searchParams.get("categories") ?? "").split(",").includes(category),
),
);
}, [searchParams]);
const firstPageQuery = {
from: dateStart(appliedFrom), from: dateStart(appliedFrom),
to: dateEnd(appliedTo), to: dateEnd(appliedTo),
...(appliedCategories.length ? { categories: appliedCategories } : {}), ...(appliedCategories.length ? { categories: appliedCategories } : {}),
...(cursor ? { cursor } : {}),
limit: 100, limit: 100,
};
const operations = useBrokerageOperations(accountId, {
...firstPageQuery,
...(cursor ? { cursor } : {}),
}); });
const resetQueryKey = brokerageOperationsQueryOptions(accountId, { const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
limit: 100, limit: 100,
}).queryKey; }).queryKey;
const resetCursors = () => setCursors([]); const resetCursors = () => setPagination({ scopeKey, cursors: [] });
const retry = () => {
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
void queryClient.invalidateQueries({
queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery)
.queryKey,
exact: true,
});
resetCursors();
} else void operations.refetch();
};
if (operations.isLoading) return { state: "loading" }; if (operations.isLoading) return { state: "loading" };
if (operations.isError) if (
return { state: "error", retry: () => operations.refetch() }; operations.isError &&
(!operations.data ||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
)
return { state: "error", retry };
if (!operations.data) return { state: "loading" }; if (!operations.data) return { state: "loading" };
const nextCursor = operations.data.nextCursor; const nextCursor = operations.data.nextCursor;
@@ -70,6 +115,9 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
return { return {
state: "loaded", state: "loaded",
refreshing: operations.isFetching,
refreshError: operations.isError,
retry,
draftFrom, draftFrom,
draftTo, draftTo,
draftCategories, draftCategories,
@@ -81,25 +129,29 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
onDraftToChange: setDraftTo, onDraftToChange: setDraftTo,
onDraftCategoriesChange: setDraftCategories, onDraftCategoriesChange: setDraftCategories,
onApply: () => { onApply: () => {
setAppliedFrom(draftFrom); setSearchParams({
setAppliedTo(draftTo); ...(draftFrom ? { from: draftFrom } : {}),
setAppliedCategories(draftCategories); ...(draftTo ? { to: draftTo } : {}),
...(draftCategories.length
? { categories: draftCategories.join(",") }
: {}),
});
resetCursors(); resetCursors();
}, },
onReset: () => { onReset: () => {
setDraftFrom(null); setDraftFrom(null);
setDraftTo(null); setDraftTo(null);
setDraftCategories([]); setDraftCategories([]);
setAppliedFrom(null); setSearchParams({});
setAppliedTo(null);
setAppliedCategories([]);
resetCursors(); resetCursors();
void queryClient.invalidateQueries({ queryKey: resetQueryKey }); void queryClient.invalidateQueries({ queryKey: resetQueryKey });
}, },
onNext: () => { onNext: () => {
if (nextCursor) setCursors((value) => [...value, nextCursor]); if (nextCursor)
setPagination({ scopeKey, cursors: [...cursors, nextCursor] });
}, },
onPrevious: () => setCursors((value) => value.slice(0, -1)), onPrevious: () =>
setPagination({ scopeKey, cursors: cursors.slice(0, -1) }),
}; };
}; };
@@ -1,4 +1,12 @@
import { Button, Group, MultiSelect, Stack, Title } from "@mantine/core"; import {
Alert,
Button,
Group,
MultiSelect,
Progress,
Stack,
Title,
} from "@mantine/core";
import { DateInput } from "@mantine/dates"; import { DateInput } from "@mantine/dates";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -67,6 +75,17 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
return ( return (
<Stack maw={960} gap="lg"> <Stack maw={960} gap="lg">
<Title order={1}>{t("ledger.title")}</Title> <Title order={1}>{t("ledger.title")}</Title>
{props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null}
{props.refreshError ? (
<Alert color="yellow">
{t("accounts.refreshError")}
<Button variant="subtle" onClick={props.retry}>
{t("accounts.retry")}
</Button>
</Alert>
) : null}
<Group align="end"> <Group align="end">
<DateInput <DateInput
label={t("ledger.from")} label={t("ledger.from")}
@@ -103,7 +122,14 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
</Group> </Group>
{items.length ? ( {items.length ? (
items.map((operation) => ( items.map((operation) => (
<LedgerEntryRow key={operation.id} operation={operation} /> <LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
)) ))
) : ( ) : (
<StateView <StateView
@@ -124,3 +124,24 @@
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
} }
.accountPositions {
list-style: none;
padding: 0;
margin: var(--mantine-spacing-sm) 0 0;
}
.accountPosition {
padding-block: var(--mantine-spacing-sm);
border-top: 1px solid var(--mantine-color-gray-3);
overflow-wrap: anywhere;
}
.accountPosition:last-child {
padding-bottom: 0;
}
.accountPositionName {
min-width: 0;
overflow-wrap: anywhere;
}
+6 -2
View File
@@ -5,14 +5,17 @@ import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading.view"; import { AccountPageLoading } from "./components/AccountPageLoading.view";
const AccountPage: FC = () => { type AccountPageProps = { accountId?: string };
const viewModel = useAccountPageViewModel();
const AccountPage: FC<AccountPageProps> = (props) => {
const viewModel = useAccountPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountPageLoading />; if (viewModel.state === "loading") return <AccountPageLoading />;
if (viewModel.state === "error") return <AccountPageError {...viewModel} />; if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
if (viewModel.state === "empty") if (viewModel.state === "empty")
return ( return (
<AccountPageEmpty <AccountPageEmpty
account={viewModel.account} account={viewModel.account}
accountId={viewModel.accountId}
refreshing={viewModel.refreshing} refreshing={viewModel.refreshing}
refreshError={viewModel.refreshError} refreshError={viewModel.refreshError}
retry={viewModel.retry} retry={viewModel.retry}
@@ -24,6 +27,7 @@ const AccountPage: FC = () => {
return ( return (
<AccountPageLoaded <AccountPageLoaded
account={viewModel.account} account={viewModel.account}
accountId={viewModel.accountId}
portfolio={viewModel.portfolio} portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections} positionSections={viewModel.positionSections}
refreshing={viewModel.refreshing} refreshing={viewModel.refreshing}
+10 -5
View File
@@ -172,13 +172,13 @@ describe("useAccountPageViewModel", () => {
"bonds", "bonds",
"other", "other",
]); ]);
expect(sections[2].positions.map((position) => position.figi)).toEqual([ expect(sections[2].groups.map((group) => group.positions[0].figi)).toEqual([
"FIGI-FUND", "FIGI-FUND",
...otherTypes.map((type) => `FIGI-${type}-NEXT`), ...otherTypes.map((type) => `FIGI-${type}-NEXT`),
]); ]);
expect(sections[0].positions[0].shareText).toBe("40 %"); expect(sections[0].groups[0].positions[0].shareText).toBe("40 %");
expect(sections[1].positions[0].shareText).toBe("20 %"); expect(sections[1].groups[0].positions[0].shareText).toBe("20 %");
expect(sections[2].positions[1].shareText).toBe("20 %"); expect(sections[2].groups[1].positions[0].shareText).toBe("20 %");
expect(snapshot).toEqual(original); expect(snapshot).toEqual(original);
}); });
@@ -195,7 +195,12 @@ describe("useAccountPageViewModel", () => {
typeof result.current, typeof result.current,
{ state: "loaded" } { state: "loaded" }
>; >;
expect(loaded.positionSections).toMatchObject([ expect(
loaded.positionSections.map(({ kind, groups }) => ({
kind,
positions: groups.flatMap((group) => group.positions),
})),
).toMatchObject([
{ {
kind: "stocks", kind: "stocks",
positions: [ positions: [
+43 -7
View File
@@ -26,9 +26,14 @@ type AccountPagePosition = Portfolio["positions"][number] & {
logoSrc: string | null; logoSrc: string | null;
}; };
type AccountPagePositionGroup = {
key: string;
positions: AccountPagePosition[];
};
type AccountPagePositionSection = { type AccountPagePositionSection = {
kind: PositionSectionKind; kind: PositionSectionKind;
positions: AccountPagePosition[]; groups: AccountPagePositionGroup[];
}; };
type AccountPageRecentOperations = type AccountPageRecentOperations =
@@ -76,8 +81,9 @@ type AccountPageViewModel =
| AccountPageEmptyViewModel | AccountPageEmptyViewModel
| AccountPageLoadedViewModel; | AccountPageLoadedViewModel;
function useAccountPageViewModel(): AccountPageViewModel { function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId } = useParams(); const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const portfolio = usePortfolio(accountId); const portfolio = usePortfolio(accountId);
const operations = useBrokerageOperations(accountId, { const operations = useBrokerageOperations(accountId, {
limit: 5, limit: 5,
@@ -177,9 +183,7 @@ function toAccountPagePositionSections(
const stocks = toSection("stocks", portfolio, denominators); const stocks = toSection("stocks", portfolio, denominators);
const bonds = toSection("bonds", portfolio, denominators); const bonds = toSection("bonds", portfolio, denominators);
const other = toSection("other", portfolio, denominators); const other = toSection("other", portfolio, denominators);
return [stocks, bonds, other].filter( return [stocks, bonds, other].filter((section) => section.groups.length > 0);
(section) => section.positions.length > 0,
);
} }
function toSection( function toSection(
@@ -189,7 +193,8 @@ function toSection(
): AccountPagePositionSection { ): AccountPagePositionSection {
return { return {
kind, kind,
positions: portfolio.positions groups: groupPositions(
portfolio.positions
.filter((position) => { .filter((position) => {
if (kind === "stocks") return position.type === "STOCK"; if (kind === "stocks") return position.type === "STOCK";
if (kind === "bonds") return position.type === "BOND"; if (kind === "bonds") return position.type === "BOND";
@@ -200,9 +205,39 @@ function toSection(
); );
}) })
.map((position) => toPosition(position, denominators, kind)), .map((position) => toPosition(position, denominators, kind)),
),
}; };
} }
function groupPositions(
positions: AccountPagePosition[],
): AccountPagePositionGroup[] {
const groups = new Map<string, AccountPagePositionGroup>();
for (const [index, position] of positions.entries()) {
const identity = position.figi?.trim()
? ["figi", position.figi.trim()]
: position.isin?.trim()
? ["isin", position.isin.trim()]
: null;
const rowKey = JSON.stringify(["row", position.account?.id, index]);
const key =
position.account && identity
? JSON.stringify([position.type, ...identity])
: rowKey;
const group = groups.get(key);
if (
group &&
!group.positions.some((item) => item.account?.id === position.account?.id)
) {
group.positions.push(position);
} else {
const uniqueKey = group ? rowKey : key;
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
}
}
return [...groups.values()];
}
function toPosition( function toPosition(
position: Portfolio["positions"][number], position: Portfolio["positions"][number],
denominators: Map<string, number>, denominators: Map<string, number>,
@@ -237,6 +272,7 @@ function toPosition(
export { export {
type AccountPageIncomeSummary, type AccountPageIncomeSummary,
type AccountPagePosition, type AccountPagePosition,
type AccountPagePositionGroup,
type AccountPagePositionSection, type AccountPagePositionSection,
type AccountPageRecentEvents, type AccountPageRecentEvents,
type AccountPageRecentOperations, type AccountPageRecentOperations,
@@ -6,6 +6,7 @@ import { accountTypeKey } from "@/entities/brokerage-account";
type AccountHeadingProps = { type AccountHeadingProps = {
account?: BrokerageAccount; account?: BrokerageAccount;
accountId?: string;
}; };
const AccountHeading: FC<AccountHeadingProps> = (props) => { const AccountHeading: FC<AccountHeadingProps> = (props) => {
@@ -14,8 +15,16 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => {
return ( return (
<Stack gap={2}> <Stack gap={2}>
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title> <Title order={1}>
{account ? ( {props.accountId === "all"
? t("accounts.all")
: (account?.name ?? t("portfolio.title"))}
</Title>
{props.accountId === "all" ? (
<Text c="dimmed" size="sm">
{t("accounts.allDescription")}
</Text>
) : account ? (
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t(accountTypeKey(account.type))} {t(accountTypeKey(account.type))}
</Text> </Text>
@@ -16,6 +16,7 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = { type AccountPageEmptyProps = {
account?: BrokerageAccount; account?: BrokerageAccount;
accountId?: string;
refreshing?: boolean; refreshing?: boolean;
refreshError?: boolean; refreshError?: boolean;
retry?: () => void; retry?: () => void;
@@ -28,7 +29,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<AccountHeading account={props.account} /> <AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState <PortfolioRefreshState
refreshing={props.refreshing ?? false} refreshing={props.refreshing ?? false}
refreshError={props.refreshError ?? false} refreshError={props.refreshError ?? false}
@@ -36,7 +37,11 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
/> />
<StateView <StateView
title={t("portfolio.emptyTitle")} title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")} text={t(
props.accountId === "all"
? "portfolio.allEmptyText"
: "portfolio.emptyText",
)}
/> />
<RecentOperations operations={props.recentOperations} /> <RecentOperations operations={props.recentOperations} />
<BrokerageEventsPreview events={props.recentEvents} /> <BrokerageEventsPreview events={props.recentEvents} />
@@ -21,6 +21,7 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = { type AccountPageLoadedProps = {
account?: BrokerageAccount; account?: BrokerageAccount;
accountId?: string;
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
refreshing?: boolean; refreshing?: boolean;
@@ -35,7 +36,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack className={classes.page} gap="lg"> <Stack className={classes.page} gap="lg">
<AccountHeading account={props.account} /> <AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState <PortfolioRefreshState
refreshing={props.refreshing ?? false} refreshing={props.refreshing ?? false}
refreshError={props.refreshError ?? false} refreshError={props.refreshError ?? false}
@@ -45,9 +46,14 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Alert color="blue">{t("portfolio.staleWarning")}</Alert> <Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null} ) : null}
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("portfolio.updatedAt", { {t(
props.accountId === "all"
? "portfolio.allUpdatedAt"
: "portfolio.updatedAt",
{
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt), date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
})} },
)}
</Text> </Text>
<CurrencyCards amounts={props.portfolio.totalsByCurrency} /> <CurrencyCards amounts={props.portfolio.totalsByCurrency} />
<Accordion <Accordion
@@ -1,6 +1,7 @@
import { Card, Group, Stack, Text } from "@mantine/core"; import { Anchor, 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 { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css"; import classes from "../AccountPage.module.css";
import type { import type {
@@ -37,6 +38,15 @@ const PositionCard: FC<PositionCardProps> = (props) => {
] ]
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
const accountLink = position.account ? (
<Anchor
component={Link}
to={`/accounts/${encodeURIComponent(position.account.id)}`}
size="xs"
>
{position.account.name}
</Anchor>
) : null;
const secondaryText = [ const secondaryText = [
position.averagePurchasePrice position.averagePurchasePrice
? t("portfolio.avgPrice", { ? t("portfolio.avgPrice", {
@@ -63,6 +73,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
<Text fw={600} lineClamp={1}> <Text fw={600} lineClamp={1}>
{position.displayName} {position.displayName}
</Text> </Text>
{accountLink}
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}> <Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
{identifierText} {identifierText}
</Text> </Text>
@@ -94,6 +105,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
<Text fw={700} lineClamp={1}> <Text fw={700} lineClamp={1}>
{position.displayName} {position.displayName}
</Text> </Text>
{accountLink}
{position.isin ? ( {position.isin ? (
<Text c="dimmed" size="xs" className={classes.mono}> <Text c="dimmed" size="xs" className={classes.mono}>
{position.isin} {position.isin}
@@ -0,0 +1,107 @@
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type {
AccountPagePositionGroup,
AccountPagePositionSection,
} from "../AccountPage.vm";
import { PositionLogo } from "./PositionLogo.view";
type PositionGroupCardProps = {
group: AccountPagePositionGroup;
kind: AccountPagePositionSection["kind"];
};
const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
const { group, kind } = props;
const { t } = useTranslation();
const identity = group.positions[0];
return (
<Card className={classes.tile} withBorder>
<Group wrap="nowrap" gap="sm">
{kind !== "other" ? (
<PositionLogo
key={identity.logoSrc ?? "no-logo"}
src={identity.logoSrc}
label={identity.displayName}
/>
) : null}
<Stack gap={2} miw={0}>
<Text fw={600}>{identity.displayName}</Text>
<Text c="dimmed" size="xs">
{identity.ticker ?? identity.isin ?? identity.figi}
</Text>
</Stack>
</Group>
<Stack
component="ul"
gap={0}
className={classes.accountPositions}
aria-label={t("portfolio.byAccount")}
>
{group.positions.map((position) => (
<Stack
component="li"
key={position.account?.id}
gap={4}
className={classes.accountPosition}
>
<Group justify="space-between" align="flex-start" gap="xs">
{position.account ? (
<Anchor
component={Link}
to={`/accounts/${encodeURIComponent(position.account.id)}`}
size="sm"
className={classes.accountPositionName}
>
{position.account.name}
</Anchor>
) : null}
<Text size="sm" fw={600}>
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Group>
<Group justify="space-between" gap="xs">
{position.currentValue ? (
<Text size="sm">{formatMoney(position.currentValue)}</Text>
) : null}
{position.returnText ? (
<Text
size="sm"
className={
position.returnTone
? classes[position.returnTone]
: undefined
}
>
{kind === "other"
? t("portfolio.return", { value: position.returnText })
: position.returnText}
</Text>
) : null}
</Group>
<Group gap="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>
</Stack>
))}
</Stack>
</Card>
);
};
export { PositionGroupCard };
@@ -2,6 +2,7 @@ import { SimpleGrid } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import type { AccountPagePositionSection } from "../AccountPage.vm"; import type { AccountPagePositionSection } from "../AccountPage.vm";
import { PositionCard } from "./PositionCard.view"; import { PositionCard } from "./PositionCard.view";
import { PositionGroupCard } from "./PositionGroupCard.view";
type PositionSectionProps = { type PositionSectionProps = {
section: AccountPagePositionSection; section: AccountPagePositionSection;
@@ -13,13 +14,21 @@ const PositionSection: FC<PositionSectionProps> = (props) => {
return ( return (
<SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}> <SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}>
{section.positions.map((position) => ( {section.groups.map((group) =>
<PositionCard group.positions.length > 1 ? (
<PositionGroupCard
key={group.key}
group={group}
kind={section.kind} kind={section.kind}
key={`${position.figi}-${position.displayName}-${position.quantity}`}
position={position}
/> />
))} ) : (
<PositionCard
key={group.key}
position={group.positions[0]}
kind={section.kind}
/>
),
)}
</SimpleGrid> </SimpleGrid>
); );
}; };
@@ -31,7 +31,14 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
) : null} ) : null}
{operations.state === "loaded" {operations.state === "loaded"
? operations.items.map((operation) => ( ? operations.items.map((operation) => (
<LedgerEntryRow key={operation.id} operation={operation} /> <LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
)) ))
: null} : null}
<Button component={Link} to={operations.to} variant="subtle"> <Button component={Link} to={operations.to} variant="subtle">
@@ -0,0 +1,127 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { toAccountPagePositionSections } from "./AccountPage.vm";
import { PositionSection } from "./components/PositionSection.view";
const position: Portfolio["positions"][number] = {
account: { id: "a", name: "Основной" },
figi: "FIGI-SBER",
displayName: "Сбербанк",
type: "STOCK",
quantity: "1.25",
currentValue: { currency: "RUB", value: "375" },
averagePurchasePrice: { currency: "RUB", value: "280" },
absoluteReturn: { currency: "RUB", value: "25" },
isin: "RU0009029540",
ticker: "SBER",
logoName: null,
};
const second = {
...position,
account: { id: "b", name: "ИИС" },
quantity: "-2",
currentValue: null,
averagePurchasePrice: null,
absoluteReturn: null,
};
function sections(positions: Portfolio["positions"]) {
return toAccountPagePositionSections({
lastSuccessfulAt: "2026-09-09T12:00:00Z",
isStale: false,
totalsByCurrency: [],
cashBalances: [],
positions,
});
}
describe("Positions grouped by security", () => {
it("shows one security with exact per-account quantities and valuations", () => {
const section = sections([position, second])[0];
render(
<MantineProvider>
<MemoryRouter>
<PositionSection section={section} />
</MemoryRouter>
</MantineProvider>,
);
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
const rows = within(
screen.getByRole("list", { name: "Позиции по счетам" }),
).getAllByRole("listitem");
expect(rows).toHaveLength(2);
expect(
within(rows[0]).getByRole("link", { name: "Основной" }),
).toHaveAttribute("href", "/accounts/a");
expect(within(rows[0]).getByText("Количество: 1.25")).toBeInTheDocument();
expect(within(rows[0]).getByText("375 ₽")).toBeInTheDocument();
expect(
within(rows[0]).getByText("Средняя цена: 280 ₽"),
).toBeInTheDocument();
expect(within(rows[1]).getByRole("link", { name: "ИИС" })).toHaveAttribute(
"href",
"/accounts/b",
);
expect(within(rows[1]).getByText("Количество: -2")).toBeInTheDocument();
expect(rows[1]).not.toHaveTextContent("₽");
});
it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => {
expect(
sections([
{ ...position, figi: null },
{ ...second, figi: null },
])[0].groups,
).toHaveLength(1);
expect(
sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups,
).toHaveLength(2);
expect(
sections([
{ ...position, figi: null, isin: null },
{ ...second, figi: null, isin: null },
])[0].groups,
).toHaveLength(2);
});
it("preserves one-account cards and does not merge repeated rows on the same account", () => {
expect(
sections([position, { ...second, account: position.account }])[0].groups,
).toHaveLength(2);
expect(
sections([
{ ...position, account: undefined },
{ ...second, account: undefined },
])[0].groups,
).toHaveLength(2);
});
it("keeps currencies and purchase prices attached to their source positions", () => {
const source = [
position,
{
...second,
currentValue: { currency: "USD", value: "1.01" },
averagePurchasePrice: { currency: "USD", value: "0.99" },
},
];
const before = structuredClone(source);
const [group] = sections(source)[0].groups;
expect(
group.positions.map((item) => [
item.quantity,
item.currentValue,
item.averagePurchasePrice,
]),
).toEqual(
source.map((item) => [
item.quantity,
item.currentValue,
item.averagePurchasePrice,
]),
);
expect(source).toEqual(before);
});
});
@@ -1,6 +1,7 @@
import { Stack, Title } from "@mantine/core"; import { Button, Stack, Title } 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 { import {
type BrokerageAccountCard, type BrokerageAccountCard,
BrokerageAccountList, BrokerageAccountList,
@@ -21,6 +22,14 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
<Stack> <Stack>
<Title order={1}>{t("accounts.title")}</Title> <Title order={1}>{t("accounts.title")}</Title>
<AccountsRefreshState {...refresh} /> <AccountsRefreshState {...refresh} />
<Button
component={Link}
to="/accounts/all"
variant="light"
w="fit-content"
>
{t("accounts.openAll")}
</Button>
<BrokerageAccountList accounts={accounts} /> <BrokerageAccountList accounts={accounts} />
</Stack> </Stack>
); );
+417
View File
@@ -116,6 +116,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/brokerage-accounts/all/operations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["BrokerageOperationsController_all"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/brokerage-accounts/{accountId}/operations": { "/api/v1/brokerage-accounts/{accountId}/operations": {
parameters: { parameters: {
query?: never; query?: never;
@@ -132,6 +148,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/brokerage-accounts/all/portfolio": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["PortfolioController_all"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/brokerage-accounts/{accountId}/portfolio": { "/api/v1/brokerage-accounts/{accountId}/portfolio": {
parameters: { parameters: {
query?: never; query?: never;
@@ -148,6 +180,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/brokerage-accounts/all/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["UpcomingEventsController_all"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/brokerage-accounts/{accountId}/events": { "/api/v1/brokerage-accounts/{accountId}/events": {
parameters: { parameters: {
query?: never; query?: never;
@@ -164,6 +212,38 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/brokerage-accounts/all/income-summary": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["IncomeAnalyticsController_allSummary"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/brokerage-accounts/all/income-analytics": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["IncomeAnalyticsController_allAnalytics"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/brokerage-accounts/{accountId}/income-summary": { "/api/v1/brokerage-accounts/{accountId}/income-summary": {
parameters: { parameters: {
query?: never; query?: never;
@@ -253,7 +333,12 @@ export interface components {
openedOn: string | null; openedOn: string | null;
portfolioSummary: components["schemas"]["PortfolioSummaryDto"] | null; portfolioSummary: components["schemas"]["PortfolioSummaryDto"] | null;
}; };
AccountReferenceDto: {
id: string;
name: string;
};
BrokerageOperationDto: { BrokerageOperationDto: {
account?: components["schemas"]["AccountReferenceDto"];
id: string; id: string;
/** Format: date-time */ /** Format: date-time */
occurredAt: string; occurredAt: string;
@@ -281,6 +366,7 @@ export interface components {
observedAt: string; observedAt: string;
}; };
PortfolioPositionDto: { PortfolioPositionDto: {
account?: components["schemas"]["AccountReferenceDto"];
figi: string | null; figi: string | null;
displayName: string; displayName: string;
/** @enum {string} */ /** @enum {string} */
@@ -302,6 +388,7 @@ export interface components {
cashBalances: components["schemas"]["MoneyDto"][]; cashBalances: components["schemas"]["MoneyDto"][];
}; };
UpcomingEventDto: { UpcomingEventDto: {
account?: components["schemas"]["AccountReferenceDto"];
/** @enum {string} */ /** @enum {string} */
type: "coupon" | "dividend" | "maturity" | "offer"; type: "coupon" | "dividend" | "maturity" | "offer";
/** Format: date */ /** Format: date */
@@ -369,6 +456,7 @@ export interface components {
receiptCount: number; receiptCount: number;
}; };
IncomeAnalyticsEntryDto: { IncomeAnalyticsEntryDto: {
account?: components["schemas"]["AccountReferenceDto"];
id: string; id: string;
/** Format: date-time */ /** Format: date-time */
occurredAt: string; occurredAt: string;
@@ -995,6 +1083,76 @@ export interface operations {
}; };
}; };
}; };
BrokerageOperationsController_all: {
parameters: {
query?: {
from?: string;
to?: string;
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
cursor?: string;
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BrokerageOperationsPageDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
BrokerageOperationsController_get: { BrokerageOperationsController_get: {
parameters: { parameters: {
query?: { query?: {
@@ -1067,6 +1225,70 @@ export interface operations {
}; };
}; };
}; };
PortfolioController_all: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PortfolioDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
PortfolioController_get: { PortfolioController_get: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1133,6 +1355,70 @@ export interface operations {
}; };
}; };
}; };
UpcomingEventsController_all: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UpcomingEventsDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
UpcomingEventsController_get: { UpcomingEventsController_get: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1199,6 +1485,137 @@ export interface operations {
}; };
}; };
}; };
IncomeAnalyticsController_allSummary: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IncomeSummaryDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
IncomeAnalyticsController_allAnalytics: {
parameters: {
query?: {
from?: string;
to?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IncomeAnalyticsDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
IncomeAnalyticsController_summary: { IncomeAnalyticsController_summary: {
parameters: { parameters: {
query?: never; query?: never;
+483
View File
@@ -680,6 +680,139 @@
] ]
} }
}, },
"/api/v1/brokerage-accounts/all/operations": {
"get": {
"operationId": "BrokerageOperationsController_all",
"parameters": [
{
"name": "from",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "to",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "categories",
"required": false,
"in": "query",
"schema": {
"type": "array",
"items": {
"type": "string",
"enum": [
"trade",
"income",
"tax",
"fee",
"transfer",
"other"
]
}
}
},
{
"name": "cursor",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "limit",
"required": false,
"in": "query",
"schema": {
"minimum": 1,
"maximum": 1000,
"default": 100,
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BrokerageOperationsPageDto"
}
}
}
},
"400": {
"description": "Invalid request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"401": {
"description": "Authentication required",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"404": {
"description": "Resource not found",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"503": {
"description": "Upstream service unavailable",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
}
},
"security": [
{
"bearer": []
}
],
"tags": [
"brokerage-accounts"
]
}
},
"/api/v1/brokerage-accounts/{accountId}/operations": { "/api/v1/brokerage-accounts/{accountId}/operations": {
"get": { "get": {
"operationId": "BrokerageOperationsController_get", "operationId": "BrokerageOperationsController_get",
@@ -821,6 +954,82 @@
] ]
} }
}, },
"/api/v1/brokerage-accounts/all/portfolio": {
"get": {
"operationId": "PortfolioController_all",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PortfolioDto"
}
}
}
},
"400": {
"description": "Invalid request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"401": {
"description": "Authentication required",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"404": {
"description": "Resource not found",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"503": {
"description": "Upstream service unavailable",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
}
},
"security": [
{
"bearer": []
}
],
"tags": [
"brokerage-accounts"
]
}
},
"/api/v1/brokerage-accounts/{accountId}/portfolio": { "/api/v1/brokerage-accounts/{accountId}/portfolio": {
"get": { "get": {
"operationId": "PortfolioController_get", "operationId": "PortfolioController_get",
@@ -906,6 +1115,82 @@
] ]
} }
}, },
"/api/v1/brokerage-accounts/all/events": {
"get": {
"operationId": "UpcomingEventsController_all",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpcomingEventsDto"
}
}
}
},
"400": {
"description": "Invalid request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"401": {
"description": "Authentication required",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"404": {
"description": "Resource not found",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"503": {
"description": "Upstream service unavailable",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
}
},
"security": [
{
"bearer": []
}
],
"tags": [
"brokerage-accounts"
]
}
},
"/api/v1/brokerage-accounts/{accountId}/events": { "/api/v1/brokerage-accounts/{accountId}/events": {
"get": { "get": {
"operationId": "UpcomingEventsController_get", "operationId": "UpcomingEventsController_get",
@@ -991,6 +1276,177 @@
] ]
} }
}, },
"/api/v1/brokerage-accounts/all/income-summary": {
"get": {
"operationId": "IncomeAnalyticsController_allSummary",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IncomeSummaryDto"
}
}
}
},
"400": {
"description": "Invalid request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"401": {
"description": "Authentication required",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"404": {
"description": "Resource not found",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"503": {
"description": "Upstream service unavailable",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
}
},
"security": [
{
"bearer": []
}
],
"tags": [
"brokerage-accounts"
]
}
},
"/api/v1/brokerage-accounts/all/income-analytics": {
"get": {
"operationId": "IncomeAnalyticsController_allAnalytics",
"parameters": [
{
"name": "from",
"required": false,
"in": "query",
"schema": {
"format": "date",
"type": "string"
}
},
{
"name": "to",
"required": false,
"in": "query",
"schema": {
"format": "date",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IncomeAnalyticsDto"
}
}
}
},
"400": {
"description": "Invalid request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"401": {
"description": "Authentication required",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"404": {
"description": "Resource not found",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
},
"503": {
"description": "Upstream service unavailable",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetailsDto"
}
}
}
}
},
"security": [
{
"bearer": []
}
],
"tags": [
"brokerage-accounts"
]
}
},
"/api/v1/brokerage-accounts/{accountId}/income-summary": { "/api/v1/brokerage-accounts/{accountId}/income-summary": {
"get": { "get": {
"operationId": "IncomeAnalyticsController_summary", "operationId": "IncomeAnalyticsController_summary",
@@ -1367,9 +1823,27 @@
"portfolioSummary" "portfolioSummary"
] ]
}, },
"AccountReferenceDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
]
},
"BrokerageOperationDto": { "BrokerageOperationDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"account": {
"$ref": "#/components/schemas/AccountReferenceDto"
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -1508,6 +1982,9 @@
"PortfolioPositionDto": { "PortfolioPositionDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"account": {
"$ref": "#/components/schemas/AccountReferenceDto"
},
"figi": { "figi": {
"type": "string", "type": "string",
"nullable": true "nullable": true
@@ -1625,6 +2102,9 @@
"UpcomingEventDto": { "UpcomingEventDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"account": {
"$ref": "#/components/schemas/AccountReferenceDto"
},
"type": { "type": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -1900,6 +2380,9 @@
"IncomeAnalyticsEntryDto": { "IncomeAnalyticsEntryDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"account": {
"$ref": "#/components/schemas/AccountReferenceDto"
},
"id": { "id": {
"type": "string" "type": "string"
}, },
+7
View File
@@ -28,6 +28,10 @@ const ru = {
}, },
}, },
accounts: { accounts: {
all: "Все счета",
openAll: "Портфель по всем счетам",
scope: "Счёт",
allDescription: "Портфель по всем доступным инвестиционным счетам",
title: "Счета", title: "Счета",
emptyTitle: "Нет инвестиционных счетов", emptyTitle: "Нет инвестиционных счетов",
emptyText: emptyText:
@@ -62,6 +66,9 @@ const ru = {
}, },
account: { title: "Счет" }, account: { title: "Счет" },
portfolio: { portfolio: {
byAccount: "Позиции по счетам",
allEmptyText: "На доступных счетах нет позиций и денежных остатков.",
allUpdatedAt: "Самые ранние данные в сводке: {{date}}",
currencies: { currencies: {
RUB: "Российский рубль", RUB: "Российский рубль",
USD: "Доллар США", USD: "Доллар США",
+6
View File
@@ -21,6 +21,12 @@ class ResizeObserver {
window.ResizeObserver = ResizeObserver; window.ResizeObserver = ResizeObserver;
// JSDOM has no layout/scrolling; Mantine Combobox scrolls the active option.
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: () => {},
});
Object.defineProperty(window, "matchMedia", { Object.defineProperty(window, "matchMedia", {
writable: true, writable: true,
value: (query: string) => ({ value: (query: string) => ({