From 0865b35f93865b4052181d918cee80c995312930 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 21:44:53 +0300 Subject: [PATCH] refactor: clarify frontend model ownership --- src/app/routes/AppRoutes/AllAccounts.test.tsx | 33 ++ src/app/routes/AppRoutes/AppRoutes.tsx | 36 --- src/entities/bond/api/queries.ts | 6 + src/entities/bond/index.ts | 2 + .../api/useBrokerageOperations.ts | 1 + src/entities/broker-operation/index.ts | 1 + .../model/useBrokerageOperationHistory.ts} | 35 ++- .../brokerage-account/model/display.ts | 13 +- src/entities/income/api/useIncome.ts | 4 + src/entities/income/index.ts | 1 + src/features/instrument-details/index.ts | 3 +- .../instrument-details/model/section.ts | 7 +- .../tbank-connection/model/screen-sync.ts | 11 +- .../AccountEventsPage.filters.test.tsx | 16 +- .../AccountEventsPage/AccountEventsPage.tsx | 6 +- .../AccountEventsPage/AccountEventsPage.vm.ts | 17 +- src/pages/account-income/model/display.ts | 8 +- .../AccountIncomePage/AccountIncomePage.tsx | 6 +- .../AccountIncomePage/AccountIncomePage.vm.ts | 11 +- .../AccountOperationsPage.tsx | 6 +- .../AccountOperationsPage.vm.ts | 83 ++--- src/pages/account/model/positions.ts | 177 +++++++++++ .../ui/AccountPage/AccountPage.test.tsx | 8 +- .../account/ui/AccountPage/AccountPage.tsx | 6 +- .../ui/AccountPage/AccountPage.vm.test.tsx | 6 +- .../account/ui/AccountPage/AccountPage.vm.ts | 186 +---------- .../components/AccountPageLoaded.view.tsx | 8 +- .../PositionCard/PositionCard.stories.tsx | 2 +- .../PositionCard/PositionCard.view.tsx | 5 +- .../components/PositionGroupCard.view.tsx | 5 +- .../components/PositionLogo.view.tsx | 7 +- .../components/PositionSection.view.tsx | 2 +- .../ui/AccountPage/position-groups.test.tsx | 2 +- .../position-layout.browser.test.tsx | 5 +- src/pages/bond/ui/BondPage/BondPage.view.tsx | 294 +----------------- src/pages/bond/ui/BondPage/BondPage.vm.ts | 18 +- .../BondPage/components/BondCoupons.view.tsx | 152 +++++++++ .../BondPage/components/BondMetrics.view.tsx | 150 +++++++++ .../ui/BondPage/components/Metric.view.tsx | 19 ++ src/pages/stock/ui/StockPage/StockPage.vm.ts | 14 +- src/shared/api/index.ts | 1 + src/shared/api/resource-query.ts | 11 +- src/shared/lib/index.ts | 1 - src/shared/lib/useImageFallback.ts | 10 - 44 files changed, 685 insertions(+), 710 deletions(-) rename src/{features/instrument-details/model/useInstrumentOperations.ts => entities/broker-operation/model/useBrokerageOperationHistory.ts} (64%) create mode 100644 src/pages/account/model/positions.ts create mode 100644 src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx create mode 100644 src/pages/bond/ui/BondPage/components/BondMetrics.view.tsx create mode 100644 src/pages/bond/ui/BondPage/components/Metric.view.tsx delete mode 100644 src/shared/lib/useImageFallback.ts diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index 62a849a..023debe 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -238,6 +238,34 @@ describe("All accounts routes", () => { ); }); + it.each(["operations", "events", "income"])( + "restores applied dates when switching accounts in %s", + async (section) => { + const search = "?from=2026-08-01&to=2026-08-31"; + renderPage(`/accounts/all/${section}${search}`); + fireEvent.click(await screen.findByLabelText("Период")); + fireEvent.click( + await screen.findByRole("button", { name: /10 August 2026/i }), + ); + fireEvent.click( + await screen.findByRole("button", { name: /31 August 2026/i }), + ); + expect(screen.getByLabelText("Период")).toHaveTextContent( + "10.08.2026 – 31.08.2026", + ); + fireEvent.click(screen.getByRole("combobox", { name: "Счёт" })); + fireEvent.click(await screen.findByRole("option", { name: "ИИС" })); + await waitFor(() => + expect(screen.getByLabelText("Период")).toHaveTextContent( + "01.08.2026 – 31.08.2026", + ), + ); + expect(screen.getByLabelText("Текущий путь")).toHaveTextContent( + `/accounts/b/${section}${search}`, + ); + }, + ); + it("groups the same security and links its accounts and overview sections", async () => { renderPage("/accounts/all"); expect(await screen.findAllByText("Сбербанк")).toHaveLength(1); @@ -312,6 +340,11 @@ describe("All accounts routes", () => { expect(screen.getByLabelText("Текущий путь")).toHaveTextContent( "/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income", ); + await screen.findByText("Первая страница"); + fireEvent.click(screen.getByRole("combobox", { name: "Счёт" })); + fireEvent.click(await screen.findByRole("option", { name: "Все счета" })); + await waitFor(() => expect(screen.getByText("Первая страница")).toBeVisible()); + expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled(); }); it("restarts an expired aggregate cursor without losing filters", async () => { diff --git a/src/app/routes/AppRoutes/AppRoutes.tsx b/src/app/routes/AppRoutes/AppRoutes.tsx index b86289e..fea6244 100644 --- a/src/app/routes/AppRoutes/AppRoutes.tsx +++ b/src/app/routes/AppRoutes/AppRoutes.tsx @@ -200,42 +200,6 @@ const AppRoutes: FC = () => ( path="/accounts" /> - - - - )} - path="/accounts/all" - /> - - - - - )} - path="/accounts/all/operations" - /> - - - - - )} - path="/accounts/all/income" - /> - - - - - )} - path="/accounts/all/events" - /> - diff --git a/src/entities/bond/api/queries.ts b/src/entities/bond/api/queries.ts index 80d254b..e51100a 100644 --- a/src/entities/bond/api/queries.ts +++ b/src/entities/bond/api/queries.ts @@ -2,6 +2,10 @@ import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; const MIN_BOND_SEARCH_LENGTH = 2; +type BondCoupons = NonNullable; + +type BondMetrics = NonNullable; + type BondPeriod = NonNullable< NonNullable< paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"] @@ -104,6 +108,8 @@ function useBondSearch(query: string) { } export { + type BondCoupons, + type BondMetrics, type BondPeriod, MIN_BOND_SEARCH_LENGTH, useBond, diff --git a/src/entities/bond/index.ts b/src/entities/bond/index.ts index f5e91aa..365bab9 100644 --- a/src/entities/bond/index.ts +++ b/src/entities/bond/index.ts @@ -1,4 +1,6 @@ export { + type BondCoupons, + type BondMetrics, type BondPeriod, MIN_BOND_SEARCH_LENGTH, useBond, diff --git a/src/entities/broker-operation/api/useBrokerageOperations.ts b/src/entities/broker-operation/api/useBrokerageOperations.ts index 1eb71b3..6f0c2a4 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.ts +++ b/src/entities/broker-operation/api/useBrokerageOperations.ts @@ -68,6 +68,7 @@ function useBrokerageOperations( export { type BrokerageOperation, + type BrokerageOperationsQuery, brokerageOperationsQueryOptions, brokerageOperationsQueryParameters as brokerageOperationsQueryParams, operationCategories, diff --git a/src/entities/broker-operation/index.ts b/src/entities/broker-operation/index.ts index fda82ba..9c81877 100644 --- a/src/entities/broker-operation/index.ts +++ b/src/entities/broker-operation/index.ts @@ -5,4 +5,5 @@ export { type OperationCategory, useBrokerageOperations, } from "./api/useBrokerageOperations"; +export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory"; export { LedgerEntryRow } from "./ui/LedgerEntryRow"; diff --git a/src/features/instrument-details/model/useInstrumentOperations.ts b/src/entities/broker-operation/model/useBrokerageOperationHistory.ts similarity index 64% rename from src/features/instrument-details/model/useInstrumentOperations.ts rename to src/entities/broker-operation/model/useBrokerageOperationHistory.ts index d67b5bf..3147b8c 100644 --- a/src/features/instrument-details/model/useInstrumentOperations.ts +++ b/src/entities/broker-operation/model/useBrokerageOperationHistory.ts @@ -1,19 +1,20 @@ -import { useQueryClient } from "@tanstack/react-query"; +import { hashKey, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; -import { - brokerageOperationsQueryOptions, - type OperationCategory, - useBrokerageOperations, -} from "@/entities/broker-operation"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; -function useInstrumentOperations( - uid: string, - filter: string, - categories: OperationCategory[], +import { + type BrokerageOperationsQuery, + brokerageOperationsQueryOptions, + useBrokerageOperations, +} from "../api/useBrokerageOperations"; + +function useBrokerageOperationHistory( + accountId: string | undefined, + firstPage: Omit, ) { - const scope = `${uid}:${filter}`; + const { queryKey } = brokerageOperationsQueryOptions(accountId, firstPage); + const scope = hashKey(queryKey); const [pagination, setPagination] = useState<{ cursors: string[]; @@ -22,13 +23,14 @@ function useInstrumentOperations( const cursors = pagination.scope === scope ? pagination.cursors : []; const client = useQueryClient(); - const firstPage = { categories, instrumentUid: uid, limit: 20 }; - const operations = useBrokerageOperations(uid ? "all" : undefined, { + const operations = useBrokerageOperations(accountId, { ...firstPage, cursor: cursors.at(-1), }); + const reset = () => setPagination({ cursors: [], scope }); + const retry = (retryScreen: () => unknown) => { if ( isApiErrorCode( @@ -36,10 +38,10 @@ function useInstrumentOperations( ApiErrorCode.InvalidOperationsCursor, ) ) { - setPagination({ cursors: [], scope }); + reset(); void client.invalidateQueries({ exact: true, - queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, + queryKey, }); } else void retryScreen(); @@ -56,8 +58,9 @@ function useInstrumentOperations( }, onPrevious: () => setPagination({ cursors: cursors.slice(0, -1), scope }), operations, + reset, retry, }; } -export { useInstrumentOperations }; +export { useBrokerageOperationHistory }; diff --git a/src/entities/brokerage-account/model/display.ts b/src/entities/brokerage-account/model/display.ts index eb0d0c0..e624699 100644 --- a/src/entities/brokerage-account/model/display.ts +++ b/src/entities/brokerage-account/model/display.ts @@ -1,6 +1,7 @@ +import type { InvestmentResourceMeta } from "@/shared/api"; + import type { BrokerageAccount, - useBrokerageAccounts, } from "../api/useBrokerageAccounts"; const VISIBLE_CURRENCY_LIMIT = 2; @@ -9,11 +10,7 @@ type BrokerageAccountCard = { account: BrokerageAccount; positionCounts: null | { bonds: number; stocks: number }; - source?: NonNullable< - ReturnType["meta"] - >["dependencies"] extends (infer T)[] | undefined - ? T - : never; + source?: NonNullable[number]; summary: | { remainingCurrencyCount: number; state: "available"; totals: Money[] } | { state: "empty" } @@ -30,9 +27,7 @@ function accountTypeKey(type: BrokerageAccount["type"]) { function brokerageAccountCards( accounts: BrokerageAccount[], - dependencies: NonNullable< - ReturnType["meta"] - >["dependencies"] = [], + dependencies: InvestmentResourceMeta["dependencies"] = [], ): BrokerageAccountCard[] { return accounts.map((account) => { const source = dependencies.find( diff --git a/src/entities/income/api/useIncome.ts b/src/entities/income/api/useIncome.ts index a6357e8..449eef3 100644 --- a/src/entities/income/api/useIncome.ts +++ b/src/entities/income/api/useIncome.ts @@ -4,6 +4,9 @@ type IncomeAnalytics = NonNullable< paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"] >; +type IncomeAnalyticsMeta + = paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["meta"]; + type IncomeSummary = NonNullable< paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"] >; @@ -44,6 +47,7 @@ function useIncomeSummary(accountId?: string) { export { type IncomeAnalytics, + type IncomeAnalyticsMeta, type IncomeSummary, useIncomeAnalytics, useIncomeSummary, diff --git a/src/entities/income/index.ts b/src/entities/income/index.ts index a277e6d..7ecf287 100644 --- a/src/entities/income/index.ts +++ b/src/entities/income/index.ts @@ -1,5 +1,6 @@ export { type IncomeAnalytics, + type IncomeAnalyticsMeta, type IncomeSummary, useIncomeAnalytics, useIncomeSummary, diff --git a/src/features/instrument-details/index.ts b/src/features/instrument-details/index.ts index 2cd23d8..556c6fa 100644 --- a/src/features/instrument-details/index.ts +++ b/src/features/instrument-details/index.ts @@ -1,5 +1,4 @@ -export { section } from "./model/section"; -export { useInstrumentOperations } from "./model/useInstrumentOperations"; +export { section, type SectionState } from "./model/section"; export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view"; export { InstrumentEvents } from "./ui/InstrumentEvents.view"; export { InstrumentPosition } from "./ui/InstrumentPosition.view"; diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts index 386ac17..d0d5d17 100644 --- a/src/features/instrument-details/model/section.ts +++ b/src/features/instrument-details/model/section.ts @@ -1,7 +1,4 @@ -import type { paths } from "@/shared/api"; - -type Meta - = paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number]; +import type { InvestmentResourceMeta } from "@/shared/api"; type SectionState = { error: boolean; @@ -20,7 +17,7 @@ function section( data: unknown; isError: boolean; isFetching: boolean; - meta?: Meta; + meta?: InvestmentResourceMeta; refetch: () => unknown; }, retry?: () => unknown, diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts index 325ce28..4d03c55 100644 --- a/src/features/tbank-connection/model/screen-sync.ts +++ b/src/features/tbank-connection/model/screen-sync.ts @@ -6,18 +6,15 @@ import { apiErrorCode, apiQueryClient, invalidateResourceAccess, + type InvestmentResourceMeta, isApiErrorCode, - type paths, useAuthenticatedQueries, } from "@/shared/api"; import { type InvestmentScreen, useTbankSync } from "./sync"; -type ResourceMeta - = paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number]; - type ScreenResource = { - meta?: ResourceMeta; + meta?: InvestmentResourceMeta; observe: (version?: string) => Promise; queryKey: QueryKey; transport: { @@ -34,7 +31,7 @@ const STATUS_INTERVAL_MS = 1000; function observeResource( resource: ScreenResource, - latest: Map, + latest: Map, completedId: string | undefined, observed: Map, ) { @@ -143,7 +140,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { invalidateResourceAccess(client, "brokerage", status.error); } - const latest = new Map(); + const latest = new Map(); const orderedStatuses = statuses.toSorted( (a, b) => a.dataUpdatedAt - b.dataUpdatedAt, diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index fbc7a51..4a75e0c 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -146,12 +146,14 @@ describe("Upcoming event date filters", () => { const queryClient = createTestQueryClient(); const { result } = renderHook( - () => useAccountEventsPageViewModel("all"), + () => useAccountEventsPageViewModel(), { wrapper: ({ children }) => ( - - {children} + + + + ), @@ -326,10 +328,14 @@ it("retains the previous complete forecast and timestamp when its source refresh ); const queryClient = createTestQueryClient(); - const { result } = renderHook(() => useAccountEventsPageViewModel("all"), { + const { result } = renderHook(() => useAccountEventsPageViewModel(), { wrapper: ({ children }) => ( - {children} + + + + + ), }); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx index 33f0160..682dd4a 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx @@ -5,10 +5,8 @@ import { AccountEventsPageError } from "./components/AccountEventsPageError.view import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view"; import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view"; -type AccountEventsPageProps = { accountId?: string }; - -const AccountEventsPage: FC = (props) => { - const viewModel = useAccountEventsPageViewModel(props.accountId); +const AccountEventsPage: FC = () => { + const viewModel = useAccountEventsPageViewModel(); if (viewModel.state === "loading") return ; return viewModel.state === "error" diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index e839d83..109f6b4 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -27,11 +27,8 @@ type AccountEventsPageViewModel | { onReset: () => void; retry: () => void; state: "error" } | { state: "loading" }; -function useAccountEventsPageViewModel( - scopeId?: string, -): AccountEventsPageViewModel { - const { accountId: routeAccountId } = useParams(); - const accountId = scopeId ?? routeAccountId ?? ""; +function useAccountEventsPageViewModel(): AccountEventsPageViewModel { + const { accountId = "" } = useParams(); const [searchParameters, setSearchParameters] = useSearchParams(); const urlFrom = searchParameters.get("from"); const urlTo = searchParameters.get("to"); @@ -59,13 +56,11 @@ function useAccountEventsPageViewModel( setSearchParameters(normalized, { replace: true }); }, [isValidUrlPeriod, searchParameters, setSearchParameters]); - const [previousPeriod, setPreviousPeriod] = useState([ - appliedFrom, - appliedTo, - ]); + const scope = JSON.stringify([accountId, appliedFrom, appliedTo]); + const [previousScope, setPreviousScope] = useState(scope); - if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) { - setPreviousPeriod([appliedFrom, appliedTo]); + if (previousScope !== scope) { + setPreviousScope(scope); setDraftPeriod([appliedFrom, appliedTo]); } diff --git a/src/pages/account-income/model/display.ts b/src/pages/account-income/model/display.ts index e1eab94..9d00091 100644 --- a/src/pages/account-income/model/display.ts +++ b/src/pages/account-income/model/display.ts @@ -1,4 +1,4 @@ -import type { IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; +import type { IncomeAnalytics, IncomeAnalyticsMeta } from "@/entities/income"; import { formatDate } from "@/shared/lib"; @@ -29,8 +29,6 @@ type IncomeDisplayModel = { yearNotice: ComparisonNotice; }; -type IncomeMeta = ReturnType["meta"]; - function comparisonDisplay( period: IncomePeriod, comparison: IncomeAnalytics["yearComparison"], @@ -63,7 +61,7 @@ function comparisonDisplay( function comparisonNotice( dependency: - NonNullable["dependencies"]>[number] | undefined, + IncomeAnalyticsMeta["dependencies"][number] | undefined, unavailable: "previousUnavailable" | "yearUnavailable", ): ComparisonNotice { if (!dependency || dependency.unavailableReason) return unavailable; @@ -98,7 +96,7 @@ function formatIncomeChange( function toIncomeDisplayModel( analytics: IncomeAnalytics, - meta?: IncomeMeta, + meta?: IncomeAnalyticsMeta, ): IncomeDisplayModel | null { const instruments = new Map< string, diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx index d9217bb..f272f09 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx @@ -5,10 +5,8 @@ import { AccountIncomePageError } from "./components/AccountIncomePageError.view import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view"; import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view"; -type AccountIncomePageProps = { accountId?: string }; - -const AccountIncomePage: FC = (props) => { - const viewModel = useAccountIncomePageViewModel(props.accountId); +const AccountIncomePage: FC = () => { + const viewModel = useAccountIncomePageViewModel(); if (viewModel.state === "loading") return ; return viewModel.state === "error" diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index 937f9e7..3f22620 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -44,11 +44,8 @@ type AccountIncomePageViewModel } | { state: "loading" }; -const useAccountIncomePageViewModel = ( - scopeId?: string, -): AccountIncomePageViewModel => { - const { accountId: routeAccountId } = useParams(); - const accountId = scopeId ?? routeAccountId ?? ""; +const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { + const { accountId = "" } = useParams(); const [searchParameters, setSearchParameters] = useSearchParams(); const requested = useMemo( @@ -83,7 +80,7 @@ const useAccountIncomePageViewModel = ( }, [period, requested, setSearchParameters]); useEffect(() => { form.reset(draftPeriod(period)); - }, [form, period]); + }, [accountId, form, period]); if (analytics.isLoading || !analytics.data) { return analytics.isError || analytics.meta?.sync.state === "failed" @@ -117,7 +114,7 @@ const useAccountIncomePageViewModel = ( analytics: analytics.data, display, form, - formRevision: `${period.from}:${period.to}:${resetRevision}`, + formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`, lastSuccessfulAt: analytics.meta?.lastSuccessfulAt, onReset, onSubmit, diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx index b001e41..de98d16 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx @@ -5,10 +5,8 @@ import { AccountOperationsPageError } from "./components/AccountOperationsPageEr import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view"; import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view"; -type AccountOperationsPageProps = { accountId?: string }; - -const AccountOperationsPage: FC = (props) => { - const viewModel = useAccountOperationsPageVm(props.accountId); +const AccountOperationsPage: FC = () => { + const viewModel = useAccountOperationsPageVm(); if (viewModel.state === "loading") { return ; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index e3b3c6a..64a4f47 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -7,7 +7,7 @@ import { brokerageOperationsQueryOptions, operationCategories, type OperationCategory, - useBrokerageOperations, + useBrokerageOperationHistory, } from "@/entities/broker-operation"; import { useScreenSync } from "@/features/tbank-connection"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; @@ -41,11 +41,8 @@ type AccountOperationsPageViewModel | { onReset: () => void; retry: () => void; state: "error" } | { state: "loading" }; -const useAccountOperationsPageVm = ( - scopeId?: string, -): AccountOperationsPageViewModel => { - const { accountId: routeAccountId } = useParams(); - const accountId = scopeId ?? routeAccountId; +const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { + const { accountId } = useParams(); const queryClient = useQueryClient(); const [searchParameters, setSearchParameters] = useSearchParams(); const urlFrom = searchParameters.get("from"); @@ -74,25 +71,6 @@ const useAccountOperationsPageVm = ( const [draftCategories, setDraftCategories] = useState(appliedCategories); - const scopeKey = JSON.stringify([ - accountId, - urlFrom, - urlTo, - appliedCategories, - ]); - - const [pagination, setPagination] = useState<{ - cursors: string[]; - scopeKey: string; - }>({ cursors: [], scopeKey }); - - const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : []; - const cursor = cursors.at(-1); - - if (pagination.scopeKey !== scopeKey) { - setPagination({ cursors: [], scopeKey }); - } - useEffect(() => { if (isValidUrlPeriod) return; const normalized = new URLSearchParams(searchParameters); @@ -100,13 +78,6 @@ const useAccountOperationsPageVm = ( normalized.delete("to"); setSearchParameters(normalized, { replace: true }); }, [isValidUrlPeriod, searchParameters, setSearchParameters]); - const [previousSearch, setPreviousSearch] = useState(searchParameters); - - if (previousSearch !== searchParameters) { - setPreviousSearch(searchParameters); - setDraftPeriod([appliedFrom, appliedTo]); - setDraftCategories(appliedCategories); - } const firstPageQuery = { from: dateStart(appliedFrom), @@ -115,10 +86,17 @@ const useAccountOperationsPageVm = ( limit: 100, }; - const operations = useBrokerageOperations(accountId, { - ...firstPageQuery, - ...(cursor && { cursor }), - }); + const history = useBrokerageOperationHistory(accountId, firstPageQuery); + const { operations } = history; + const scope = JSON.stringify([accountId, searchParameters.toString()]); + const [previousScope, setPreviousScope] = useState(scope); + + if (previousScope !== scope) { + setPreviousScope(scope); + setDraftPeriod([appliedFrom, appliedTo]); + setDraftCategories(appliedCategories); + history.reset(); + } const sync = useScreenSync( { accountId, screen: "operations", ...firstPageQuery }, @@ -131,30 +109,13 @@ const useAccountOperationsPageVm = ( limit: 100, }).queryKey; - const resetCursors = () => setPagination({ cursors: [], scopeKey }); - - const retry = () => { - if ( - isApiErrorCode( - apiErrorCode(operations.error), - ApiErrorCode.InvalidOperationsCursor, - ) - ) { - void queryClient.invalidateQueries({ - exact: true, - queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery) - .queryKey, - }); - resetCursors(); - } - else void sync.retry(); - }; + const retry = () => history.retry(sync.retry); const onReset = () => { setDraftPeriod([null, null]); setDraftCategories([]); setSearchParameters({}); - resetCursors(); + history.reset(); void queryClient.invalidateQueries({ queryKey: resetQueryKey }); }; @@ -180,7 +141,7 @@ const useAccountOperationsPageVm = ( draftCategories, draftPeriod, hasNext: Boolean(nextCursor && operations.data.hasNext), - hasPrevious: cursors.length > 0, + hasPrevious: history.hasPrevious, historyLoading: operations.meta?.coverage?.historyState === "loading" || operations.meta?.coverage?.historyState === "partial", @@ -195,20 +156,16 @@ const useAccountOperationsPageVm = ( categories: draftCategories.join(","), }), }); - resetCursors(); + history.reset(); }, onCategoriesChange: values => setDraftCategories( values.filter((value): value is OperationCategory => operationCategorySet.has(value), ), ), - onNext: () => { - if (nextCursor) - setPagination({ cursors: [...cursors, nextCursor], scopeKey }); - }, + onNext: history.onNext, onPeriodChange: setDraftPeriod, - onPrevious: () => - setPagination({ cursors: cursors.slice(0, -1), scopeKey }), + onPrevious: history.onPrevious, onReset, partial: operations.meta?.availability !== "complete", refreshError: operations.isError || sync.sourceError || sync.transportError, diff --git a/src/pages/account/model/positions.ts b/src/pages/account/model/positions.ts new file mode 100644 index 0000000..347e215 --- /dev/null +++ b/src/pages/account/model/positions.ts @@ -0,0 +1,177 @@ +import { formatShare, type Portfolio } from "@/entities/portfolio"; +import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; + +type AccountPagePosition = Portfolio["positions"][number] & { + instrumentTo?: string; + logoSrc: null | string; + returnText: null | string; + returnTone?: AccountPageReturnTone; + shareText: null | string; +}; + +type AccountPagePositionGroup = { + key: string; + positions: AccountPagePosition[]; +}; + +type AccountPagePositionSection = { + groups: AccountPagePositionGroup[]; + kind: PositionSectionKind; +}; + +type AccountPageReturnTone = "negative" | "positive"; + +type PositionSectionKind = "bonds" + | "other" + | "stocks"; + +function groupPositions( + positions: AccountPagePosition[], +): AccountPagePositionGroup[] { + const groups = new Map(); + + for (const [index, position] of positions.entries()) { + const identity = positionIdentity(position); + const rowKey = JSON.stringify(["row", position.account?.id, index]); + + const key + = identity && position.account + ? JSON.stringify([position.type, ...identity]) + : rowKey; + + const group = groups.get(key); + + if ( + group + && group.positions.every( + item => item.account?.id !== position.account?.id, + ) + ) { + group.positions.push(position); + } + else { + const uniqueKey = group ? rowKey : key; + groups.set(uniqueKey, { key: uniqueKey, positions: [position] }); + } + } + + // eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline. + return [...groups.values()]; +} + +function positionIdentity(position: AccountPagePosition) { + if (position.instrumentUid?.trim()) + return ["uid", position.instrumentUid.trim()]; + if (position.figi?.trim()) return ["figi", position.figi.trim()]; + + return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined; +} + +function toAccountPagePositionSections( + portfolio: Portfolio, + instrumentDetails: Map = new Map(), +): AccountPagePositionSection[] { + const denominators = new Map(); + + for (const position of portfolio.positions) { + if (!position.currentValue) continue; + const currency = position.currentValue.currency; + denominators.set( + currency, + (denominators.get(currency) ?? 0) + Number(position.currentValue.value), + ); + } + + const stocks = toSection( + "stocks", + portfolio, + denominators, + instrumentDetails, + ); + + const bonds = toSection("bonds", portfolio, denominators, instrumentDetails); + const other = toSection("other", portfolio, denominators, instrumentDetails); + + return [stocks, bonds, other].filter(section => section.groups.length > 0); +} + +function toPosition( + position: Portfolio["positions"][number], + denominators: Map, + kind: PositionSectionKind, + instrumentSlug?: string, +): AccountPagePosition { + const isExpanded = kind !== "other"; + + const returnValue = position.absoluteReturn + ? Number(position.absoluteReturn.value) + : null; + + let returnTone: AccountPageReturnTone | undefined; + if (returnValue !== null && returnValue !== 0) + returnTone = returnValue > 0 ? "positive" : "negative"; + + const returnPrefix + = isExpanded && returnValue !== null && returnValue > 0 ? "+" : ""; + + return { + ...position, + instrumentTo: + (kind === "stocks" || kind === "bonds") && position.instrumentUid + ? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}` + : undefined, + logoSrc: isExpanded + ? instrumentLogoUrl(position.logoName) + : null, + returnText: position.absoluteReturn + ? `${returnPrefix}${formatMoney(position.absoluteReturn)}` + : null, + returnTone, + shareText: position.currentValue?.currency + ? formatShare(position.currentValue, { + currency: position.currentValue.currency, + value: String(denominators.get(position.currentValue.currency) ?? 0), + }) + : null, + }; +} + +function toSection( + kind: PositionSectionKind, + portfolio: Portfolio, + denominators: Map, + instrumentDetails: Map, +): AccountPagePositionSection { + return { + groups: groupPositions( + portfolio.positions + .filter((position) => { + if (kind === "stocks") return position.type === "STOCK"; + + return kind === "bonds" + ? position.type === "BOND" + : position.type !== "STOCK" + && position.type !== "BOND" + && position.type !== "CURRENCY"; + }) + .map(position => + toPosition( + position, + denominators, + kind, + instrumentDetails.get(position.instrumentUid ?? "")?.slug, + ), + ), + ), + kind, + }; +} + +export { + type AccountPagePosition, + type AccountPagePositionGroup, + type AccountPagePositionSection, + toAccountPagePositionSections, +}; diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 7b89b18..ca410bb 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -24,8 +24,8 @@ import type { AccountPageRecentOperations, } from "./AccountPage.vm"; +import { toAccountPagePositionSections } from "../../model/positions"; import { AccountPage } from "./AccountPage"; -import { toAccountPagePositionSections } from "./AccountPage.vm"; import { AccountPageEmpty } from "./components/AccountPageEmpty.view"; import { AccountPageError } from "./components/AccountPageError.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; @@ -39,13 +39,13 @@ function configureTestAuth() { }); } -function renderAccountRoute(queryClient: QueryClient) { +function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") { configureTestAuth(); return render( - + } path="/accounts/:accountId" /> @@ -202,7 +202,7 @@ describe("AccountPage states", () => { }), ); configureTestAuth(); - renderPage(); + renderAccountRoute(createTestQueryClient(), accountId); await waitFor(() => { const links = screen.getAllByRole("link", { name: position.displayName, diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index 4512daf..edf4835 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -6,10 +6,8 @@ import { AccountPageError } from "./components/AccountPageError.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view"; -type AccountPageProps = { accountId?: string }; - -const AccountPage: FC = (props) => { - const viewModel = useAccountPageViewModel(props.accountId); +const AccountPage: FC = () => { + const viewModel = useAccountPageViewModel(); if (viewModel.state === "loading") return ; if (viewModel.state === "error") return ; if (viewModel.state === "empty") diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx index 0f9ce5a..c5ca484 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx @@ -13,10 +13,8 @@ import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; -import { - toAccountPagePositionSections, - useAccountPageViewModel, -} from "./AccountPage.vm"; +import { toAccountPagePositionSections } from "../../model/positions"; +import { useAccountPageViewModel } from "./AccountPage.vm"; const portfolio: Portfolio = { diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 32868b7..6f35f44 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -13,11 +13,12 @@ import { useBrokerageEvents, } from "@/entities/brokerage-event"; import { type IncomeSummary, useIncomeSummary } from "@/entities/income"; -import { formatShare, usePortfolio } from "@/entities/portfolio"; +import { usePortfolio } from "@/entities/portfolio"; import { useStocks } from "@/entities/stock"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; -import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; + +import { type AccountPagePositionSection, toAccountPagePositionSections } from "../../model/positions"; const RECENT_EVENTS_LIMIT = 5; @@ -58,24 +59,6 @@ type AccountPageLoadedViewModel = AccountPageContent & { state: "loaded"; }; -type AccountPagePosition = Portfolio["positions"][number] & { - instrumentTo?: string; - logoSrc: null | string; - returnText: null | string; - returnTone?: AccountPageReturnTone; - shareText: null | string; -}; - -type AccountPagePositionGroup = { - key: string; - positions: AccountPagePosition[]; -}; - -type AccountPagePositionSection = { - groups: AccountPagePositionGroup[]; - kind: PositionSectionKind; -}; - type AccountPageRecentEvents = BrokerageEventsPreviewState; type AccountPageRecentOperations @@ -94,89 +77,12 @@ type AccountPageRecentOperations to: string; }; -type AccountPageReturnTone = "negative" | "positive"; - type AccountPageViewModel = | AccountPageEmptyViewModel | AccountPageLoadedViewModel | { code?: string; retry: () => Promise | undefined; state: "error" } | { state: "loading" }; -type PositionSectionKind = "bonds" - | "other" - | "stocks"; - -function groupPositions( - positions: AccountPagePosition[], -): AccountPagePositionGroup[] { - const groups = new Map(); - - for (const [index, position] of positions.entries()) { - const identity = positionIdentity(position); - const rowKey = JSON.stringify(["row", position.account?.id, index]); - - const key - = identity && position.account - ? JSON.stringify([position.type, ...identity]) - : rowKey; - - const group = groups.get(key); - - if ( - group - && group.positions.every( - item => item.account?.id !== position.account?.id, - ) - ) { - group.positions.push(position); - } - else { - const uniqueKey = group ? rowKey : key; - groups.set(uniqueKey, { key: uniqueKey, positions: [position] }); - } - } - - // eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline. - return [...groups.values()]; -} - -function positionIdentity(position: AccountPagePosition) { - if (position.instrumentUid?.trim()) - return ["uid", position.instrumentUid.trim()]; - if (position.figi?.trim()) return ["figi", position.figi.trim()]; - - return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined; -} - -function toAccountPagePositionSections( - portfolio: Portfolio, - instrumentDetails: Map = new Map(), -): AccountPagePositionSection[] { - const denominators = new Map(); - - for (const position of portfolio.positions) { - if (!position.currentValue) continue; - const currency = position.currentValue.currency; - denominators.set( - currency, - (denominators.get(currency) ?? 0) + Number(position.currentValue.value), - ); - } - - const stocks = toSection( - "stocks", - portfolio, - denominators, - instrumentDetails, - ); - - const bonds = toSection("bonds", portfolio, denominators, instrumentDetails); - const other = toSection("other", portfolio, denominators, instrumentDetails); - - return [stocks, bonds, other].filter(section => section.groups.length > 0); -} - function toIncomeSummary( accountId: string | undefined, income: ReturnType, @@ -199,47 +105,6 @@ function toIncomeSummary( }; } -function toPosition( - position: Portfolio["positions"][number], - denominators: Map, - kind: PositionSectionKind, - instrumentSlug?: string, -): AccountPagePosition { - const isExpanded = kind !== "other"; - - const returnValue = position.absoluteReturn - ? Number(position.absoluteReturn.value) - : null; - - let returnTone: AccountPageReturnTone | undefined; - if (returnValue !== null && returnValue !== 0) - returnTone = returnValue > 0 ? "positive" : "negative"; - - const returnPrefix - = isExpanded && returnValue !== null && returnValue > 0 ? "+" : ""; - - return { - ...position, - instrumentTo: - (kind === "stocks" || kind === "bonds") && position.instrumentUid - ? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}` - : undefined, - logoSrc: isExpanded - ? instrumentLogoUrl(position.logoName) - : null, - returnText: position.absoluteReturn - ? `${returnPrefix}${formatMoney(position.absoluteReturn)}` - : null, - returnTone, - shareText: position.currentValue?.currency - ? formatShare(position.currentValue, { - currency: position.currentValue.currency, - value: String(denominators.get(position.currentValue.currency) ?? 0), - }) - : null, - }; -} - function toRecentEvents( accountId: string | undefined, events: ReturnType, @@ -299,41 +164,8 @@ function toRecentOperations( }; } -function toSection( - kind: PositionSectionKind, - portfolio: Portfolio, - denominators: Map, - instrumentDetails: Map, -): AccountPagePositionSection { - return { - groups: groupPositions( - portfolio.positions - .filter((position) => { - if (kind === "stocks") return position.type === "STOCK"; - - return kind === "bonds" - ? position.type === "BOND" - : position.type !== "STOCK" - && position.type !== "BOND" - && position.type !== "CURRENCY"; - }) - .map(position => - toPosition( - position, - denominators, - kind, - instrumentDetails.get(position.instrumentUid ?? "")?.slug, - ), - ), - ), - kind, - }; -} - -function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { - const { accountId: routeAccountId } = useParams(); - const accountId = scopeId ?? routeAccountId; +function useAccountPageViewModel(): AccountPageViewModel { + const { accountId } = useParams(); const portfolio = usePortfolio(accountId); const stocks = useStocks( @@ -344,8 +176,6 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { ) ?? [], ); - const instrumentDetails = stocks; - const operations = useBrokerageOperations(accountId, { limit: 5, }); @@ -408,7 +238,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { portfolio: portfolio.data, positionSections: toAccountPagePositionSections( portfolio.data, - instrumentDetails, + stocks, ), recentEvents, recentOperations, @@ -419,11 +249,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { export { type AccountPageIncomeSummary, - type AccountPagePosition, - type AccountPagePositionGroup, - type AccountPagePositionSection, type AccountPageRecentEvents, type AccountPageRecentOperations, - toAccountPagePositionSections, useAccountPageViewModel, }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx index 8ee2a43..1910aba 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -9,12 +9,8 @@ import type { Portfolio } from "@/entities/portfolio"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { formatSnapshotTime } from "@/shared/lib"; -import type { - AccountPageIncomeSummary, - AccountPagePositionSection, - AccountPageRecentEvents, - AccountPageRecentOperations, -} from "../AccountPage.vm"; +import type { AccountPagePositionSection } from "../../../model/positions"; +import type { AccountPageIncomeSummary, AccountPageRecentEvents, AccountPageRecentOperations } from "../AccountPage.vm"; import classes from "../AccountPage.module.css"; import { AccountHeading } from "./AccountHeading.view"; diff --git a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx index 126619d..526694e 100644 --- a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { AccountPagePosition } from "../../AccountPage.vm"; +import type { AccountPagePosition } from "../../../../model/positions"; import { PositionCard } from "./PositionCard.view"; diff --git a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx index d2062ea..a83205f 100644 --- a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx @@ -6,10 +6,7 @@ import { Link } from "react-router"; import { formatMoney } from "@/shared/lib"; -import type { - AccountPagePosition, - AccountPagePositionSection, -} from "../../AccountPage.vm"; +import type { AccountPagePosition, AccountPagePositionSection } from "../../../../model/positions"; import classes from "../../AccountPage.module.css"; import { PositionLogo } from "../PositionLogo.view"; diff --git a/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx b/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx index 32da3a9..25e0ddd 100644 --- a/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx @@ -6,10 +6,7 @@ import { Link } from "react-router"; import { formatMoney } from "@/shared/lib"; -import type { - AccountPagePositionGroup, - AccountPagePositionSection, -} from "../AccountPage.vm"; +import type { AccountPagePositionGroup, AccountPagePositionSection } from "../../../model/positions"; import classes from "../AccountPage.module.css"; import { PositionLogo } from "./PositionLogo.view"; diff --git a/src/pages/account/ui/AccountPage/components/PositionLogo.view.tsx b/src/pages/account/ui/AccountPage/components/PositionLogo.view.tsx index 254d61b..4aeb6a7 100644 --- a/src/pages/account/ui/AccountPage/components/PositionLogo.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionLogo.view.tsx @@ -1,10 +1,9 @@ import type { FC } from "react"; import { Anchor, Avatar } from "@mantine/core"; +import { useState } from "react"; import { Link } from "react-router"; -import { useImageFallback } from "@/shared/lib"; - type PositionLogoProps = { label: string; src: null | string; @@ -13,7 +12,9 @@ type PositionLogoProps = { const PositionLogo: FC = (props) => { const { label, src } = props; - const { failed, onError } = useImageFallback(); + const [failed, setFailed] = useState(false); + // eslint-disable-next-line frontend/readability -- Rendering fallback belongs to this keyed logo, without page data or actions. + const onError = () => setFailed(true); const logo = src && !failed diff --git a/src/pages/account/ui/AccountPage/components/PositionSection.view.tsx b/src/pages/account/ui/AccountPage/components/PositionSection.view.tsx index af13558..8f4d19e 100644 --- a/src/pages/account/ui/AccountPage/components/PositionSection.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionSection.view.tsx @@ -2,7 +2,7 @@ import type { FC } from "react"; import { SimpleGrid } from "@mantine/core"; -import type { AccountPagePositionSection } from "../AccountPage.vm"; +import type { AccountPagePositionSection } from "../../../model/positions"; import { PositionCard } from "./PositionCard/PositionCard.view"; import { PositionGroupCard } from "./PositionGroupCard.view"; diff --git a/src/pages/account/ui/AccountPage/position-groups.test.tsx b/src/pages/account/ui/AccountPage/position-groups.test.tsx index 0322b47..4875400 100644 --- a/src/pages/account/ui/AccountPage/position-groups.test.tsx +++ b/src/pages/account/ui/AccountPage/position-groups.test.tsx @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import type { Portfolio } from "@/entities/portfolio"; -import { toAccountPagePositionSections } from "./AccountPage.vm"; +import { toAccountPagePositionSections } from "../../model/positions"; import { PositionSection } from "./components/PositionSection.view"; const position: Portfolio["positions"][number] diff --git a/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx b/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx index cbbe612..262dcb2 100644 --- a/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx +++ b/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx @@ -8,10 +8,7 @@ import { cleanup, render, screen } from "@testing-library/react"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { afterEach, expect, it } from "vitest"; -import type { - AccountPagePosition, - AccountPagePositionSection, -} from "./AccountPage.vm"; +import type { AccountPagePosition, AccountPagePositionSection } from "../../model/positions"; import { PositionSection } from "./components/PositionSection.view"; diff --git a/src/pages/bond/ui/BondPage/BondPage.view.tsx b/src/pages/bond/ui/BondPage/BondPage.view.tsx index df3a659..f3cbbbd 100644 --- a/src/pages/bond/ui/BondPage/BondPage.view.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.view.tsx @@ -1,9 +1,7 @@ import type { FC } from "react"; import { - Accordion, Alert, - Anchor, Avatar, Badge, Button, @@ -21,7 +19,6 @@ import { InstrumentChart, InstrumentEvents, InstrumentPosition, - InstrumentSection, } from "@/features/instrument-details"; import { formatDate, @@ -33,6 +30,9 @@ import { import type { BondPageVm } from "./BondPage.vm"; import classes from "./BondPage.module.css"; +import { BondCoupons } from "./components/BondCoupons.view"; +import { BondMetrics } from "./components/BondMetrics.view"; +import { Metric } from "./components/Metric.view"; type BondPageViewProps = { vm: BondPageVm }; @@ -226,7 +226,7 @@ const BondPageView: FC = (props) => { - + @@ -236,7 +236,12 @@ const BondPageView: FC = (props) => { vm={vm} /> - + = (props) => { ); }; -const BondMetrics: FC = (props) => { - const { vm } = props; - const { t } = useTranslation(); - const creditRating = vm.metrics?.creditRating; - - let rating = t( - vm.metrics?.unavailable.includes("rating") - ? "bond.ratingUnavailable" - : "bond.ratingMissing", - ); - - if (creditRating) { - rating - = creditRating.status === "withdrawn" - ? t("bond.ratingWithdrawn") - : (creditRating.value ?? t("bond.unknown")); - } - - const couponDate = vm.metrics?.couponDate - ? formatDate(vm.metrics?.couponDate) - : t("bond.unknown"); - - const effectiveYield = vm.metrics?.effectiveYield - ? `${formatDecimal(vm.metrics?.effectiveYield.value)} %` - : t("bond.unknown"); - - const couponYield - = vm.metrics?.couponYieldPercent == undefined - ? t("bond.unknown") - : `${formatDecimal(vm.metrics?.couponYieldPercent)} %`; - - let yieldContent; - - if (vm.metrics?.effectiveYield) { - yieldContent = ( - - {t("bond.yieldSource", { - at: formatSnapshotTime(vm.metrics?.effectiveYield.tradeAt), - date: formatDate(vm.metrics?.effectiveYield.date), - price: formatDecimal( - vm.metrics?.effectiveYield.pricePercent, - ), - target: t( - `bond.yieldTargets.${vm.metrics?.effectiveYield.dateType}`, - { defaultValue: t("bond.yieldTargets.unknown") }, - ), - })} - - ); - } - else { - yieldContent = ( - - {t("bond.yieldUnavailable")} - - ); - } - - return ( - - {!!vm.metrics && ( - -
- - - - - - - - - - - -
- - {!!vm.metrics.creditRating && ( - - - {t("bond.ratingSource", { - date: formatDate(vm.metrics.creditRating.date), - })} - - - - {t("bond.ratingChecked", { - date: formatSnapshotTime(vm.metrics.creditRating.observedAt), - })} - - - )} - - - {t("bond.ratingCoverage")} - - - {yieldContent} - - - {t("bond.couponYieldNote")} - - - {!!vm.metrics?.couponYieldPriceAt && ( - - {t("bond.couponYieldPriceAt", { - date: formatSnapshotTime(vm.metrics?.couponYieldPriceAt), - })} - - )} - - {vm.metrics.unavailable.length > 0 && ( - - {t("bond.metricsPartial")} - - )} -
- )} -
- ); -}; - -const BondCoupons: FC = (props) => { - const { vm } = props; - const { t } = useTranslation(); - - return ( - - {!!vm.coupons && ( - - {!!(vm.coupons.estimateUnavailable || vm.coupons.positionIsStale) && ( - - {t("stock.estimateUnavailable")} - - )} - - {vm.upcoming.length > 0 - ? ( - vm.upcoming.map(coupon => ( - - )) - ) - : {t("bond.noCoupons")}} - - - {t("bond.couponWindow", { - from: formatDate(vm.coupons.from), - to: formatDate(vm.coupons.to), - })} - - - - {t("bond.couponNote")} - - - {!!vm.coupons.positionAt && ( - - {t("stock.snapshotAt", { - date: formatSnapshotTime(vm.coupons.positionAt), - })} - - )} - - - - {t("bond.couponHistory")} - - - - {vm.pastCoupons.length > 0 - ? ( - vm.pastCoupons.map(coupon => ( - - )) - ) - : {t("bond.noCoupons")}} - - - - - - )} - - ); -}; - -type MetricProps = { label: string; value: string }; - -const Metric: FC = props => ( -
- - {props.label} - - - - {props.value} - -
-); - -type CouponProps = { - coupon: NonNullable["coupons"][number]; -}; - -const Coupon: FC = (props) => { - const { coupon } = props; - const { t } = useTranslation(); - - return ( - - -
- - {coupon.paymentDate - ? formatDate(coupon.paymentDate) - : t("stock.dateUnknown")} - - - - {t("bond.couponNumber", { - number: coupon.number ?? t("bond.unknown"), - })} - -
- - - - {!!coupon.estimatedAmount && ( - - )} -
- - - - {t(`bond.couponTypes.${coupon.couponType}`, { - defaultValue: t("bond.unknown"), - })} - - - {coupon.periodDays != undefined && ( - - {t("bond.couponDays", { count: coupon.periodDays })} - - )} - - {!!coupon.recordDate && ( - - {t("stock.record")}: {formatDate(coupon.recordDate)} - - )} - -
- ); -}; - export { BondPageView }; diff --git a/src/pages/bond/ui/BondPage/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts index 28e3178..46271c9 100644 --- a/src/pages/bond/ui/BondPage/BondPage.vm.ts +++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts @@ -1,7 +1,5 @@ import { useParams, useSearchParams } from "react-router"; -import type { OperationCategory } from "@/entities/broker-operation"; - import { type BondPeriod, useBond, @@ -11,10 +9,8 @@ import { useBondPosition, useBondQuote, } from "@/entities/bond"; -import { - section, - useInstrumentOperations, -} from "@/features/instrument-details"; +import { type OperationCategory, useBrokerageOperationHistory } from "@/entities/broker-operation"; +import { section } from "@/features/instrument-details"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { instrumentLogoUrl } from "@/shared/lib"; @@ -51,12 +47,12 @@ function useBondPageVm() { const position = useBondPosition(uid); const coupons = useBondCoupons(uid); - const history = useInstrumentOperations( - uid, - filter, + const history = useBrokerageOperationHistory(uid ? "all" : undefined, { // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - categories[filter], - ); + categories: categories[filter], + instrumentUid: uid, + limit: 20, + }); const { operations } = history; diff --git a/src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx b/src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx new file mode 100644 index 0000000..abc1ad1 --- /dev/null +++ b/src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx @@ -0,0 +1,152 @@ +import type { FC } from "react"; + +import { Accordion, Alert, Badge, Group, Paper, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +import type { BondCoupons as Coupons } from "@/entities/bond"; + +import { InstrumentSection, type SectionState } from "@/features/instrument-details"; +import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; + +import { Metric } from "./Metric.view"; + +type BondCouponsProps = { + coupons?: Coupons; + pastCoupons: Coupons["coupons"]; + state: SectionState; + upcoming: Coupons["coupons"]; +}; + +const BondCoupons: FC = (props) => { + const { coupons, pastCoupons, state, upcoming } = props; + + const { t } = useTranslation(); + + return ( + + {!!coupons && ( + + {!!(coupons.estimateUnavailable || coupons.positionIsStale) && ( + + {t("stock.estimateUnavailable")} + + )} + + {upcoming.length > 0 + ? ( + upcoming.map(coupon => ( + + )) + ) + : {t("bond.noCoupons")}} + + + {t("bond.couponWindow", { + from: formatDate(coupons.from), + to: formatDate(coupons.to), + })} + + + + {t("bond.couponNote")} + + + {!!coupons.positionAt && ( + + {t("stock.snapshotAt", { + date: formatSnapshotTime(coupons.positionAt), + })} + + )} + + + + {t("bond.couponHistory")} + + + + {pastCoupons.length > 0 + ? ( + pastCoupons.map(coupon => ( + + )) + ) + : {t("bond.noCoupons")}} + + + + + + )} + + ); +}; + +type CouponProps = { + coupon: Coupons["coupons"][number]; +}; + +const Coupon: FC = (props) => { + const { coupon } = props; + const { t } = useTranslation(); + + return ( + + +
+ + {coupon.paymentDate + ? formatDate(coupon.paymentDate) + : t("stock.dateUnknown")} + + + + {t("bond.couponNumber", { + number: coupon.number ?? t("bond.unknown"), + })} + +
+ + + + {!!coupon.estimatedAmount && ( + + )} +
+ + + + {t(`bond.couponTypes.${coupon.couponType}`, { + defaultValue: t("bond.unknown"), + })} + + + {coupon.periodDays != undefined && ( + + {t("bond.couponDays", { count: coupon.periodDays })} + + )} + + {!!coupon.recordDate && ( + + {t("stock.record")}: {formatDate(coupon.recordDate)} + + )} + +
+ ); +}; + +export { BondCoupons }; diff --git a/src/pages/bond/ui/BondPage/components/BondMetrics.view.tsx b/src/pages/bond/ui/BondPage/components/BondMetrics.view.tsx new file mode 100644 index 0000000..6840089 --- /dev/null +++ b/src/pages/bond/ui/BondPage/components/BondMetrics.view.tsx @@ -0,0 +1,150 @@ +import type { FC } from "react"; + +import { Anchor, Group, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +import type { BondMetrics as Metrics } from "@/entities/bond"; + +import { InstrumentSection, type SectionState } from "@/features/instrument-details"; +import { formatDate, formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib"; + +import classes from "../BondPage.module.css"; +import { Metric } from "./Metric.view"; + +type BondMetricsProps = { metrics?: Metrics; state: SectionState }; + +const BondMetrics: FC = (props) => { + const { metrics, state } = props; + const { t } = useTranslation(); + const creditRating = metrics?.creditRating; + + let rating = t( + metrics?.unavailable.includes("rating") + ? "bond.ratingUnavailable" + : "bond.ratingMissing", + ); + + if (creditRating) { + rating + = creditRating.status === "withdrawn" + ? t("bond.ratingWithdrawn") + : (creditRating.value ?? t("bond.unknown")); + } + + const couponDate = metrics?.couponDate + ? formatDate(metrics?.couponDate) + : t("bond.unknown"); + + const effectiveYield = metrics?.effectiveYield + ? `${formatDecimal(metrics?.effectiveYield.value)} %` + : t("bond.unknown"); + + const couponYield + = metrics?.couponYieldPercent == undefined + ? t("bond.unknown") + : `${formatDecimal(metrics?.couponYieldPercent)} %`; + + let yieldContent; + + if (metrics?.effectiveYield) { + yieldContent = ( + + {t("bond.yieldSource", { + at: formatSnapshotTime(metrics?.effectiveYield.tradeAt), + date: formatDate(metrics?.effectiveYield.date), + price: formatDecimal( + metrics?.effectiveYield.pricePercent, + ), + target: t( + `bond.yieldTargets.${metrics?.effectiveYield.dateType}`, + { defaultValue: t("bond.yieldTargets.unknown") }, + ), + })} + + ); + } + else { + yieldContent = ( + + {t("bond.yieldUnavailable")} + + ); + } + + return ( + + {!!metrics && ( + +
+ + + + + + + + + + + +
+ + {!!metrics.creditRating && ( + + + {t("bond.ratingSource", { + date: formatDate(metrics.creditRating.date), + })} + + + + {t("bond.ratingChecked", { + date: formatSnapshotTime(metrics.creditRating.observedAt), + })} + + + )} + + + {t("bond.ratingCoverage")} + + + {yieldContent} + + + {t("bond.couponYieldNote")} + + + {!!metrics?.couponYieldPriceAt && ( + + {t("bond.couponYieldPriceAt", { + date: formatSnapshotTime(metrics?.couponYieldPriceAt), + })} + + )} + + {metrics.unavailable.length > 0 && ( + + {t("bond.metricsPartial")} + + )} +
+ )} +
+ ); +}; + +export { BondMetrics }; diff --git a/src/pages/bond/ui/BondPage/components/Metric.view.tsx b/src/pages/bond/ui/BondPage/components/Metric.view.tsx new file mode 100644 index 0000000..eb6c15f --- /dev/null +++ b/src/pages/bond/ui/BondPage/components/Metric.view.tsx @@ -0,0 +1,19 @@ +import type { FC } from "react"; + +import { Text } from "@mantine/core"; + +type MetricProps = { label: string; value: string }; + +const Metric: FC = props => ( +
+ + {props.label} + + + + {props.value} + +
+); + +export { Metric }; diff --git a/src/pages/stock/ui/StockPage/StockPage.vm.ts b/src/pages/stock/ui/StockPage/StockPage.vm.ts index d92ce0e..2e5d095 100644 --- a/src/pages/stock/ui/StockPage/StockPage.vm.ts +++ b/src/pages/stock/ui/StockPage/StockPage.vm.ts @@ -1,7 +1,6 @@ import { useParams, useSearchParams } from "react-router"; -import type { OperationCategory } from "@/entities/broker-operation"; - +import { type OperationCategory, useBrokerageOperationHistory } from "@/entities/broker-operation"; import { type StockPeriod, useStock, @@ -13,7 +12,6 @@ import { } from "@/entities/stock"; import { section, - useInstrumentOperations, } from "@/features/instrument-details"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; @@ -51,12 +49,12 @@ function useStockPageVm() { const company = useStockCompany(uid); const dividends = useStockDividends(uid); - const history = useInstrumentOperations( - uid, - filter, + const history = useBrokerageOperationHistory(uid ? "all" : undefined, { // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - categories[filter], - ); + categories: categories[filter], + instrumentUid: uid, + limit: 20, + }); const { operations } = history; diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index a2d2b84..3851c8b 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -13,6 +13,7 @@ export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; export { invalidateResourceAccess, + type InvestmentResourceMeta, unwrapResourceResult, useResourceQuery, } from "./resource-query"; diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts index 388da96..02be78c 100644 --- a/src/shared/api/resource-query.ts +++ b/src/shared/api/resource-query.ts @@ -16,9 +16,11 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; const accessEpochs = new WeakMap>(); +type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"]; + type Resource = { data: unknown; - meta: components["schemas"]["InvestmentResourceMetaDto"]; + meta: InvestmentResourceMeta; }; function invalidateResourceAccess( @@ -183,4 +185,9 @@ function useResourceQuery( }; } -export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery }; +export { + invalidateResourceAccess, + type InvestmentResourceMeta, + unwrapResourceResult, + useResourceQuery, +}; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 8fd812a..a3e2668 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -8,4 +8,3 @@ export { formatSnapshotTime, instrumentLogoUrl, } from "./display"; -export { useImageFallback } from "./useImageFallback"; diff --git a/src/shared/lib/useImageFallback.ts b/src/shared/lib/useImageFallback.ts deleted file mode 100644 index 2b14d39..0000000 --- a/src/shared/lib/useImageFallback.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useState } from "react"; - -function useImageFallback() { - const [failed, setFailed] = useState(false); - const onError = () => setFailed(true); - - return { failed, onError }; -} - -export { useImageFallback };