From 4f3ee7ddb289284f8a6a0677438f270e8756a3fb Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 14 Aug 2026 13:42:31 +0300 Subject: [PATCH] feat: add operations page --- src/app/routes/AppRoutes.test.tsx | 1 + src/app/routes/AppRoutes.tsx | 5 + .../api/useBrokerageOperations.test.tsx | 34 +++ .../api/useBrokerageOperations.ts | 55 ++++ src/entities/broker-operation/index.ts | 8 + src/pages/account-operations/index.ts | 1 + .../ui/AccountOperationsPage.test.tsx | 116 ++++++++ .../ui/AccountOperationsPage.tsx | 177 ++++++++++++ src/pages/account/ui/AccountPage.test.tsx | 35 ++- src/pages/account/ui/AccountPage.tsx | 10 +- src/pages/account/ui/AccountPage.view.tsx | 62 +++- src/pages/account/ui/AccountPage.vm.ts | 10 +- src/shared/api/generated/schema.d.ts | 105 +++++++ src/shared/api/spec/openapi.json | 264 ++++++++++++++++++ src/shared/i18n/locales/ru.ts | 29 ++ src/test/handlers.ts | 9 + 16 files changed, 913 insertions(+), 8 deletions(-) create mode 100644 src/entities/broker-operation/api/useBrokerageOperations.test.tsx create mode 100644 src/entities/broker-operation/api/useBrokerageOperations.ts create mode 100644 src/entities/broker-operation/index.ts create mode 100644 src/pages/account-operations/index.ts create mode 100644 src/pages/account-operations/ui/AccountOperationsPage.test.tsx create mode 100644 src/pages/account-operations/ui/AccountOperationsPage.tsx 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", + }), + ), ];