diff --git a/.ai/conventions/frontend-ui.md b/.ai/conventions/frontend-ui.md index 9b7e494..a4d9c8d 100644 --- a/.ai/conventions/frontend-ui.md +++ b/.ai/conventions/frontend-ui.md @@ -2,8 +2,16 @@ ## Выбор компонента и state -Используй существующий Mantine component и общую тему, если они выражают нужное -поведение. Wrapper нужен для проектной ответственности, а не ради нового имени +Сначала переиспользуй подходящий component проекта; иначе используй готовый +Mantine component и общую тему. Для выпадающего выбора используй `Select` или +`MultiSelect`, а не браузерный ` { + 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 }; diff --git a/src/app/ui/AppBreadcrumbs.test.tsx b/src/app/ui/AppBreadcrumbs.test.tsx index aaa8c46..080baf5 100644 --- a/src/app/ui/AppBreadcrumbs.test.tsx +++ b/src/app/ui/AppBreadcrumbs.test.tsx @@ -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(); }); diff --git a/src/app/ui/AppBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs.tsx index c361f24..73bbe90 100644 --- a/src/app/ui/AppBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs.tsx @@ -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 ( - + + + + ); if (events) return ( - + + + + ); if (income) return ( - + + + + ); if (account) - return ; + return ( + + + + + ); if (accounts) return ; if (settings) diff --git a/src/entities/broker-operation/ui/LedgerEntryRow.tsx b/src/entities/broker-operation/ui/LedgerEntryRow.tsx index 4ccbc79..ad587f2 100644 --- a/src/entities/broker-operation/ui/LedgerEntryRow.tsx +++ b/src/entities/broker-operation/ui/LedgerEntryRow.tsx @@ -15,9 +15,13 @@ const LedgerEntryRow: FC = (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 ( = (props) => { date, event.dateKind === "registry" ? t("events.dateKind.registry") : null, event.isin, + event.account?.name, ] .filter(Boolean) .join(" · "); diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx index f4cb6e1..36fff05 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx @@ -36,7 +36,15 @@ const BrokerageEventsPreview: FC = (props) => { {events.state === "loaded" ? events.items.map((event) => ( )) diff --git a/src/pages/account-events/ui/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage.tsx index 44a140b..9638ca3 100644 --- a/src/pages/account-events/ui/AccountEventsPage.tsx +++ b/src/pages/account-events/ui/AccountEventsPage.tsx @@ -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 = (props) => { + const viewModel = useAccountEventsPageViewModel(props.accountId); if (viewModel.state === "loading") return ; if (viewModel.state === "error") return ; - if (viewModel.state === "empty") return ; - return ; + if (viewModel.state === "empty") + return ; + return ; }; export { AccountEventsPage }; diff --git a/src/pages/account-events/ui/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage.vm.ts index aeb0ef4..e5984ca 100644 --- a/src/pages/account-events/ui/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage.vm.ts @@ -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 }; diff --git a/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx index 394cbf6..825bdd9 100644 --- a/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx +++ b/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx @@ -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 = (props) => { const { t } = useTranslation(); return ( {t("events.title")} + {props.refreshing ? ( + + ) : null} + {props.refreshError ? ( + + {t("accounts.refreshError")} + + + ) : null} {t("events.hint")} diff --git a/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx index b31b650..1f80437 100644 --- a/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx @@ -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 = (props) => { const { t } = useTranslation(); return ( {t("events.title")} + {props.refreshing ? ( + + ) : null} + {props.refreshError ? ( + + {t("accounts.refreshError")} + + + ) : null} {t("events.hint")} {props.events.map((event) => ( ))} diff --git a/src/pages/account-income/ui/AccountIncomePage.tsx b/src/pages/account-income/ui/AccountIncomePage.tsx index 62e1722..7e991ce 100644 --- a/src/pages/account-income/ui/AccountIncomePage.tsx +++ b/src/pages/account-income/ui/AccountIncomePage.tsx @@ -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 = (props) => { + const viewModel = useAccountIncomePageViewModel(props.accountId); if (viewModel.state === "loading") return ; if (viewModel.state === "error") return ; diff --git a/src/pages/account-income/ui/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage.vm.ts index ba0814b..a485fe7 100644 --- a/src/pages/account-income/ui/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage.vm.ts @@ -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( () => ({ diff --git a/src/pages/account-operations/ui/AccountOperationsPage.tsx b/src/pages/account-operations/ui/AccountOperationsPage.tsx index 2e05100..153ee24 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage.tsx @@ -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 = (props) => { + const viewModel = useAccountOperationsPageVm(props.accountId); if (viewModel.state === "loading") { return ; diff --git a/src/pages/account-operations/ui/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage.vm.ts index 1f255f6..41341d3 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage.vm.ts @@ -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(null); - const [appliedTo, setAppliedTo] = useState(null); - const [appliedCategories, setAppliedCategories] = useState< - OperationCategory[] - >([]); - const [draftFrom, setDraftFrom] = useState(null); - const [draftTo, setDraftTo] = useState(null); - const [draftCategories, setDraftCategories] = useState( - [], + 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([]); + const [draftFrom, setDraftFrom] = useState(appliedFrom); + const [draftTo, setDraftTo] = useState(appliedTo); + const [draftCategories, setDraftCategories] = + useState(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) }), }; }; diff --git a/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx index a5e8b5b..774128e 100644 --- a/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx @@ -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 = ( return ( {t("ledger.title")} + {props.refreshing ? ( + + ) : null} + {props.refreshError ? ( + + {t("accounts.refreshError")} + + + ) : null} = ( {items.length ? ( items.map((operation) => ( - + )) ) : ( { - const viewModel = useAccountPageViewModel(); +type AccountPageProps = { accountId?: string }; + +const AccountPage: FC = (props) => { + const viewModel = useAccountPageViewModel(props.accountId); if (viewModel.state === "loading") return ; if (viewModel.state === "error") return ; if (viewModel.state === "empty") return ( { return ( { "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: [ diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts index 7912d71..8b3823a 100644 --- a/src/pages/account/ui/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage.vm.ts @@ -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(); + 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, @@ -237,6 +272,7 @@ function toPosition( export { type AccountPageIncomeSummary, type AccountPagePosition, + type AccountPagePositionGroup, type AccountPagePositionSection, type AccountPageRecentEvents, type AccountPageRecentOperations, diff --git a/src/pages/account/ui/components/AccountHeading.view.tsx b/src/pages/account/ui/components/AccountHeading.view.tsx index 75eb7a2..3d89f4d 100644 --- a/src/pages/account/ui/components/AccountHeading.view.tsx +++ b/src/pages/account/ui/components/AccountHeading.view.tsx @@ -6,6 +6,7 @@ import { accountTypeKey } from "@/entities/brokerage-account"; type AccountHeadingProps = { account?: BrokerageAccount; + accountId?: string; }; const AccountHeading: FC = (props) => { @@ -14,8 +15,16 @@ const AccountHeading: FC = (props) => { return ( - {account?.name ?? t("portfolio.title")} - {account ? ( + + {props.accountId === "all" + ? t("accounts.all") + : (account?.name ?? t("portfolio.title"))} + + {props.accountId === "all" ? ( + + {t("accounts.allDescription")} + + ) : account ? ( {t(accountTypeKey(account.type))} diff --git a/src/pages/account/ui/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/components/AccountPageEmpty.view.tsx index 3add94f..c0e35a1 100644 --- a/src/pages/account/ui/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/components/AccountPageEmpty.view.tsx @@ -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 = (props) => { const { t } = useTranslation(); return ( - + = (props) => { /> diff --git a/src/pages/account/ui/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/components/AccountPageLoaded.view.tsx index d96ce81..21261da 100644 --- a/src/pages/account/ui/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/components/AccountPageLoaded.view.tsx @@ -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 = (props) => { const { t } = useTranslation(); return ( - + = (props) => { {t("portfolio.staleWarning")} ) : null} - {t("portfolio.updatedAt", { - date: formatSnapshotTime(props.portfolio.lastSuccessfulAt), - })} + {t( + props.accountId === "all" + ? "portfolio.allUpdatedAt" + : "portfolio.updatedAt", + { + date: formatSnapshotTime(props.portfolio.lastSuccessfulAt), + }, + )} = (props) => { ] .filter(Boolean) .join(" · "); + const accountLink = position.account ? ( + + {position.account.name} + + ) : null; const secondaryText = [ position.averagePurchasePrice ? t("portfolio.avgPrice", { @@ -63,6 +73,7 @@ const PositionCard: FC = (props) => { {position.displayName} + {accountLink} {identifierText} @@ -94,6 +105,7 @@ const PositionCard: FC = (props) => { {position.displayName} + {accountLink} {position.isin ? ( {position.isin} diff --git a/src/pages/account/ui/components/PositionGroupCard.view.tsx b/src/pages/account/ui/components/PositionGroupCard.view.tsx new file mode 100644 index 0000000..1c478d3 --- /dev/null +++ b/src/pages/account/ui/components/PositionGroupCard.view.tsx @@ -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 = (props) => { + const { group, kind } = props; + const { t } = useTranslation(); + const identity = group.positions[0]; + return ( + + + {kind !== "other" ? ( + + ) : null} + + {identity.displayName} + + {identity.ticker ?? identity.isin ?? identity.figi} + + + + + {group.positions.map((position) => ( + + + {position.account ? ( + + {position.account.name} + + ) : null} + + {t("portfolio.quantity", { value: position.quantity })} + + + + {position.currentValue ? ( + {formatMoney(position.currentValue)} + ) : null} + {position.returnText ? ( + + {kind === "other" + ? t("portfolio.return", { value: position.returnText }) + : position.returnText} + + ) : null} + + + {position.averagePurchasePrice ? ( + + {t("portfolio.avgPrice", { + value: formatMoney(position.averagePurchasePrice), + })} + + ) : null} + {position.shareText ? ( + + {t("portfolio.share", { value: position.shareText })} + + ) : null} + + + ))} + + + ); +}; + +export { PositionGroupCard }; diff --git a/src/pages/account/ui/components/PositionSection.view.tsx b/src/pages/account/ui/components/PositionSection.view.tsx index 4bbfd31..6e2667b 100644 --- a/src/pages/account/ui/components/PositionSection.view.tsx +++ b/src/pages/account/ui/components/PositionSection.view.tsx @@ -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 = (props) => { return ( - {section.positions.map((position) => ( - - ))} + {section.groups.map((group) => + group.positions.length > 1 ? ( + + ) : ( + + ), + )} ); }; diff --git a/src/pages/account/ui/components/RecentOperations.view.tsx b/src/pages/account/ui/components/RecentOperations.view.tsx index 40b5cf2..5829887 100644 --- a/src/pages/account/ui/components/RecentOperations.view.tsx +++ b/src/pages/account/ui/components/RecentOperations.view.tsx @@ -31,7 +31,14 @@ const RecentOperations: FC = (props) => { ) : null} {operations.state === "loaded" ? operations.items.map((operation) => ( - + )) : null} ); diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 95ccc0c..b1dfe28 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/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; diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index 8e3ac14..63b0bd0 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -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" }, diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 8aecdf1..2011a42 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -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: "Доллар США", diff --git a/src/test/setup.ts b/src/test/setup.ts index bbb84fa..f8dc53f 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -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) => ({