diff --git a/src/app/routes/AppRoutes.test.tsx b/src/app/routes/AppRoutes.test.tsx
index 17e30d9..5eabbcf 100644
--- a/src/app/routes/AppRoutes.test.tsx
+++ b/src/app/routes/AppRoutes.test.tsx
@@ -35,6 +35,7 @@ describe("AppRoutes", () => {
["/register", "Регистрация"],
["/accounts", "Вход"],
["/accounts/demo", "Вход"],
+ ["/accounts/demo/operations", "Вход"],
["/settings", "Вход"],
["/unknown", "Страница не найдена"],
])("renders %s with its page title", async (path, title) => {
diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes.tsx
index 1f61bc2..85dc1a8 100644
--- a/src/app/routes/AppRoutes.tsx
+++ b/src/app/routes/AppRoutes.tsx
@@ -1,6 +1,7 @@
import { Navigate, Route, Routes, useLocation } from "react-router";
import { useAuth } from "@/features/auth";
import { AccountPage } from "@/pages/account";
+import { AccountOperationsPage } from "@/pages/account-operations";
import { AccountsPage } from "@/pages/accounts";
import { LoginPage } from "@/pages/login";
import { NotFoundPage } from "@/pages/not-found";
@@ -58,6 +59,10 @@ export function AppRoutes() {
>
} />
} />
+ }
+ />
} />
} />
diff --git a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx
new file mode 100644
index 0000000..6761e89
--- /dev/null
+++ b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx
@@ -0,0 +1,34 @@
+import { MantineProvider } from "@mantine/core";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it } from "vitest";
+import {
+ brokerageOperationsQueryParams,
+ useBrokerageOperations,
+} from "./useBrokerageOperations";
+
+describe("useBrokerageOperations", () => {
+ it("normalizes query parameters and loads a page without retries", async () => {
+ expect(
+ brokerageOperationsQueryParams({
+ categories: ["tax", "income"],
+ cursor: "next",
+ }),
+ ).toEqual({ categories: ["income", "tax"], cursor: "next" });
+ const client = new QueryClient();
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(
+ () => useBrokerageOperations("account-1", "token", { limit: 5 }),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toMatchObject({ items: [] });
+ });
+});
diff --git a/src/entities/broker-operation/api/useBrokerageOperations.ts b/src/entities/broker-operation/api/useBrokerageOperations.ts
new file mode 100644
index 0000000..22c4b4c
--- /dev/null
+++ b/src/entities/broker-operation/api/useBrokerageOperations.ts
@@ -0,0 +1,55 @@
+import { useQuery } from "@tanstack/react-query";
+import { apiQueryClient } from "@/shared/api";
+import type { paths } from "@/shared/api/generated/schema";
+
+type OperationsEndpoint =
+ paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
+
+export type BrokerageOperation =
+ OperationsEndpoint["responses"][200]["content"]["application/json"]["items"][number];
+type BrokerageOperationsQuery = NonNullable<
+ OperationsEndpoint["parameters"]["query"]
+>;
+export type OperationCategory = BrokerageOperation["category"];
+
+export const operationCategories = [
+ "trade",
+ "income",
+ "tax",
+ "fee",
+ "transfer",
+ "other",
+] as const satisfies readonly OperationCategory[];
+
+export function brokerageOperationsQueryParams(
+ query: BrokerageOperationsQuery,
+) {
+ return {
+ ...(query.from ? { from: query.from } : {}),
+ ...(query.to ? { to: query.to } : {}),
+ ...(query.categories?.length
+ ? { categories: [...query.categories].sort() }
+ : {}),
+ ...(query.cursor ? { cursor: query.cursor } : {}),
+ ...(query.limit ? { limit: query.limit } : {}),
+ };
+}
+
+export function useBrokerageOperations(
+ accountId: string | undefined,
+ accessToken: string | null,
+ query: BrokerageOperationsQuery = {},
+) {
+ const params = brokerageOperationsQueryParams(query);
+ return useQuery({
+ queryKey: ["brokerage-operations", accountId ?? "", params] as const,
+ enabled: Boolean(accountId && accessToken),
+ staleTime: 30_000,
+ retry: false,
+ ...apiQueryClient.queryOptions(
+ "get",
+ "/api/v1/brokerage-accounts/{accountId}/operations",
+ { params: { path: { accountId: accountId ?? "" }, query: params } },
+ ),
+ });
+}
diff --git a/src/entities/broker-operation/index.ts b/src/entities/broker-operation/index.ts
new file mode 100644
index 0000000..f79e9e7
--- /dev/null
+++ b/src/entities/broker-operation/index.ts
@@ -0,0 +1,8 @@
+export type {
+ BrokerageOperation,
+ OperationCategory,
+} from "./api/useBrokerageOperations";
+export {
+ operationCategories,
+ useBrokerageOperations,
+} from "./api/useBrokerageOperations";
diff --git a/src/pages/account-operations/index.ts b/src/pages/account-operations/index.ts
new file mode 100644
index 0000000..148dd00
--- /dev/null
+++ b/src/pages/account-operations/index.ts
@@ -0,0 +1 @@
+export { AccountOperationsPage } from "./ui/AccountOperationsPage";
diff --git a/src/pages/account-operations/ui/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage.test.tsx
new file mode 100644
index 0000000..ae0aa47
--- /dev/null
+++ b/src/pages/account-operations/ui/AccountOperationsPage.test.tsx
@@ -0,0 +1,116 @@
+import { MantineProvider } from "@mantine/core";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { HttpResponse, http } from "msw";
+import { MemoryRouter, Route, Routes } from "react-router";
+import { describe, expect, it, vi } from "vitest";
+import type { BrokerageOperation } from "@/entities/broker-operation";
+import { server } from "@/test/server";
+import { AccountOperationsPage, OperationRow } from "./AccountOperationsPage";
+
+vi.mock("@/features/auth", () => ({
+ useAuth: () => ({ accessToken: "token" }),
+}));
+
+const operation: BrokerageOperation = {
+ id: "operation-1",
+ occurredAt: "2026-08-14T10:00:00Z",
+ type: "OPERATION_TYPE_BUY",
+ category: "trade",
+ description: "Покупка акций",
+ figi: null,
+ instrumentUid: null,
+ ticker: "SBER",
+ instrumentType: "share",
+ payment: null,
+ quantity: "2",
+ price: { currency: "RUB", value: "100" },
+ accruedInterest: null,
+ commission: { currency: "RUB", value: "1" },
+};
+
+describe("OperationRow", () => {
+ it("marks cashless operations without rendering operation details", () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText("Без движения денег")).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Показать детали" }),
+ ).toBeNull();
+ expect(screen.queryByText("Цена: 100 ₽")).toBeNull();
+ expect(screen.queryByText("Количество: 2")).toBeNull();
+ });
+});
+
+describe("AccountOperationsPage", () => {
+ it("resets cursor pagination when filters change", async () => {
+ const searches: string[] = [];
+ server.use(
+ http.get(
+ "*/api/v1/brokerage-accounts/:accountId/operations",
+ ({ request }) => {
+ const url = new URL(request.url);
+ searches.push(url.search);
+ return HttpResponse.json({
+ accountId: "account-1",
+ items: [operation],
+ nextCursor: url.searchParams.get("cursor") ? null : "next-1",
+ hasNext: !url.searchParams.get("cursor"),
+ observedAt: "2026-08-14T00:00:00Z",
+ });
+ },
+ ),
+ );
+ render(
+
+
+
+
+ }
+ />
+
+
+
+ ,
+ );
+
+ fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
+ await waitFor(() => expect(searches.at(-1)).toContain("cursor=next-1"));
+ fireEvent.click(screen.getByRole("checkbox", { name: "Налог" }));
+ await waitFor(() => expect(searches.at(-1)).toContain("categories=tax"));
+ expect(searches.at(-1)).not.toContain("cursor=");
+ });
+
+ it("renders an error state", async () => {
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
+ HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
+ ),
+ );
+ render(
+
+
+
+
+ }
+ />
+
+
+
+ ,
+ );
+ expect(
+ await screen.findByText("Не удалось загрузить журнал"),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/account-operations/ui/AccountOperationsPage.tsx b/src/pages/account-operations/ui/AccountOperationsPage.tsx
new file mode 100644
index 0000000..eaddb7a
--- /dev/null
+++ b/src/pages/account-operations/ui/AccountOperationsPage.tsx
@@ -0,0 +1,177 @@
+import {
+ Button,
+ Card,
+ Checkbox,
+ Group,
+ Stack,
+ Text,
+ TextInput,
+ Title,
+} from "@mantine/core";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Link, useParams } from "react-router";
+import {
+ type BrokerageOperation,
+ type OperationCategory,
+ operationCategories,
+ useBrokerageOperations,
+} from "@/entities/broker-operation";
+import { formatMoney, formatSnapshotTime } from "@/entities/portfolio";
+import { useAuth } from "@/features/auth";
+import { StateView } from "@/shared/ui";
+
+function dateStart(value: string) {
+ return value ? `${value}T00:00:00.000Z` : undefined;
+}
+
+function dateEnd(value: string) {
+ return value ? `${value}T23:59:59.999Z` : undefined;
+}
+
+export function AccountOperationsPage() {
+ const { accountId } = useParams();
+ const { accessToken } = useAuth();
+ const { t } = useTranslation();
+ const [from, setFrom] = useState("");
+ const [to, setTo] = useState("");
+ const [categories, setCategories] = useState([]);
+ const [cursors, setCursors] = useState([]);
+ const cursor = cursors.at(-1);
+ const operations = useBrokerageOperations(accountId, accessToken, {
+ from: dateStart(from),
+ to: dateEnd(to),
+ ...(categories.length ? { categories } : {}),
+ ...(cursor ? { cursor } : {}),
+ limit: 100,
+ });
+ const reset = () => setCursors([]);
+
+ if (operations.isLoading)
+ return (
+
+ );
+ if (operations.isError)
+ return (
+ operations.refetch()}>
+ {t("ledger.retry")}
+
+ }
+ />
+ );
+ return (
+
+
+ {t("ledger.title")}
+
+
+
+ {
+ setFrom(event.currentTarget.value);
+ reset();
+ }}
+ />
+ {
+ setTo(event.currentTarget.value);
+ reset();
+ }}
+ />
+
+ {
+ setCategories(value as OperationCategory[]);
+ reset();
+ }}
+ >
+
+ {operationCategories.map((category) => (
+
+ ))}
+
+
+ {operations.data.items.length ? (
+ operations.data.items.map((operation) => (
+
+ ))
+ ) : (
+
+ )}
+ {operations.data.hasNext || cursors.length ? (
+
+
+
+
+ ) : null}
+
+ );
+}
+
+export function OperationRow({ operation }: { operation: BrokerageOperation }) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {operation.description || operation.type}
+
+ {formatSnapshotTime(operation.occurredAt)} ·{" "}
+ {operation.ticker ??
+ operation.instrumentType ??
+ t("ledger.noInstrument")}
+
+
+
+
+ {operation.payment
+ ? formatMoney(operation.payment)
+ : t("ledger.noMoney")}
+
+
+ {t(`ledger.category.${operation.category}`)}
+
+
+
+
+ );
+}
diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx
index 7f7f50a..0377d4d 100644
--- a/src/pages/account/ui/AccountPage.test.tsx
+++ b/src/pages/account/ui/AccountPage.test.tsx
@@ -1,8 +1,11 @@
import { MantineProvider } from "@mantine/core";
-import { cleanup, render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
+import { server } from "@/test/server";
import {
AccountPageEmpty,
AccountPageError,
@@ -12,9 +15,11 @@ import {
function renderPage(ui: React.ReactNode) {
return render(
-
- {ui}
- ,
+
+
+ {ui}
+
+ ,
);
}
@@ -114,4 +119,26 @@ describe("AccountPage states", () => {
screen.getByRole("heading", { level: 1, name: "Основной" }),
).toBeInTheDocument();
});
+
+ it("keeps the portfolio visible when recent events fail", async () => {
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
+ HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
+ ),
+ );
+ renderPage(
+ ,
+ );
+
+ await waitFor(() =>
+ expect(
+ screen.getByText("Не удалось загрузить последние события."),
+ ).toBeInTheDocument(),
+ );
+ expect(screen.getByText("Вторая")).toBeInTheDocument();
+ });
});
diff --git a/src/pages/account/ui/AccountPage.tsx b/src/pages/account/ui/AccountPage.tsx
index 5706d87..4f36ea6 100644
--- a/src/pages/account/ui/AccountPage.tsx
+++ b/src/pages/account/ui/AccountPage.tsx
@@ -11,10 +11,18 @@ export function AccountPage() {
if (viewModel.state === "loading") return ;
if (viewModel.state === "error") return ;
if (viewModel.state === "empty")
- return ;
+ return (
+
+ );
return (
);
diff --git a/src/pages/account/ui/AccountPage.view.tsx b/src/pages/account/ui/AccountPage.view.tsx
index 2f70f25..3b031df 100644
--- a/src/pages/account/ui/AccountPage.view.tsx
+++ b/src/pages/account/ui/AccountPage.view.tsx
@@ -11,6 +11,7 @@ import {
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
+import { useBrokerageOperations } from "@/entities/broker-operation";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { accountTypeKey } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
@@ -80,7 +81,15 @@ export function AccountPageError({
/>
);
}
-export function AccountPageEmpty({ account }: { account?: BrokerageAccount }) {
+export function AccountPageEmpty({
+ account,
+ accountId,
+ accessToken = null,
+}: {
+ account?: BrokerageAccount;
+ accountId?: string;
+ accessToken?: string | null;
+}) {
const { t } = useTranslation();
return (
@@ -89,14 +98,19 @@ export function AccountPageEmpty({ account }: { account?: BrokerageAccount }) {
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
+
);
}
export function AccountPageLoaded({
account,
+ accountId,
+ accessToken,
portfolio,
}: {
account?: BrokerageAccount;
+ accountId?: string;
+ accessToken?: string | null;
portfolio: Portfolio;
}) {
const { t } = useTranslation();
@@ -148,9 +162,55 @@ export function AccountPageLoaded({
))}
+
);
}
+function RecentOperations({
+ accountId,
+ accessToken,
+}: {
+ accountId: string | undefined;
+ accessToken: string | null;
+}) {
+ const { t } = useTranslation();
+ const operations = useBrokerageOperations(accountId, accessToken, {
+ limit: 5,
+ });
+ return (
+
+ {operations.isLoading ? (
+ {t("ledger.recentLoading")}
+ ) : null}
+ {operations.isError ? (
+ {t("ledger.recentError")}
+ ) : null}
+ {operations.data?.items.length === 0 ? (
+ {t("ledger.recentEmpty")}
+ ) : null}
+ {operations.data?.items.map((operation) => (
+
+ {operation.description || operation.type}
+
+ {operation.payment
+ ? formatMoney(operation.payment)
+ : t("ledger.noMoney")}
+
+
+ ))}
+
+
+ );
+}
function PositionSection({
title,
positions,
diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts
index 2369182..77c42b7 100644
--- a/src/pages/account/ui/AccountPage.vm.ts
+++ b/src/pages/account/ui/AccountPage.vm.ts
@@ -22,6 +22,12 @@ export function useAccountPageViewModel() {
if (portfolio.isFetching || !portfolio.data)
return { state: "loading" as const };
if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
- return { state: "empty" as const, account };
- return { state: "loaded" as const, account, portfolio: portfolio.data };
+ return { state: "empty" as const, account, accountId, accessToken };
+ return {
+ state: "loaded" as const,
+ account,
+ accountId,
+ accessToken,
+ portfolio: portfolio.data,
+ };
}
diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts
index 9a4bb86..6ca14ee 100644
--- a/src/shared/api/generated/schema.d.ts
+++ b/src/shared/api/generated/schema.d.ts
@@ -116,6 +116,22 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/brokerage-accounts/{accountId}/operations": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["BrokerageOperationsController_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/brokerage-accounts/{accountId}/portfolio": {
parameters: {
query?: never;
@@ -185,6 +201,32 @@ export interface components {
currency: string;
value: string;
};
+ BrokerageOperationDto: {
+ id: string;
+ /** Format: date-time */
+ occurredAt: string;
+ type: string;
+ /** @enum {string} */
+ category: "trade" | "income" | "tax" | "fee" | "transfer" | "other";
+ description: string;
+ figi: string | null;
+ instrumentUid: string | null;
+ ticker: string | null;
+ instrumentType: string | null;
+ payment: components["schemas"]["MoneyDto"] | null;
+ quantity: string | null;
+ price: components["schemas"]["MoneyDto"] | null;
+ accruedInterest: components["schemas"]["MoneyDto"] | null;
+ commission: components["schemas"]["MoneyDto"] | null;
+ };
+ BrokerageOperationsPageDto: {
+ accountId: string;
+ items: components["schemas"]["BrokerageOperationDto"][];
+ nextCursor: string | null;
+ hasNext: boolean;
+ /** Format: date-time */
+ observedAt: string;
+ };
PortfolioPositionDto: {
figi: string | null;
displayName: string;
@@ -715,6 +757,69 @@ export interface operations {
};
};
};
+ BrokerageOperationsController_get: {
+ parameters: {
+ query?: {
+ from?: string;
+ to?: string;
+ categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
+ cursor?: string;
+ limit?: number;
+ };
+ header?: never;
+ path: {
+ accountId: string;
+ };
+ 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"];
+ };
+ };
+ };
+ };
PortfolioController_get: {
parameters: {
query?: never;
diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json
index 48d2e02..8052431 100644
--- a/src/shared/api/spec/openapi.json
+++ b/src/shared/api/spec/openapi.json
@@ -590,6 +590,137 @@
]
}
},
+ "/api/v1/brokerage-accounts/{accountId}/operations": {
+ "get": {
+ "operationId": "BrokerageOperationsController_get",
+ "parameters": [
+ {
+ "name": "accountId",
+ "required": true,
+ "in": "path",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "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"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "tags": [
+ "brokerage-accounts"
+ ]
+ }
+ },
"/api/v1/brokerage-accounts/{accountId}/portfolio": {
"get": {
"operationId": "PortfolioController_get",
@@ -829,6 +960,139 @@
"value"
]
},
+ "BrokerageOperationDto": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "occurredAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "type": {
+ "type": "string"
+ },
+ "category": {
+ "type": "string",
+ "enum": [
+ "trade",
+ "income",
+ "tax",
+ "fee",
+ "transfer",
+ "other"
+ ]
+ },
+ "description": {
+ "type": "string"
+ },
+ "figi": {
+ "type": "string",
+ "nullable": true
+ },
+ "instrumentUid": {
+ "type": "string",
+ "nullable": true
+ },
+ "ticker": {
+ "type": "string",
+ "nullable": true
+ },
+ "instrumentType": {
+ "type": "string",
+ "nullable": true
+ },
+ "payment": {
+ "nullable": true,
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/MoneyDto"
+ }
+ ]
+ },
+ "quantity": {
+ "type": "string",
+ "nullable": true
+ },
+ "price": {
+ "nullable": true,
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/MoneyDto"
+ }
+ ]
+ },
+ "accruedInterest": {
+ "nullable": true,
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/MoneyDto"
+ }
+ ]
+ },
+ "commission": {
+ "nullable": true,
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/MoneyDto"
+ }
+ ]
+ }
+ },
+ "required": [
+ "id",
+ "occurredAt",
+ "type",
+ "category",
+ "description",
+ "figi",
+ "instrumentUid",
+ "ticker",
+ "instrumentType",
+ "payment",
+ "quantity",
+ "price",
+ "accruedInterest",
+ "commission"
+ ]
+ },
+ "BrokerageOperationsPageDto": {
+ "type": "object",
+ "properties": {
+ "accountId": {
+ "type": "string"
+ },
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/BrokerageOperationDto"
+ }
+ },
+ "nextCursor": {
+ "type": "string",
+ "nullable": true
+ },
+ "hasNext": {
+ "type": "boolean"
+ },
+ "observedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "required": [
+ "accountId",
+ "items",
+ "nextCursor",
+ "hasNext",
+ "observedAt"
+ ]
+ },
"PortfolioPositionDto": {
"type": "object",
"properties": {
diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts
index 4971086..b581b66 100644
--- a/src/shared/i18n/locales/ru.ts
+++ b/src/shared/i18n/locales/ru.ts
@@ -62,6 +62,35 @@ export const ru = {
quantity: "Количество: {{value}}",
return: "Доход: {{value}}",
},
+ ledger: {
+ title: "Журнал операций",
+ back: "К счету",
+ open: "Открыть журнал",
+ recentTitle: "Последние события",
+ recentLoading: "Загрузка последних событий",
+ recentError: "Не удалось загрузить последние события.",
+ recentEmpty: "Последних событий пока нет.",
+ from: "С",
+ to: "По",
+ categories: "Категории",
+ previous: "Назад",
+ next: "Далее",
+ retry: "Повторить",
+ errorTitle: "Не удалось загрузить журнал",
+ errorText: "Попробуйте запросить операции еще раз.",
+ emptyTitle: "Операций нет",
+ emptyText: "Измените период или фильтры.",
+ noMoney: "Без движения денег",
+ noInstrument: "Без инструмента",
+ category: {
+ trade: "Сделка",
+ income: "Доход",
+ tax: "Налог",
+ fee: "Комиссия",
+ transfer: "Перевод",
+ other: "Прочее",
+ },
+ },
settings: { title: "Настройки" },
tbank: {
title: "T-Bank Invest",
diff --git a/src/test/handlers.ts b/src/test/handlers.ts
index a790536..6f5e5bc 100644
--- a/src/test/handlers.ts
+++ b/src/test/handlers.ts
@@ -36,4 +36,13 @@ export const handlers = [
cashBalances: [],
}),
),
+ http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
+ HttpResponse.json({
+ accountId: "account-1",
+ items: [],
+ nextCursor: null,
+ hasNext: false,
+ observedAt: "2026-08-14T00:00:00Z",
+ }),
+ ),
];