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
+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>
}
/>
<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
path="/accounts/:accountId"
element={
+4 -2
View File
@@ -13,8 +13,10 @@ const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => {
const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation();
const accountName =
accounts?.find((account) => account.id === accountId)?.name ??
t("breadcrumbs.accountFallback");
accountId === "all"
? t("accounts.all")
: (accounts?.find((account) => account.id === accountId)?.name ??
t("breadcrumbs.accountFallback"));
const accountPath = `/accounts/${accountId}`;
const items = [
{ 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 { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AppBreadcrumbs } from "./AppBreadcrumbs";
@@ -77,7 +77,9 @@ describe("AppBreadcrumbs", () => {
it("uses the account fallback without exposing the identifier", () => {
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();
});
+38 -13
View File
@@ -1,7 +1,9 @@
import { Stack } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useMatch } from "react-router";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
const AppBreadcrumbs: FC = () => {
@@ -18,27 +20,50 @@ const AppBreadcrumbs: FC = () => {
if (operations)
return (
<AccountBreadcrumbs
accountId={operations.params.accountId ?? ""}
section="operations"
/>
<Stack gap="sm" mb="lg">
<AccountBreadcrumbs
accountId={operations.params.accountId ?? ""}
section="operations"
/>
<AccountScopeSwitch
accountId={operations.params.accountId ?? ""}
section="operations"
/>
</Stack>
);
if (events)
return (
<AccountBreadcrumbs
accountId={events.params.accountId ?? ""}
section="events"
/>
<Stack gap="sm" mb="lg">
<AccountBreadcrumbs
accountId={events.params.accountId ?? ""}
section="events"
/>
<AccountScopeSwitch
accountId={events.params.accountId ?? ""}
section="events"
/>
</Stack>
);
if (income)
return (
<AccountBreadcrumbs
accountId={income.params.accountId ?? ""}
section="income"
/>
<Stack gap="sm" mb="lg">
<AccountBreadcrumbs
accountId={income.params.accountId ?? ""}
section="income"
/>
<AccountScopeSwitch
accountId={income.params.accountId ?? ""}
section="income"
/>
</Stack>
);
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)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
if (settings)
@@ -15,9 +15,13 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
const instrument =
operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument");
const payment = paymentPresentation(operation);
const metadata = [formatSnapshotTime(operation.occurredAt), instrument].join(
" · ",
);
const metadata = [
formatSnapshotTime(operation.occurredAt),
instrument,
operation.account?.name,
]
.filter(Boolean)
.join(" · ");
return (
<EntryCard
@@ -19,6 +19,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
date,
event.dateKind === "registry" ? t("events.dateKind.registry") : null,
event.isin,
event.account?.name,
]
.filter(Boolean)
.join(" · ");
@@ -36,7 +36,15 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
{events.state === "loaded"
? events.items.map((event) => (
<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}
/>
))
@@ -5,13 +5,16 @@ import { AccountEventsPageError } from "./components/AccountEventsPageError.view
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
const AccountEventsPage: FC = () => {
const viewModel = useAccountEventsPageViewModel();
type AccountEventsPageProps = { accountId?: string };
const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
if (viewModel.state === "error")
return <AccountEventsPageError retry={viewModel.retry} />;
if (viewModel.state === "empty") return <AccountEventsPageEmpty />;
return <AccountEventsPageLoaded events={viewModel.events} />;
if (viewModel.state === "empty")
return <AccountEventsPageEmpty {...viewModel} />;
return <AccountEventsPageLoaded {...viewModel} />;
};
export { AccountEventsPage };
@@ -4,20 +4,37 @@ import {
useBrokerageEvents,
} from "@/entities/brokerage-event";
type EventsRefreshState = {
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
};
type AccountEventsPageViewModel =
| { state: "loading" }
| { state: "empty" }
| ({ state: "empty" } & EventsRefreshState)
| { state: "error"; retry: () => void }
| { state: "loaded"; events: BrokerageEvent[] };
| ({ state: "loaded"; events: BrokerageEvent[] } & EventsRefreshState);
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const { accountId } = useParams();
function useAccountEventsPageViewModel(
scopeId?: string,
): AccountEventsPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const events = useBrokerageEvents(accountId);
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.events.length) return { state: "empty" };
return { state: "loaded", events: events.data.events };
const refresh = {
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 { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
import type { EventsRefreshState } from "../AccountEventsPage.vm";
const AccountEventsPageEmpty: FC = () => {
const AccountEventsPageEmpty: FC<EventsRefreshState> = (props) => {
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<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>
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
</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 { useTranslation } from "react-i18next";
import {
type BrokerageEvent,
BrokerageEventCard,
} 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 { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<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>
<Stack gap="sm">
{props.events.map((event) => (
<BrokerageEventCard
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>
@@ -4,8 +4,10 @@ import { AccountIncomePageError } from "./components/AccountIncomePageError.view
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
const AccountIncomePage: FC = () => {
const viewModel = useAccountIncomePageViewModel();
type AccountIncomePageProps = { accountId?: string };
const AccountIncomePage: FC<AccountIncomePageProps> = (props) => {
const viewModel = useAccountIncomePageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountIncomePageLoading />;
if (viewModel.state === "error")
return <AccountIncomePageError {...viewModel} />;
@@ -58,8 +58,11 @@ const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine(
{ path: ["to"], ...validationMessage("income.validation.period")({}) },
);
const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
const { accountId } = useParams();
const useAccountIncomePageViewModel = (
scopeId?: string,
): AccountIncomePageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const [searchParams, setSearchParams] = useSearchParams();
const requested = useMemo(
() => ({
@@ -4,8 +4,10 @@ import { AccountOperationsPageError } from "./components/AccountOperationsPageEr
import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view";
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
const AccountOperationsPage: FC = () => {
const viewModel = useAccountOperationsPageVm();
type AccountOperationsPageProps = { accountId?: string };
const AccountOperationsPage: FC<AccountOperationsPageProps> = (props) => {
const viewModel = useAccountOperationsPageVm(props.accountId);
if (viewModel.state === "loading") {
return <AccountOperationsPageLoading />;
@@ -1,18 +1,24 @@
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useParams } from "react-router";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import {
type BrokerageOperation,
brokerageOperationsQueryOptions,
type OperationCategory,
operationCategories,
useBrokerageOperations,
} from "@/entities/broker-operation";
import { apiErrorCode } from "@/shared/api";
type AccountOperationsPageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void }
| {
state: "loaded";
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
draftFrom: string | null;
draftTo: string | null;
draftCategories: OperationCategory[];
@@ -29,37 +35,76 @@ type AccountOperationsPageViewModel =
onPrevious: () => void;
};
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const { accountId } = useParams();
const useAccountOperationsPageVm = (
scopeId?: string,
): AccountOperationsPageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const queryClient = useQueryClient();
const [appliedFrom, setAppliedFrom] = useState<string | null>(null);
const [appliedTo, setAppliedTo] = useState<string | null>(null);
const [appliedCategories, setAppliedCategories] = useState<
OperationCategory[]
>([]);
const [draftFrom, setDraftFrom] = useState<string | null>(null);
const [draftTo, setDraftTo] = useState<string | null>(null);
const [draftCategories, setDraftCategories] = useState<OperationCategory[]>(
[],
const [searchParams, setSearchParams] = useSearchParams();
const appliedFrom = searchParams.get("from");
const appliedTo = searchParams.get("to");
const appliedCategories = operationCategories.filter((category) =>
(searchParams.get("categories") ?? "").split(",").includes(category),
);
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 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),
to: dateEnd(appliedTo),
...(appliedCategories.length ? { categories: appliedCategories } : {}),
...(cursor ? { cursor } : {}),
limit: 100,
};
const operations = useBrokerageOperations(accountId, {
...firstPageQuery,
...(cursor ? { cursor } : {}),
});
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
limit: 100,
}).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.isError)
return { state: "error", retry: () => operations.refetch() };
if (
operations.isError &&
(!operations.data ||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
)
return { state: "error", retry };
if (!operations.data) return { state: "loading" };
const nextCursor = operations.data.nextCursor;
@@ -70,6 +115,9 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
return {
state: "loaded",
refreshing: operations.isFetching,
refreshError: operations.isError,
retry,
draftFrom,
draftTo,
draftCategories,
@@ -81,25 +129,29 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
onDraftToChange: setDraftTo,
onDraftCategoriesChange: setDraftCategories,
onApply: () => {
setAppliedFrom(draftFrom);
setAppliedTo(draftTo);
setAppliedCategories(draftCategories);
setSearchParams({
...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}),
...(draftCategories.length
? { categories: draftCategories.join(",") }
: {}),
});
resetCursors();
},
onReset: () => {
setDraftFrom(null);
setDraftTo(null);
setDraftCategories([]);
setAppliedFrom(null);
setAppliedTo(null);
setAppliedCategories([]);
setSearchParams({});
resetCursors();
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
},
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 type { FC } from "react";
import { useTranslation } from "react-i18next";
@@ -67,6 +75,17 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
return (
<Stack maw={960} gap="lg">
<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">
<DateInput
label={t("ledger.from")}
@@ -103,7 +122,14 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
</Group>
{items.length ? (
items.map((operation) => (
<LedgerEntryRow key={operation.id} operation={operation} />
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
))
) : (
<StateView
@@ -124,3 +124,24 @@
padding-left: 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 { AccountPageLoading } from "./components/AccountPageLoading.view";
const AccountPage: FC = () => {
const viewModel = useAccountPageViewModel();
type AccountPageProps = { accountId?: string };
const AccountPage: FC<AccountPageProps> = (props) => {
const viewModel = useAccountPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountPageLoading />;
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
if (viewModel.state === "empty")
return (
<AccountPageEmpty
account={viewModel.account}
accountId={viewModel.accountId}
refreshing={viewModel.refreshing}
refreshError={viewModel.refreshError}
retry={viewModel.retry}
@@ -24,6 +27,7 @@ const AccountPage: FC = () => {
return (
<AccountPageLoaded
account={viewModel.account}
accountId={viewModel.accountId}
portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections}
refreshing={viewModel.refreshing}
+10 -5
View File
@@ -172,13 +172,13 @@ describe("useAccountPageViewModel", () => {
"bonds",
"other",
]);
expect(sections[2].positions.map((position) => position.figi)).toEqual([
expect(sections[2].groups.map((group) => group.positions[0].figi)).toEqual([
"FIGI-FUND",
...otherTypes.map((type) => `FIGI-${type}-NEXT`),
]);
expect(sections[0].positions[0].shareText).toBe("40 %");
expect(sections[1].positions[0].shareText).toBe("20 %");
expect(sections[2].positions[1].shareText).toBe("20 %");
expect(sections[0].groups[0].positions[0].shareText).toBe("40 %");
expect(sections[1].groups[0].positions[0].shareText).toBe("20 %");
expect(sections[2].groups[1].positions[0].shareText).toBe("20 %");
expect(snapshot).toEqual(original);
});
@@ -195,7 +195,12 @@ describe("useAccountPageViewModel", () => {
typeof result.current,
{ state: "loaded" }
>;
expect(loaded.positionSections).toMatchObject([
expect(
loaded.positionSections.map(({ kind, groups }) => ({
kind,
positions: groups.flatMap((group) => group.positions),
})),
).toMatchObject([
{
kind: "stocks",
positions: [
+53 -17
View File
@@ -26,9 +26,14 @@ type AccountPagePosition = Portfolio["positions"][number] & {
logoSrc: string | null;
};
type AccountPagePositionGroup = {
key: string;
positions: AccountPagePosition[];
};
type AccountPagePositionSection = {
kind: PositionSectionKind;
positions: AccountPagePosition[];
groups: AccountPagePositionGroup[];
};
type AccountPageRecentOperations =
@@ -76,8 +81,9 @@ type AccountPageViewModel =
| AccountPageEmptyViewModel
| AccountPageLoadedViewModel;
function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams();
function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const portfolio = usePortfolio(accountId);
const operations = useBrokerageOperations(accountId, {
limit: 5,
@@ -177,9 +183,7 @@ function toAccountPagePositionSections(
const stocks = toSection("stocks", portfolio, denominators);
const bonds = toSection("bonds", portfolio, denominators);
const other = toSection("other", portfolio, denominators);
return [stocks, bonds, other].filter(
(section) => section.positions.length > 0,
);
return [stocks, bonds, other].filter((section) => section.groups.length > 0);
}
function toSection(
@@ -189,20 +193,51 @@ function toSection(
): AccountPagePositionSection {
return {
kind,
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" &&
position.type !== "CURRENCY"
);
})
.map((position) => toPosition(position, denominators, kind)),
groups: groupPositions(
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" &&
position.type !== "CURRENCY"
);
})
.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(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
@@ -237,6 +272,7 @@ function toPosition(
export {
type AccountPageIncomeSummary,
type AccountPagePosition,
type AccountPagePositionGroup,
type AccountPagePositionSection,
type AccountPageRecentEvents,
type AccountPageRecentOperations,
@@ -6,6 +6,7 @@ import { accountTypeKey } from "@/entities/brokerage-account";
type AccountHeadingProps = {
account?: BrokerageAccount;
accountId?: string;
};
const AccountHeading: FC<AccountHeadingProps> = (props) => {
@@ -14,8 +15,16 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => {
return (
<Stack gap={2}>
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
{account ? (
<Title order={1}>
{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">
{t(accountTypeKey(account.type))}
</Text>
@@ -16,6 +16,7 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = {
account?: BrokerageAccount;
accountId?: string;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
@@ -28,7 +29,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<AccountHeading account={props.account} />
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
refreshError={props.refreshError ?? false}
@@ -36,7 +37,11 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
/>
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
text={t(
props.accountId === "all"
? "portfolio.allEmptyText"
: "portfolio.emptyText",
)}
/>
<RecentOperations operations={props.recentOperations} />
<BrokerageEventsPreview events={props.recentEvents} />
@@ -21,6 +21,7 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = {
account?: BrokerageAccount;
accountId?: string;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
@@ -35,7 +36,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation();
return (
<Stack className={classes.page} gap="lg">
<AccountHeading account={props.account} />
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
refreshError={props.refreshError ?? false}
@@ -45,9 +46,14 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null}
<Text c="dimmed" size="sm">
{t("portfolio.updatedAt", {
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
})}
{t(
props.accountId === "all"
? "portfolio.allUpdatedAt"
: "portfolio.updatedAt",
{
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
},
)}
</Text>
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
<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 { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type {
@@ -37,6 +38,15 @@ const PositionCard: FC<PositionCardProps> = (props) => {
]
.filter(Boolean)
.join(" · ");
const accountLink = position.account ? (
<Anchor
component={Link}
to={`/accounts/${encodeURIComponent(position.account.id)}`}
size="xs"
>
{position.account.name}
</Anchor>
) : null;
const secondaryText = [
position.averagePurchasePrice
? t("portfolio.avgPrice", {
@@ -63,6 +73,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
<Text fw={600} lineClamp={1}>
{position.displayName}
</Text>
{accountLink}
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
{identifierText}
</Text>
@@ -94,6 +105,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
<Text fw={700} lineClamp={1}>
{position.displayName}
</Text>
{accountLink}
{position.isin ? (
<Text c="dimmed" size="xs" className={classes.mono}>
{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 { AccountPagePositionSection } from "../AccountPage.vm";
import { PositionCard } from "./PositionCard.view";
import { PositionGroupCard } from "./PositionGroupCard.view";
type PositionSectionProps = {
section: AccountPagePositionSection;
@@ -13,13 +14,21 @@ const PositionSection: FC<PositionSectionProps> = (props) => {
return (
<SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}>
{section.positions.map((position) => (
<PositionCard
kind={section.kind}
key={`${position.figi}-${position.displayName}-${position.quantity}`}
position={position}
/>
))}
{section.groups.map((group) =>
group.positions.length > 1 ? (
<PositionGroupCard
key={group.key}
group={group}
kind={section.kind}
/>
) : (
<PositionCard
key={group.key}
position={group.positions[0]}
kind={section.kind}
/>
),
)}
</SimpleGrid>
);
};
@@ -31,7 +31,14 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
) : null}
{operations.state === "loaded"
? operations.items.map((operation) => (
<LedgerEntryRow key={operation.id} operation={operation} />
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
))
: null}
<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 { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
type BrokerageAccountCard,
BrokerageAccountList,
@@ -21,6 +22,14 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
<Stack>
<Title order={1}>{t("accounts.title")}</Title>
<AccountsRefreshState {...refresh} />
<Button
component={Link}
to="/accounts/all"
variant="light"
w="fit-content"
>
{t("accounts.openAll")}
</Button>
<BrokerageAccountList accounts={accounts} />
</Stack>
);
+417
View File
@@ -116,6 +116,22 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -132,6 +148,22 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -148,6 +180,22 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -164,6 +212,38 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -253,7 +333,12 @@ export interface components {
openedOn: string | null;
portfolioSummary: components["schemas"]["PortfolioSummaryDto"] | null;
};
AccountReferenceDto: {
id: string;
name: string;
};
BrokerageOperationDto: {
account?: components["schemas"]["AccountReferenceDto"];
id: string;
/** Format: date-time */
occurredAt: string;
@@ -281,6 +366,7 @@ export interface components {
observedAt: string;
};
PortfolioPositionDto: {
account?: components["schemas"]["AccountReferenceDto"];
figi: string | null;
displayName: string;
/** @enum {string} */
@@ -302,6 +388,7 @@ export interface components {
cashBalances: components["schemas"]["MoneyDto"][];
};
UpcomingEventDto: {
account?: components["schemas"]["AccountReferenceDto"];
/** @enum {string} */
type: "coupon" | "dividend" | "maturity" | "offer";
/** Format: date */
@@ -369,6 +456,7 @@ export interface components {
receiptCount: number;
};
IncomeAnalyticsEntryDto: {
account?: components["schemas"]["AccountReferenceDto"];
id: string;
/** Format: date-time */
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: {
parameters: {
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: {
parameters: {
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: {
parameters: {
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: {
parameters: {
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": {
"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": {
"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": {
"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": {
"get": {
"operationId": "IncomeAnalyticsController_summary",
@@ -1367,9 +1823,27 @@
"portfolioSummary"
]
},
"AccountReferenceDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
]
},
"BrokerageOperationDto": {
"type": "object",
"properties": {
"account": {
"$ref": "#/components/schemas/AccountReferenceDto"
},
"id": {
"type": "string"
},
@@ -1508,6 +1982,9 @@
"PortfolioPositionDto": {
"type": "object",
"properties": {
"account": {
"$ref": "#/components/schemas/AccountReferenceDto"
},
"figi": {
"type": "string",
"nullable": true
@@ -1625,6 +2102,9 @@
"UpcomingEventDto": {
"type": "object",
"properties": {
"account": {
"$ref": "#/components/schemas/AccountReferenceDto"
},
"type": {
"type": "string",
"enum": [
@@ -1900,6 +2380,9 @@
"IncomeAnalyticsEntryDto": {
"type": "object",
"properties": {
"account": {
"$ref": "#/components/schemas/AccountReferenceDto"
},
"id": {
"type": "string"
},
+7
View File
@@ -28,6 +28,10 @@ const ru = {
},
},
accounts: {
all: "Все счета",
openAll: "Портфель по всем счетам",
scope: "Счёт",
allDescription: "Портфель по всем доступным инвестиционным счетам",
title: "Счета",
emptyTitle: "Нет инвестиционных счетов",
emptyText:
@@ -62,6 +66,9 @@ const ru = {
},
account: { title: "Счет" },
portfolio: {
byAccount: "Позиции по счетам",
allEmptyText: "На доступных счетах нет позиций и денежных остатков.",
allUpdatedAt: "Самые ранние данные в сводке: {{date}}",
currencies: {
RUB: "Российский рубль",
USD: "Доллар США",
+6
View File
@@ -21,6 +21,12 @@ class 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", {
writable: true,
value: (query: string) => ({