{props.label}
diff --git a/src/features/tbank-connection/index.ts b/src/features/tbank-connection/index.ts
index 850e209..9b07524 100644
--- a/src/features/tbank-connection/index.ts
+++ b/src/features/tbank-connection/index.ts
@@ -1,2 +1,6 @@
export { useScreenSync } from "./model/screen-sync";
+export {
+ InvestmentRefreshState,
+ type InvestmentRefreshStateProps,
+} from "./ui/InvestmentRefreshState/InvestmentRefreshState.view";
export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection";
diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts
index a25a7fe..445e65c 100644
--- a/src/features/tbank-connection/model/connection-boundary.ts
+++ b/src/features/tbank-connection/model/connection-boundary.ts
@@ -9,7 +9,14 @@ import {
type TbankConnectionStatus
= paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
-const brokerageAccountsPath = "/api/v1/brokerage-accounts";
+function isInvestmentPath(path: string) {
+ return [
+ "/api/v1/brokerage-accounts",
+ "/api/v1/stocks",
+ "/api/v1/bonds",
+ "/api/v1/tbank-sync",
+ ].some(root => path === root || path.startsWith(`${root}/`));
+}
const tbankConnectionQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/tbank-connection");
@@ -20,13 +27,7 @@ function commitTbankConnectionBoundary(
queryClient: QueryClient,
status: TbankConnectionStatus,
) {
- invalidateApiRequests(
- path =>
- path.startsWith("/api/v1/brokerage-accounts")
- || path.startsWith("/api/v1/stocks")
- || path.startsWith("/api/v1/bonds")
- || path.startsWith("/api/v1/tbank-sync"),
- );
+ invalidateApiRequests(isInvestmentPath);
queryClient.setQueryData(tbankConnectionQueryKey, status);
void queryClient.resetQueries({
predicate: ({ queryKey }) => {
@@ -35,13 +36,7 @@ function commitTbankConnectionBoundary(
return (
method === "get"
&& typeof path === "string"
- && (path.startsWith("/api/v1/tbank-sync")
- || path === "/api/v1/bonds"
- || path.startsWith("/api/v1/bonds/")
- || path === "/api/v1/stocks"
- || path.startsWith("/api/v1/stocks/")
- || path === brokerageAccountsPath
- || path.startsWith(`${brokerageAccountsPath}/`))
+ && isInvestmentPath(path)
);
},
});
diff --git a/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories.tsx b/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories.tsx
new file mode 100644
index 0000000..651044d
--- /dev/null
+++ b/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories.tsx
@@ -0,0 +1,52 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { Stack, Text } from "@mantine/core";
+import { fn } from "storybook/test";
+
+import { InvestmentRefreshState } from "./InvestmentRefreshState.view";
+
+const meta = {
+ args: { labelScope: "portfolio", refreshError: false, refreshing: false, retry: fn() },
+ argTypes: {
+ refreshError: { control: "boolean" },
+ refreshing: { control: "boolean" },
+ retry: { control: false },
+ },
+ component: InvestmentRefreshState,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Обновление уже показанных данных портфеля. Синтетические суммы остаются видимыми вместе с InvestmentRefreshState при refetch и его ошибке. Суммы синтетические; это presentation Client Cached Snapshot, без QueryClient и запроса API. Retry регистрирует локальное действие в Actions и не доказывает восстановление API.",
+ },
+ },
+ },
+ render: arguments_ => (
+
+ 125 000,50 ₽ · 320 USD
+
+
+
+ ),
+ tags: ["autodocs"],
+ title: "Product/InvestmentRefreshState",
+} satisfies Meta
;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Idle: Story = { name: "Сохранённые данные" };
+export const Refreshing: Story = {
+ args: { refreshing: true },
+ name: "Фоновое обновление",
+};
+export const RefreshError: Story = {
+ args: { labelScope: "portfolio", refreshError: true },
+ name: "Ошибка обновления с retry",
+};
+
+export const SourceFailure: Story = {
+ args: { labelScope: "portfolio", refreshError: true, sourceError: true },
+ name: "Ошибка синхронизации сохраняет суммы",
+};
diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsRefreshState.view.tsx b/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.view.tsx
similarity index 70%
rename from src/pages/accounts/ui/AccountsPage/components/AccountsRefreshState.view.tsx
rename to src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.view.tsx
index 089a4ef..796d95a 100644
--- a/src/pages/accounts/ui/AccountsPage/components/AccountsRefreshState.view.tsx
+++ b/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.view.tsx
@@ -5,7 +5,8 @@ import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib";
-type AccountsRefreshStateProps = {
+type InvestmentRefreshStateProps = {
+ labelScope?: "accounts" | "portfolio";
lastSuccessfulAt?: null | string;
refreshError?: boolean;
refreshing?: boolean;
@@ -13,8 +14,8 @@ type AccountsRefreshStateProps = {
sourceError?: boolean;
};
-const AccountsRefreshState: FC = (props) => {
- const { refreshError = false, refreshing = false, retry } = props;
+const InvestmentRefreshState: FC = (props) => {
+ const { labelScope = "accounts", refreshError = false, refreshing = false, retry } = props;
const { t } = useTranslation();
return (
@@ -37,11 +38,11 @@ const AccountsRefreshState: FC = (props) => {
{!!refreshing && (
-
+
- {t("accounts.refreshing")}
+ {t(`${labelScope}.refreshing`)}
)}
@@ -53,7 +54,7 @@ const AccountsRefreshState: FC = (props) => {
{t(
props.sourceError
? "investment.syncFailed"
- : "accounts.refreshError",
+ : `${labelScope}.refreshError`,
)}
@@ -63,4 +64,4 @@ const AccountsRefreshState: FC = (props) => {
);
};
-export { AccountsRefreshState, type AccountsRefreshStateProps };
+export { InvestmentRefreshState, type InvestmentRefreshStateProps };
diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
index 109f6b4..f9707e0 100644
--- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
@@ -1,12 +1,12 @@
-import { useEffect, useState } from "react";
-import { useParams, useSearchParams } from "react-router";
+import { useState } from "react";
+import { useParams } from "react-router";
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection";
-import { isValidPeriod } from "@/shared/lib";
+import { isValidPeriod, useUrlPeriod } from "@/shared/lib";
type AccountEventsPageViewModel
= | {
@@ -29,18 +29,12 @@ type AccountEventsPageViewModel
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const { accountId = "" } = useParams();
- const [searchParameters, setSearchParameters] = useSearchParams();
- const urlFrom = searchParameters.get("from");
- const urlTo = searchParameters.get("to");
- const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo);
- const appliedFrom = isValidUrlPeriod
- ? urlFrom
- : null;
-
- const appliedTo = isValidUrlPeriod
- ? urlTo
- : null;
+ const {
+ from: appliedFrom,
+ setParameters: setSearchParameters,
+ to: appliedTo,
+ } = useUrlPeriod();
const [draftPeriod, setDraftPeriod] = useState<[
null | string,
@@ -48,13 +42,6 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
]>([appliedFrom, appliedTo]);
const [draftFrom, draftTo] = draftPeriod;
- useEffect(() => {
- if (isValidUrlPeriod) return;
- const normalized = new URLSearchParams(searchParameters);
- normalized.delete("from");
- normalized.delete("to");
- setSearchParameters(normalized, { replace: true });
- }, [isValidUrlPeriod, searchParameters, setSearchParameters]);
const scope = JSON.stringify([accountId, appliedFrom, appliedTo]);
const [previousScope, setPreviousScope] = useState(scope);
diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
index 362a84f..649e9e1 100644
--- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
@@ -12,7 +12,10 @@ import {
import { DatePickerInput } from "@mantine/dates";
import { useTranslation } from "react-i18next";
-import { BrokerageEventCard } from "@/entities/brokerage-event";
+import {
+ BrokerageEventCard,
+ brokerageEventKey,
+} from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -33,15 +36,7 @@ const AccountEventsPageLoadedEvents: FC = (
{props.events.map(event => (
))}
diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css
index cf9807e..2e737bc 100644
--- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css
+++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css
@@ -22,13 +22,6 @@
min-width: 0;
}
-/* Keep the reused Ledger row's full text readable inside income cards. */
-.entries :global(.mantine-Text-root) {
- -webkit-line-clamp: unset;
- white-space: normal;
- overflow-wrap: anywhere;
-}
-
.comparison {
container-type: inline-size;
}
diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx
index fd85150..ab7507b 100644
--- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx
@@ -5,13 +5,11 @@ import { useTranslation } from "react-i18next";
import { LedgerEntryRow } from "@/entities/broker-operation";
-import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
+import type { IncomeDisplayModel } from "../../../model/display";
import classes from "../AccountIncomePage.module.css";
-type IncomePaymentsProps = { entries: LoadedProps["display"]["entries"] };
-
-type LoadedProps = Extract;
+type IncomePaymentsProps = { entries: IncomeDisplayModel["entries"] };
const IncomePayments: FC = (props) => {
const { t } = useTranslation();
@@ -23,7 +21,7 @@ const IncomePayments: FC = (props) => {
{props.entries.map(({ key, operation }) => (
-
+
))}
diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts
index 64a4f47..6b9f5fe 100644
--- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts
+++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts
@@ -1,6 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
-import { useEffect, useState } from "react";
-import { useParams, useSearchParams } from "react-router";
+import { useState } from "react";
+import { useParams } from "react-router";
import {
type BrokerageOperation,
@@ -11,7 +11,7 @@ import {
} from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
-import { isValidPeriod } from "@/shared/lib";
+import { isValidPeriod, useUrlPeriod } from "@/shared/lib";
const operationCategorySet = new Set(operationCategories);
@@ -44,18 +44,13 @@ type AccountOperationsPageViewModel
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const { accountId } = useParams();
const queryClient = useQueryClient();
- const [searchParameters, setSearchParameters] = useSearchParams();
- const urlFrom = searchParameters.get("from");
- const urlTo = searchParameters.get("to");
- const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo);
- const appliedFrom = isValidUrlPeriod
- ? urlFrom
- : null;
-
- const appliedTo = isValidUrlPeriod
- ? urlTo
- : null;
+ const {
+ from: appliedFrom,
+ parameters: searchParameters,
+ setParameters: setSearchParameters,
+ to: appliedTo,
+ } = useUrlPeriod();
const appliedCategories = operationCategories.filter(category =>
(searchParameters.get("categories") ?? "").split(",").includes(category),
@@ -71,14 +66,6 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const [draftCategories, setDraftCategories]
= useState(appliedCategories);
- useEffect(() => {
- if (isValidUrlPeriod) return;
- const normalized = new URLSearchParams(searchParameters);
- normalized.delete("from");
- normalized.delete("to");
- setSearchParameters(normalized, { replace: true });
- }, [isValidUrlPeriod, searchParameters, setSearchParameters]);
-
const firstPageQuery = {
from: dateStart(appliedFrom),
to: dateEnd(appliedTo),
diff --git a/src/pages/account/model/previews.ts b/src/pages/account/model/previews.ts
new file mode 100644
index 0000000..7390268
--- /dev/null
+++ b/src/pages/account/model/previews.ts
@@ -0,0 +1,144 @@
+import type { BrokerageOperation } from "@/entities/broker-operation";
+import type {
+ BrokerageEvent,
+ BrokerageEventsPreviewState,
+} from "@/entities/brokerage-event";
+import type { IncomeSummary } from "@/entities/income";
+import type { InvestmentResourceMeta } from "@/shared/api";
+
+const RECENT_EVENTS_LIMIT = 5;
+
+type AccountPageIncomeSummary
+ = | {
+ lastSuccessfulAt?: null | string;
+ refreshError?: boolean;
+ stale?: boolean;
+ state: "loaded";
+ summary: IncomeSummary;
+ to: string;
+ }
+ | { retry: () => Promise | undefined; state: "error"; to: string }
+ | {
+ state: "empty"
+ | "loading"
+ | "partial";
+ to: string;
+ };
+
+type AccountPageRecentEvents = BrokerageEventsPreviewState;
+
+type AccountPageRecentOperations
+ = | {
+ items: BrokerageOperation[];
+ lastSuccessfulAt?: null | string;
+ refreshError?: boolean;
+ stale?: boolean;
+ state: "loaded" | "partial";
+ to: string;
+ }
+ | {
+ state: "empty"
+ | "error"
+ | "loading";
+ to: string;
+ };
+
+type PreviewResource = {
+ data?: T;
+ isError: boolean;
+ meta?: Pick;
+};
+
+function toIncomeSummary(
+ accountId: string | undefined,
+ income: PreviewResource,
+ retry: () => Promise,
+): AccountPageIncomeSummary {
+ const to = `/accounts/${accountId}/income`;
+ if (!income.data && !income.isError && income.meta?.sync.state !== "failed")
+ return { state: "loading", to };
+ if (income.meta?.availability === "partial") return { state: "partial", to };
+ if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
+ return { retry, state: "error", to };
+ if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
+
+ return {
+ lastSuccessfulAt: income.meta?.lastSuccessfulAt,
+ refreshError: income.isError || income.meta?.sync.state === "failed",
+ stale: income.meta?.freshness === "stale",
+ state: "loaded",
+ summary: income.data,
+ to,
+ };
+}
+
+function toRecentEvents(
+ accountId: string | undefined,
+ events: PreviewResource<{ events: BrokerageEvent[] }>,
+): AccountPageRecentEvents {
+ const to = `/accounts/${accountId}/events`;
+ if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
+ return { state: "loading", to };
+ if (events.meta?.availability === "partial")
+ return {
+ items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
+ state: "partial",
+ to,
+ };
+ if (!events.data && (events.isError || events.meta?.sync.state === "failed"))
+ return { state: "error", to };
+ const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
+ if (items.length === 0) return { state: "empty", to };
+
+ return {
+ items,
+ lastSuccessfulAt: events.meta?.lastSuccessfulAt,
+ refreshError: events.isError || events.meta?.sync.state === "failed",
+ stale: events.meta?.freshness === "stale",
+ state: "loaded",
+ to,
+ };
+}
+
+function toRecentOperations(
+ accountId: string | undefined,
+ operations: PreviewResource<{ items: BrokerageOperation[] }>,
+): AccountPageRecentOperations {
+ const to = `/accounts/${accountId}/operations`;
+ if (
+ !operations.data
+ && !operations.isError
+ && operations.meta?.sync.state !== "failed"
+ )
+ return { state: "loading", to };
+ if (operations.meta?.availability === "partial")
+ return { items: operations.data?.items ?? [], state: "partial", to };
+ if (
+ !operations.data
+ && (operations.isError || operations.meta?.sync.state === "failed")
+ )
+ return { state: "error", to };
+ if (!operations.data?.items.length) return { state: "empty", to };
+
+ return {
+ items: operations.data.items,
+ lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
+ refreshError:
+ operations.isError || operations.meta?.sync.state === "failed",
+ stale: operations.meta?.freshness === "stale",
+ state: "loaded",
+ to,
+ };
+}
+
+export { toIncomeSummary, toRecentEvents, toRecentOperations };
+export type {
+ AccountPageIncomeSummary,
+ AccountPageRecentEvents,
+ AccountPageRecentOperations,
+};
diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx
index ebe4aff..1a8a691 100644
--- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx
+++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx
@@ -22,7 +22,7 @@ import { server } from "@/test/server";
import type {
AccountPageRecentEvents,
AccountPageRecentOperations,
-} from "./AccountPage.vm";
+} from "../../model/previews";
import { toAccountPagePositionSections } from "../../model/positions";
import { AccountPage } from "./AccountPage";
diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts
index 69fb200..77c0341 100644
--- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts
+++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts
@@ -1,6 +1,5 @@
import { useParams } from "react-router";
-import type { BrokerageOperation } from "@/entities/broker-operation";
import type { Portfolio } from "@/entities/portfolio";
import { useBrokerageOperations } from "@/entities/broker-operation";
@@ -9,17 +8,25 @@ import {
useBrokerageAccounts,
} from "@/entities/brokerage-account";
import {
- type BrokerageEventsPreviewState,
useBrokerageEvents,
} from "@/entities/brokerage-event";
-import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
+import { useIncomeSummary } from "@/entities/income";
import { usePortfolio } from "@/entities/portfolio";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
-import { type AccountPagePositionSection, toAccountPagePositionSections } from "../../model/positions";
-
-const RECENT_EVENTS_LIMIT = 5;
+import {
+ type AccountPagePositionSection,
+ toAccountPagePositionSections,
+} from "../../model/positions";
+import {
+ type AccountPageIncomeSummary,
+ type AccountPageRecentEvents,
+ type AccountPageRecentOperations,
+ toIncomeSummary,
+ toRecentEvents,
+ toRecentOperations,
+} from "../../model/previews";
type AccountPageContent = {
account?: BrokerageAccount;
@@ -38,131 +45,18 @@ type AccountPageEmptyViewModel = AccountPageContent & {
state: "empty";
};
-type AccountPageIncomeSummary
- = | {
- lastSuccessfulAt?: null | string;
- refreshError?: boolean;
- stale?: boolean;
- state: "loaded";
- summary: IncomeSummary;
- to: string;
- }
- | { retry: () => Promise | undefined; state: "error"; to: string }
- | { state: "empty"
- | "loading"
- | "partial"; to: string; };
-
type AccountPageLoadedViewModel = AccountPageContent & {
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
state: "loaded";
};
-type AccountPageRecentEvents = BrokerageEventsPreviewState;
-
-type AccountPageRecentOperations
- = | {
- items: BrokerageOperation[];
- lastSuccessfulAt?: null | string;
- refreshError?: boolean;
- stale?: boolean;
- state: "loaded" | "partial";
- to: string;
- }
- | {
- state: "empty"
- | "error"
- | "loading";
- to: string;
- };
-
type AccountPageViewModel
= | AccountPageEmptyViewModel
| AccountPageLoadedViewModel
| { code?: string; retry: () => Promise | undefined; state: "error" }
| { state: "loading" };
-function toIncomeSummary(
- accountId: string | undefined,
- income: ReturnType,
-): AccountPageIncomeSummary {
- const to = `/accounts/${accountId}/income`;
- if (!income.data && !income.isError && income.meta?.sync.state !== "failed")
- return { state: "loading", to };
- if (income.meta?.availability === "partial") return { state: "partial", to };
- if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
- return { retry: () => income.refetch(), state: "error", to };
- if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
-
- return {
- lastSuccessfulAt: income.meta?.lastSuccessfulAt,
- refreshError: income.isError || income.meta?.sync.state === "failed",
- stale: income.meta?.freshness === "stale",
- state: "loaded",
- summary: income.data,
- to,
- };
-}
-
-function toRecentEvents(
- accountId: string | undefined,
- events: ReturnType,
-): AccountPageRecentEvents {
- const to = `/accounts/${accountId}/events`;
- if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
- return { state: "loading", to };
- if (events.meta?.availability === "partial")
- return {
- items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
- state: "partial",
- to,
- };
- if (!events.data && (events.isError || events.meta?.sync.state === "failed"))
- return { state: "error", to };
- const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
- if (items.length === 0) return { state: "empty", to };
-
- return {
- items,
- lastSuccessfulAt: events.meta?.lastSuccessfulAt,
- refreshError: events.isError || events.meta?.sync.state === "failed",
- stale: events.meta?.freshness === "stale",
- state: "loaded",
- to,
- };
-}
-
-function toRecentOperations(
- accountId: string | undefined,
- operations: ReturnType,
-): AccountPageRecentOperations {
- const to = `/accounts/${accountId}/operations`;
- if (
- !operations.data
- && !operations.isError
- && operations.meta?.sync.state !== "failed"
- )
- return { state: "loading", to };
- if (operations.meta?.availability === "partial")
- return { items: operations.data?.items ?? [], state: "partial", to };
- if (
- !operations.data
- && (operations.isError || operations.meta?.sync.state === "failed")
- )
- return { state: "error", to };
- if (!operations.data?.items.length) return { state: "empty", to };
-
- return {
- items: operations.data.items,
- lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
- refreshError:
- operations.isError || operations.meta?.sync.state === "failed",
- stale: operations.meta?.freshness === "stale",
- state: "loaded",
- to,
- };
-}
-
function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams();
const portfolio = usePortfolio(accountId);
@@ -197,7 +91,10 @@ function useAccountPageViewModel(): AccountPageViewModel {
const account = accounts.data?.find(item => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations);
const recentEvents = toRecentEvents(accountId, events);
- const incomeSummary = toIncomeSummary(accountId, income);
+
+ const incomeSummary = toIncomeSummary(
+ accountId, income, () => income.refetch(),
+ );
const refresh = {
partial: portfolio.meta?.availability === "partial",
@@ -235,9 +132,4 @@ function useAccountPageViewModel(): AccountPageViewModel {
};
}
-export {
- type AccountPageIncomeSummary,
- type AccountPageRecentEvents,
- type AccountPageRecentOperations,
- useAccountPageViewModel,
-};
+export { useAccountPageViewModel };
diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx
index bcea919..1d3a28b 100644
--- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx
@@ -7,16 +7,16 @@ import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
+import { InvestmentRefreshState } from "@/features/tbank-connection";
import type {
AccountPageIncomeSummary,
AccountPageRecentEvents,
AccountPageRecentOperations,
-} from "../AccountPage.vm";
+} from "../../../model/previews";
import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view";
-import { PortfolioRefreshState } from "./PortfolioRefreshState/PortfolioRefreshState.view";
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { RecentOperations } from "./RecentOperations.view";
@@ -45,7 +45,8 @@ const AccountPageEmpty: FC = (props) => {
- = (props) => {
- (
-
-
-
-
-
- ),
- tags: ["autodocs"],
- title: "Product/PortfolioRefreshState",
-} satisfies Meta;
-
-export default meta;
-
-type Story = StoryObj;
-
-export const Idle: Story = { name: "Сохранённые данные" };
-export const Refreshing: Story = {
- args: { refreshing: true },
- name: "Фоновое обновление",
-};
-export const RefreshError: Story = {
- args: { refreshError: true },
- name: "Ошибка обновления с retry",
-};
-
-export const SourceFailure: Story = {
- args: { refreshError: true, sourceError: true },
- name: "Ошибка синхронизации сохраняет суммы",
-};
diff --git a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx
deleted file mode 100644
index 1507457..0000000
--- a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import type { FC } from "react";
-
-import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
-import { useTranslation } from "react-i18next";
-
-type PortfolioRefreshStateProps = {
- refreshError: boolean;
- refreshing: boolean;
- retry?: () => void;
- sourceError?: boolean;
-};
-
-const PortfolioRefreshState: FC = (props) => {
- const { refreshError, refreshing, retry } = props;
- const { t } = useTranslation();
-
- return (
- <>
-
-
- {!!refreshing && (
-
-
-
-
- {t("portfolio.refreshing")}
-
-
- )}
-
- {!!refreshError && (
-
-
-
- {t(
- props.sourceError
- ? "investment.syncFailed"
- : "portfolio.refreshError",
- )}
-
-
-
- )}
- >
- );
-};
-
-export { PortfolioRefreshState };
diff --git a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx
index ff81b82..0ba28bc 100644
--- a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx
@@ -8,7 +8,7 @@ import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib";
-import type { AccountPageRecentOperations } from "../AccountPage.vm";
+import type { AccountPageRecentOperations } from "../../../model/previews";
type RecentOperationsProps = {
operations: AccountPageRecentOperations;
diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx
index 3e007ab..a7908cf 100644
--- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx
+++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx
@@ -5,16 +5,16 @@ import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import {
- AccountsRefreshState,
- type AccountsRefreshStateProps,
-} from "./AccountsRefreshState.view";
+ InvestmentRefreshState,
+ type InvestmentRefreshStateProps,
+} from "@/features/tbank-connection";
-const AccountsPageEmpty: FC = (props) => {
+const AccountsPageEmpty: FC = (props) => {
const { t } = useTranslation();
return (
-
+
= (props) => {
-
+
+
);
};
diff --git a/src/pages/bond/ui/BondPage/components/BondMetrics.view.tsx b/src/pages/bond/ui/BondPage/components/BondMetrics.view.tsx
index b137627..bb64e45 100644
--- a/src/pages/bond/ui/BondPage/components/BondMetrics.view.tsx
+++ b/src/pages/bond/ui/BondPage/components/BondMetrics.view.tsx
@@ -5,8 +5,18 @@ import { useTranslation } from "react-i18next";
import type { BondMetrics as Metrics } from "@/entities/bond";
-import { instrumentDetailsStyles, InstrumentMetric, InstrumentSection, type SectionState } from "@/features/instrument-details";
-import { formatDate, formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib";
+import {
+ InstrumentMetric,
+ InstrumentMetrics,
+ InstrumentSection,
+ type SectionState,
+} from "@/features/instrument-details";
+import {
+ formatDate,
+ formatDecimal,
+ formatMoney,
+ formatSnapshotTime,
+} from "@/shared/lib";
type BondMetricsProps = { metrics?: Metrics; state: SectionState };
@@ -72,7 +82,7 @@ const BondMetrics: FC = (props) => {
{!!metrics && (
-
+
= (props) => {
-
+
{!!metrics.creditRating && (
diff --git a/src/pages/card/ui/CardPage/CardHistory.view.tsx b/src/pages/card/ui/CardPage/CardHistory.view.tsx
new file mode 100644
index 0000000..5111a95
--- /dev/null
+++ b/src/pages/card/ui/CardPage/CardHistory.view.tsx
@@ -0,0 +1,126 @@
+import {
+ ActionIcon,
+ Anchor,
+ Group,
+ Skeleton,
+ Stack,
+ Text,
+} from "@mantine/core";
+import {
+ DeleteIcon,
+ EditIcon,
+ OperationIcon,
+} from "@wealthledger/icons";
+import { EntryCard, LoadingSkeleton } from "@wealthledger/ui";
+import { type FC } from "react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+
+import {
+ bankOperationAppearance,
+} from "@/entities/bank-card";
+
+import type { CardPageVm } from "./CardPage.vm";
+
+import classes from "./CardPage.module.css";
+
+type CardHistoryProps = Pick;
+
+// eslint-disable-next-line sonarjs/function-return-type -- React renders elements, lists and empty output.
+const CardHistory: FC = (props) => {
+ const vm = props;
+ const { t } = useTranslation();
+
+ if (vm.rows) {
+ if (vm.rows.length > 0) {
+ return vm.rows.map((row) => {
+ const details = row.otherCard
+ ? (
+
+ {t("bank.card")}
+
+ )
+ : undefined;
+
+ return (
+
+ )}
+ color={row.color}
+ details={details}
+ icon={(
+
+ )}
+ initial={row.title.charAt(0)}
+ metadata={row.metadata}
+ title={row.title}
+ tone={row.tone}
+ value={row.value}
+ valueLabel={t(`bank.kinds.${row.item.kind}`)}
+ wrap
+ />
+
+ );
+ });
+ }
+
+ return {t("bank.noOperations")};
+ }
+
+ if (vm.history.loading) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ return null;
+};
+
+export { CardHistory };
diff --git a/src/pages/card/ui/CardPage/CardPage.view.tsx b/src/pages/card/ui/CardPage/CardPage.view.tsx
index 8385c93..e839f64 100644
--- a/src/pages/card/ui/CardPage/CardPage.view.tsx
+++ b/src/pages/card/ui/CardPage/CardPage.view.tsx
@@ -1,6 +1,5 @@
import {
ActionIcon,
- Anchor,
Button,
Group,
Pagination,
@@ -14,10 +13,9 @@ import {
AddIcon,
DeleteIcon,
EditIcon,
- OperationIcon,
RestoreIcon,
} from "@wealthledger/icons";
-import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui";
+import { LoadingSkeleton, StateView } from "@wealthledger/ui";
import { type FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -25,114 +23,18 @@ import { Link } from "react-router";
import {
BankCardFace,
BankFeedback,
- bankOperationAppearance,
BankPeriodPicker,
BankSummaryView,
} from "@/entities/bank-card";
import type { CardPageVm } from "./CardPage.vm";
+import { CardHistory } from "./CardHistory.view";
import classes from "./CardPage.module.css";
import { useCardPagePresentationVm } from "./CardPagePresentation.vm";
type CardPageViewProps = { vm: CardPageVm };
-// eslint-disable-next-line sonarjs/function-return-type -- React renders elements, lists and empty output.
-const CardPageViewHistory: FC = (props) => {
- const { vm } = props;
- const { t } = useTranslation();
- const card = vm.card;
-
- if (!card) return;
-
- if (vm.rows) {
- if (vm.rows.length > 0) {
- return vm.rows.map((row) => {
- const details = row.otherCard
- ? (
-
- {t("bank.card")}
-
- )
- : undefined;
-
- return (
-
- )}
- color={row.color}
- details={details}
- icon={(
-
- )}
- initial={row.title.charAt(0)}
- metadata={row.metadata}
- title={row.title}
- tone={row.tone}
- value={row.value}
- valueLabel={t(`bank.kinds.${row.item.kind}`)}
- wrap
- />
-
- );
- });
- }
-
- return {t("bank.noOperations")};
- }
-
- if (vm.history.loading) {
- return (
-
-
-
-
-
-
-
- );
- }
-
- return null;
-};
-
const CardPageView: FC = (props) => {
const { vm } = props;
@@ -291,7 +193,7 @@ const CardPageView: FC = (props) => {
gap="lg"
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.page ?? "loading"}`}
>
-
+
{vm.history.error && (
diff --git a/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts b/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts
index d61f3de..14f57b7 100644
--- a/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts
+++ b/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts
@@ -6,7 +6,13 @@ import { useTranslation } from "react-i18next";
import type { CardPageVm } from "./CardPage.vm";
-function useCardPagePresentationVm(vm: CardPageVm) {
+function useCardPagePresentationVm(vm: Pick) {
const { t } = useTranslation();
const card = vm.card;
const paginationRef = useRef(null);
diff --git a/src/pages/card/ui/OperationForm/OperationFields.vm.ts b/src/pages/card/ui/OperationForm/OperationFields.vm.ts
index 01d247e..bca394c 100644
--- a/src/pages/card/ui/OperationForm/OperationFields.vm.ts
+++ b/src/pages/card/ui/OperationForm/OperationFields.vm.ts
@@ -1,6 +1,3 @@
-import type { DateInput } from "@mantine/dates";
-import type { ComponentProps } from "react";
-
import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -10,10 +7,9 @@ import type {
} from "./OperationForm.types";
function useOperationFieldsVm(props: Pick) {
const { t } = useTranslation();
@@ -48,38 +44,13 @@ function useOperationFieldsVm(props: Pick["dateParser"] = value =>
- props.validDate(props.dateInputValue(value))
- ? props.dateInputValue(value)
- : null;
-
- const onDateBlur: ComponentProps["onBlur"] = (event) => {
- dateField.onChange(props.dateInputValue(event.currentTarget.value));
- dateField.onBlur();
- };
-
- const onDateChange: (value: null | string) => void = value =>
- dateField.onChange(value ?? "");
-
- const onDateInput: ComponentProps["onInput"] = event =>
- dateField.onChange(props.dateInputValue(event.currentTarget.value));
-
- const dateValue = props.validDate(dateField.value)
- ? dateField.value
- : undefined;
-
return {
cardIdField,
categoryIdField,
- dateField,
- dateParser,
- dateValue,
+ date: { ...dateField, ...props.bindDate(dateField) },
errors,
isSubmitting,
kindField,
- onDateBlur,
- onDateChange,
- onDateInput,
onKindChange,
register,
sourceCardLabel,
diff --git a/src/pages/card/ui/OperationForm/OperationForm.stories.tsx b/src/pages/card/ui/OperationForm/OperationForm.stories.tsx
index 54f2c93..32c9211 100644
--- a/src/pages/card/ui/OperationForm/OperationForm.stories.tsx
+++ b/src/pages/card/ui/OperationForm/OperationForm.stories.tsx
@@ -43,9 +43,14 @@ const OperationExample: FC = (props) => {
)
}
fields={{
+ bindDate: field => ({
+ ...field,
+ dateParser: () => "2026-08-01",
+ onChange: value => field.onChange(value ?? ""),
+ onInput: () => {},
+ }),
cards: [{ label: "Учебная карта", value: "demo" }],
categories: [],
- dateInputValue: () => "2026-08-01",
defaultCategory: "",
editing: true,
kind,
@@ -53,7 +58,6 @@ const OperationExample: FC = (props) => {
maxDate: () => "2026-08-27",
refundCardId: "demo",
targets: [],
- validDate: value => value === "2026-08-01",
}}
latest={
props.conflict
@@ -107,7 +111,7 @@ const meta = {
docs: {
description: {
component:
- "Synthetic presentation без API/VM. Date callbacks фиксируют fixture 01.08.2026; денежная validation, recovery и nested category flow проверяются application tests. Actions только локальные.",
+ "Synthetic presentation без API/VM. денежная validation, recovery и nested category flow проверяются application tests. Actions только локальные.",
},
},
},
diff --git a/src/pages/card/ui/OperationForm/OperationForm.types.ts b/src/pages/card/ui/OperationForm/OperationForm.types.ts
index ea4adad..63f77fb 100644
--- a/src/pages/card/ui/OperationForm/OperationForm.types.ts
+++ b/src/pages/card/ui/OperationForm/OperationForm.types.ts
@@ -1,11 +1,15 @@
-import type { BankCard, BankOperation } from "@/entities/bank-card";
+import type {
+ BankCard,
+ bankDateField,
+ BankOperation,
+} from "@/entities/bank-card";
type Choice = { label: string; value: string };
type OperationFieldsProps = {
+ bindDate: typeof bankDateField;
cards: Choice[];
categories: Choice[];
- dateInputValue: (value: string) => string;
defaultCategory: string;
editing: boolean;
kind: OperationValues["kind"];
@@ -13,7 +17,6 @@ type OperationFieldsProps = {
maxDate: () => string;
refundCardId: string;
targets: Choice[];
- validDate: (value: string) => boolean;
};
type OperationFormProps = {
diff --git a/src/pages/card/ui/OperationForm/OperationForm.view.tsx b/src/pages/card/ui/OperationForm/OperationForm.view.tsx
index 5322d7d..7161d1f 100644
--- a/src/pages/card/ui/OperationForm/OperationForm.view.tsx
+++ b/src/pages/card/ui/OperationForm/OperationForm.view.tsx
@@ -1,6 +1,7 @@
import type { FC, ReactNode, SubmitEventHandler } from "react";
-import { Button,
+import {
+ Button,
Fieldset,
Group,
Modal,
@@ -26,15 +27,10 @@ const OperationFields: FC = (props) => {
const {
cardIdField,
categoryIdField,
- dateField,
- dateParser,
- dateValue,
+ date,
errors,
isSubmitting,
kindField,
- onDateBlur,
- onDateChange,
- onDateInput,
onKindChange,
register,
sourceCardLabel,
@@ -82,21 +78,16 @@ const OperationFields: FC = (props) => {
/>
diff --git a/src/pages/card/ui/OperationForm/OperationForm.vm.ts b/src/pages/card/ui/OperationForm/OperationForm.vm.ts
index c45f178..449f0d1 100644
--- a/src/pages/card/ui/OperationForm/OperationForm.vm.ts
+++ b/src/pages/card/ui/OperationForm/OperationForm.vm.ts
@@ -4,14 +4,12 @@ import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
- bankDateInputValue,
- bankDateSchema,
+ bankDateField,
bankMoneyValue,
type BankOperation,
bankOperationKinds,
bankToday,
useBankCategories,
- useBankExpenses,
useBankFormFocus,
useBankOperation,
useCreateBankOperation,
@@ -25,6 +23,7 @@ import type {
} from "./OperationForm.types";
import { createOperationSchema } from "./OperationForm.schema";
+import { useRefundPurchase } from "./useRefundPurchase";
const useOperationFormVm = (props: OperationFormProps) => {
useBankFormFocus();
@@ -33,7 +32,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
const [categoriesOpen, setCategoriesOpen] = useState(false);
const [version, setVersion] = useState(props.operation?.version ?? 1);
const [latest, setLatest] = useState();
- const [sourcePage, setSourcePage] = useState(1);
const currentQuery = useBankOperation(props.operation?.id);
@@ -72,20 +70,14 @@ const useOperationFormVm = (props: OperationFormProps) => {
)(values, context, options),
});
- const sourceId = useWatch({
- control: methods.control,
- name: "originalExpenseId",
- });
-
- const sourceQuery = useBankOperation(sourceId || undefined);
-
const kind = useWatch({ control: methods.control, name: "kind" });
- const expenses = useBankExpenses(
- kind === "refund" ? props.card.id : undefined,
- { page: sourcePage },
+ const purchase = useRefundPurchase(
+ methods.control, props.card.id, kind, Boolean(props.operation),
);
+ const { sourceId, sourceQuery } = purchase;
+
const source = useWatch({ control: methods.control, name: "cardId" });
const isPending = !opened || methods.formState.isSubmitting;
@@ -149,23 +141,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
else setError(result.error);
};
- const expenseChoice = (item: BankOperation) => ({
- label: t("bank.expenseChoice", {
- amount: formatMoney(item.amount),
- available: formatMoney(item.availableRefund),
- date: formatDate(item.date),
- name: item.description || item.categoryName,
- }),
- value: item.id,
- });
-
- const expenseOptions
- = expenses.data?.items.map(element => expenseChoice(element)) ?? [];
-
- const selected = sourceQuery.data
- ? expenseChoice(sourceQuery.data)
- : undefined;
-
return {
categoriesError: categories.isError
? categories.error
@@ -173,16 +148,14 @@ const useOperationFormVm = (props: OperationFormProps) => {
categoriesOpen,
closeCategories: () => setCategoriesOpen(false),
error,
- expensesError: expenses.isError
- ? expenses.error
- : null,
+ expensesError: purchase.error,
methods,
onCategoryCreated: (id: string) => {
methods.setValue("categoryId", id);
setCategoriesOpen(false);
},
retryCategories: () => void categories.refetch(),
- retryExpenses: () => void expenses.refetch(),
+ retryExpenses: purchase.retry,
retrySource: () => void sourceQuery.refetch(),
sourceError: sourceQuery.isError
? sourceQuery.error
@@ -190,14 +163,12 @@ const useOperationFormVm = (props: OperationFormProps) => {
view: {
categoriesOpen,
editing: Boolean(props.operation),
- emptyExpenses: Boolean(
- expenses.data && expenses.data.items.length === 0 && !props.operation,
- ),
+ emptyExpenses: purchase.empty,
fields: {
+ bindDate: bankDateField,
cards: options,
categories:
categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [],
- dateInputValue: bankDateInputValue,
defaultCategory: categories.data?.find(c => c.isDefault)?.id ?? "",
editing: Boolean(props.operation),
kind,
@@ -208,7 +179,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
maxDate: bankToday,
refundCardId: props.card.id,
targets: options.filter(c => c.value !== source),
- validDate: (value: string) => bankDateSchema.safeParse(value).success,
},
latest: latest
? {
@@ -221,26 +191,22 @@ const useOperationFormVm = (props: OperationFormProps) => {
version: latest.version,
}
: undefined,
- loadingExpenses: expenses.isLoading,
- nextDisabled: !expenses.data?.hasNext || expenses.isFetching,
+ loadingExpenses: purchase.loading,
+ nextDisabled: purchase.nextDisabled,
onAddCategory: () => setCategoriesOpen(true),
onCancel: () => setOpened(false),
onClose: () => !isPending && !categoriesOpen && setOpened(false),
onExitTransitionEnd: props.onClose,
- onNext: () => setSourcePage(sourcePage + 1),
- onPrevious: () => setSourcePage(sourcePage - 1),
+ onNext: purchase.onNext,
+ onPrevious: purchase.onPrevious,
onReload: error && (sourceId || props.operation) ? onReload : undefined,
onSubmit,
opened,
pending: isPending,
- previousDisabled: sourcePage === 1 || expenses.isFetching,
+ previousDisabled: purchase.previousDisabled,
refund: {
editing: Boolean(props.operation),
- expenses:
- selected
- && expenseOptions.every(item => item.value !== selected.value)
- ? [selected, ...expenseOptions]
- : expenseOptions,
+ expenses: purchase.options,
limit: props.operation
? refundLimit
: sourceQuery.data?.availableRefund,
@@ -248,7 +214,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
saveDisabled:
(kind === "expense" && !categories.data)
|| (kind === "refund" && !sourceQuery.data),
- sourcePage,
+ sourcePage: purchase.page,
},
};
};
diff --git a/src/pages/card/ui/OperationForm/useRefundPurchase.ts b/src/pages/card/ui/OperationForm/useRefundPurchase.ts
new file mode 100644
index 0000000..0cd05d7
--- /dev/null
+++ b/src/pages/card/ui/OperationForm/useRefundPurchase.ts
@@ -0,0 +1,61 @@
+import type { Control } from "react-hook-form";
+
+import { useState } from "react";
+import { useWatch } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+import {
+ type BankOperation,
+ useBankExpenses,
+ useBankOperation,
+} from "@/entities/bank-card";
+import { formatDate, formatMoney } from "@/shared/lib";
+
+import type { OperationValues } from "./OperationForm.types";
+
+function useRefundPurchase(
+ control: Control,
+ cardId: string,
+ kind: OperationValues["kind"],
+ isEditing: boolean,
+) {
+ const { t } = useTranslation();
+ const [page, setPage] = useState(1);
+ const sourceId = useWatch({ control, name: "originalExpenseId" });
+ const sourceQuery = useBankOperation(sourceId || undefined);
+ const expenses = useBankExpenses(kind === "refund" ? cardId : undefined, { page });
+
+ const choice = (item: BankOperation) => ({
+ label: t("bank.expenseChoice", {
+ amount: formatMoney(item.amount),
+ available: formatMoney(item.availableRefund),
+ date: formatDate(item.date),
+ name: item.description || item.categoryName,
+ }),
+ value: item.id,
+ });
+
+ const options = expenses.data?.items.map(item => choice(item)) ?? [];
+ const selected = sourceQuery.data ? choice(sourceQuery.data) : undefined;
+
+ return {
+ empty: Boolean(
+ !isEditing && expenses.data && expenses.data.items.length === 0,
+ ),
+ error: expenses.isError ? expenses.error : null,
+ loading: expenses.isLoading,
+ nextDisabled: !expenses.data?.hasNext || expenses.isFetching,
+ onNext: () => setPage(page + 1),
+ onPrevious: () => setPage(page - 1),
+ options: selected && options.every(item => item.value !== selected.value)
+ ? [selected, ...options]
+ : options,
+ page,
+ previousDisabled: page === 1 || expenses.isFetching,
+ retry: () => void expenses.refetch(),
+ sourceId,
+ sourceQuery,
+ };
+}
+
+export { useRefundPurchase };
diff --git a/src/pages/settings/ui/SettingsPage/SettingsPage.vm.ts b/src/pages/settings/ui/SettingsPage/SettingsPage.vm.ts
index 9ee1cbb..c048de0 100644
--- a/src/pages/settings/ui/SettingsPage/SettingsPage.vm.ts
+++ b/src/pages/settings/ui/SettingsPage/SettingsPage.vm.ts
@@ -1,16 +1,18 @@
import { useTranslation } from "react-i18next";
-import { useNavigate } from "react-router";
import { useAuth } from "@/features/auth";
function useSettingsPageVm() {
const { t } = useTranslation();
const { logout } = useAuth();
- const navigate = useNavigate();
const onLogout = async () => {
- await logout();
- await navigate("/login", { replace: true });
+ try {
+ await logout();
+ }
+ catch {
+ // The session is already cleared; the route guard owns navigation.
+ }
};
return { onLogout, t };
diff --git a/src/pages/stock/ui/StockPage/StockPage.view.tsx b/src/pages/stock/ui/StockPage/StockPage.view.tsx
index cb934b2..42ba5af 100644
--- a/src/pages/stock/ui/StockPage/StockPage.view.tsx
+++ b/src/pages/stock/ui/StockPage/StockPage.view.tsx
@@ -2,45 +2,23 @@ import type { FC } from "react";
import {
Alert,
- Button,
- Group,
- Skeleton,
- Stack,
Text,
} from "@mantine/core";
-import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import {
- instrumentDetailsStyles,
InstrumentHeader,
- InstrumentMetric,
- InstrumentQuoteStatus,
+ InstrumentPage,
InstrumentChart as StockChart,
InstrumentEvents as StockEvents,
InstrumentPosition as StockPosition,
- InstrumentSection as StockSection,
} from "@/features/instrument-details";
-import {
- formatDate,
- formatDecimal,
- formatMoney,
-} from "@/shared/lib";
import type { StockPageVm } from "./StockPage.vm";
+import { StockCompany } from "./components/StockCompany.view";
import { StockDividends } from "./components/StockDividends.view";
-import classes from "./StockPage.module.css";
-
-const compactMetricFormatter = new Intl.NumberFormat("ru-RU", {
- maximumFractionDigits: 2,
- notation: "compact",
-});
-
-const metricFormatter = new Intl.NumberFormat("ru-RU", {
- maximumFractionDigits: 2,
- notation: "standard",
-});
+import { StockQuote } from "./components/StockQuote.view";
type StockPageViewProps = { vm: StockPageVm };
@@ -59,96 +37,8 @@ const StockPageView: FC = (props) => {
>
);
- const appearanceClass = vm.quote?.change?.value.startsWith("-")
- ? classes.negative
- : classes.positive;
-
- let quoteContent;
-
- if (vm.quoteState.loading) {
- quoteContent = (
-
-
-
- );
- }
- else {
- quoteContent = (
-
-
- {formatMoney(vm.quote?.price)}
-
-
- {!!vm.quote?.change && (
-
- {vm.quote.change.value.startsWith("-") ? "" : "+"}
-
- {formatMoney(vm.quote.change)}
-
- {vm.quote.changePercent === null
- ? ""
- : ` (${formatDecimal(vm.quote.changePercent)} %)`}
-
- )}
-
- {vm.quote?.previousCloseDate
- ? (
-
- {t("stock.changeBase", {
- date: formatDate(vm.quote.previousCloseDate),
- })}
-
- )
- : (
-
- {t("stock.noComparison")}
-
- )}
-
-
-
-
-
- {!!vm.quote?.unavailable.length && (
-
- {t("stock.partialQuote")}
-
- )}
-
- {!!vm.quoteState.refreshing && (
-
- {t("accounts.refreshing")}
-
- )}
-
- );
- }
-
- let companyMetricsContent;
-
- if (vm.company) {
- if (vm.company.metrics.length > 0) {
- companyMetricsContent = (
-
- {vm.company.metrics.map((metric) => {
- const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · ");
- const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter;
- const value = `${formatter.format(metric.value)} ${metricUnit(metric, t("stock.currencyUnknown"))}`;
-
- return (
-
- );
- })}
-
- );
- }
- else {
- companyMetricsContent = {t("stock.noFundamentals")};
- }
- }
-
return (
-
+
= (props) => {
)}
- {quoteContent}
+
-
-
- {!!stock.sector && (
-
- {t("stock.sector")}:{" "}
- {t(`stock.sectors.${stock.sector}`, {
- defaultValue: stock.sector,
- })}
-
- )}
-
- {stock.lot !== null && {t("stock.lot", { value: stock.lot })}}
-
-
- {!!vm.company && (
-
- {vm.company.unavailable.length > 0 && (
-
-
- {t("stock.partialCompany")}
-
-
-
-
- )}
-
- {companyMetricsContent}
-
-
- {t("stock.periodNote")}
-
-
- )}
-
+
@@ -229,20 +80,8 @@ const StockPageView: FC = (props) => {
/>
-
+
);
};
-function metricUnit(
- metric: NonNullable["metrics"][number],
- unknownCurrency: string,
-) {
- if (metric.unit === "money")
- return metric.currency === "RUB"
- ? "₽"
- : (metric.currency ?? unknownCurrency);
-
- return metric.unit === "percent" ? "%" : "×";
-}
-
export { StockPageView };
diff --git a/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx b/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx
new file mode 100644
index 0000000..df67069
--- /dev/null
+++ b/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx
@@ -0,0 +1,120 @@
+import type { FC } from "react";
+
+import {
+ Alert,
+ Button,
+ Group,
+ Stack,
+ Text,
+} from "@mantine/core";
+import { useTranslation } from "react-i18next";
+
+import {
+ InstrumentMetric,
+ InstrumentMetrics,
+ InstrumentSection as StockSection,
+} from "@/features/instrument-details";
+
+import type { StockPageVm } from "../StockPage.vm";
+
+const compactMetricFormatter = new Intl.NumberFormat("ru-RU", {
+ maximumFractionDigits: 2,
+ notation: "compact",
+});
+
+const metricFormatter = new Intl.NumberFormat("ru-RU", {
+ maximumFractionDigits: 2,
+ notation: "standard",
+});
+
+type StockCompanyProps = Pick & {
+ stock: NonNullable;
+};
+
+const StockCompany: FC = (props) => {
+ const { stock } = props;
+ const { t } = useTranslation();
+ let companyMetricsContent;
+
+ if (props.company) {
+ if (props.company.metrics.length > 0) {
+ companyMetricsContent = (
+
+ {props.company.metrics.map((metric) => {
+ const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · ");
+ const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter;
+ const value = `${formatter.format(metric.value)} ${metricUnit(metric, t("stock.currencyUnknown"))}`;
+
+ return (
+
+ );
+ })}
+
+ );
+ }
+ else {
+ companyMetricsContent = {t("stock.noFundamentals")};
+ }
+ }
+
+ return (
+
+
+ {!!stock.sector && (
+
+ {t("stock.sector")}:{" "}
+ {t(`stock.sectors.${stock.sector}`, {
+ defaultValue: stock.sector,
+ })}
+
+ )}
+
+ {stock.lot !== null && {t("stock.lot", { value: stock.lot })}}
+
+
+ {!!props.company && (
+
+ {props.company.unavailable.length > 0 && (
+
+
+ {t("stock.partialCompany")}
+
+
+
+
+ )}
+
+ {companyMetricsContent}
+
+
+ {t("stock.periodNote")}
+
+
+ )}
+
+ );
+};
+
+function metricUnit(
+ metric: NonNullable["metrics"][number],
+ unknownCurrency: string,
+) {
+ if (metric.unit === "money")
+ return metric.currency === "RUB"
+ ? "₽"
+ : (metric.currency ?? unknownCurrency);
+
+ return metric.unit === "percent" ? "%" : "×";
+}
+
+export { StockCompany };
diff --git a/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx b/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx
new file mode 100644
index 0000000..5c9a7b7
--- /dev/null
+++ b/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx
@@ -0,0 +1,98 @@
+import type { FC } from "react";
+
+import {
+ Group,
+ Skeleton,
+ Text,
+} from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
+import { useTranslation } from "react-i18next";
+
+import {
+ InstrumentPrice,
+ InstrumentQuoteStatus,
+} from "@/features/instrument-details";
+import {
+ formatDate,
+ formatDecimal,
+ formatMoney,
+} from "@/shared/lib";
+
+import type { StockPageVm } from "../StockPage.vm";
+
+import classes from "../StockPage.module.css";
+
+type StockQuoteProps = Pick;
+
+const StockQuote: FC = (props) => {
+ const { t } = useTranslation();
+
+ const appearanceClass = props.quote?.change?.value.startsWith("-")
+ ? classes.negative
+ : classes.positive;
+
+ let quoteContent;
+
+ if (props.quoteState.loading) {
+ quoteContent = (
+
+
+
+ );
+ }
+ else {
+ quoteContent = (
+
+
+ {formatMoney(props.quote?.price)}
+
+
+ {!!props.quote?.change && (
+
+ {props.quote.change.value.startsWith("-") ? "" : "+"}
+
+ {formatMoney(props.quote.change)}
+
+ {props.quote.changePercent === null
+ ? ""
+ : ` (${formatDecimal(props.quote.changePercent)} %)`}
+
+ )}
+
+ {props.quote?.previousCloseDate
+ ? (
+
+ {t("stock.changeBase", {
+ date: formatDate(props.quote.previousCloseDate),
+ })}
+
+ )
+ : (
+
+ {t("stock.noComparison")}
+
+ )}
+
+
+
+
+
+ {!!props.quote?.unavailable.length && (
+
+ {t("stock.partialQuote")}
+
+ )}
+
+ {!!props.quoteState.refreshing && (
+
+ {t("accounts.refreshing")}
+
+ )}
+
+ );
+ }
+
+ return quoteContent;
+};
+
+export { StockQuote };
diff --git a/src/entities/portfolio/api/resource-lifecycle.test.tsx b/src/shared/api/resource-query.test.tsx
similarity index 87%
rename from src/entities/portfolio/api/resource-lifecycle.test.tsx
rename to src/shared/api/resource-query.test.tsx
index 6dcea08..9ccbc2a 100644
--- a/src/entities/portfolio/api/resource-lifecycle.test.tsx
+++ b/src/shared/api/resource-query.test.tsx
@@ -7,11 +7,17 @@ import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
-import {
- type Portfolio,
- portfolioQueryOptions,
- usePortfolio,
-} from "./usePortfolio";
+import { apiQueryClient, type paths, useResourceQuery } from "./index";
+
+type Portfolio = NonNullable;
+
+function resourceOptions(accountId: string) {
+ return apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId } } });
+}
+
+function usePrivateResource(accountId: string) {
+ return useResourceQuery({ ...resourceOptions(accountId), meta: { accessGroup: "brokerage" } });
+}
const snapshot: Portfolio = {
cashBalances: [],
@@ -52,7 +58,7 @@ describe("persistent resource query lifecycle", () => {
),
);
const { queryClient, wrapper } = createQueryWrapper();
- const { result } = renderHook(() => usePortfolio("a"), { wrapper });
+ const { result } = renderHook(() => usePrivateResource("a"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
await act(() => result.current.observe("new"));
await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
@@ -60,7 +66,7 @@ describe("persistent resource query lifecycle", () => {
expect(modes).toEqual([null, "observe", "observe"]);
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
expect(
- queryClient.getQueryData(portfolioQueryOptions("a").queryKey),
+ queryClient.getQueryData(resourceOptions("a").queryKey),
).toMatchObject({ data: snapshot, meta: { stateVersion: "new" } });
});
it("does not convert missing or failed source data to empty financial success", async () => {
@@ -79,7 +85,7 @@ describe("persistent resource query lifecycle", () => {
),
);
- const { result } = renderHook(() => usePortfolio("a"), {
+ const { result } = renderHook(() => usePrivateResource("a"), {
wrapper: createQueryWrapper().wrapper,
});
@@ -109,7 +115,7 @@ describe("persistent resource query lifecycle", () => {
),
);
- const { result } = renderHook(() => usePortfolio("a"), {
+ const { result } = renderHook(() => usePrivateResource("a"), {
wrapper: createQueryWrapper().wrapper,
});
@@ -186,7 +192,7 @@ describe("persistent resource query lifecycle", () => {
const { wrapper } = createQueryWrapper();
const { result } = renderHook(
- () => ({ a: usePortfolio("a"), b: usePortfolio("b") }),
+ () => ({ a: usePrivateResource("a"), b: usePrivateResource("b") }),
{ wrapper },
);
diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts
index 653b6f6..6a84724 100644
--- a/src/shared/api/resource-query.ts
+++ b/src/shared/api/resource-query.ts
@@ -1,14 +1,13 @@
import {
type QueryClient,
type QueryFunctionContext,
- type QueryKey,
useQueryClient,
type UseQueryOptions,
type UseQueryResult,
} from "@tanstack/react-query";
import { useEffect } from "react";
-import type { components } from "./generated/schema";
+import type { components, paths } from "./generated/schema";
import { useAuthenticatedQuery } from "./authenticated-query";
import { getAuthenticatedSessionSnapshot } from "./client";
@@ -23,6 +22,19 @@ type Resource = {
meta: InvestmentResourceMeta;
};
+type ResourcePath = {
+ [P in keyof paths]: paths[P]["get"] extends {
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- HTTP success response in the generated contract.
+ responses: { 200: { content: { "application/json": Resource } } };
+ } ? P : never;
+}[keyof paths];
+
+type ResourceQueryKey = readonly [
+ "get",
+ ResourcePath,
+ { params?: { path?: object; query?: { sync?: "observe" } } }?,
+];
+
function invalidateResourceAccess(
client: QueryClient,
group: string,
@@ -58,6 +70,17 @@ function invalidateResourceAccess(
});
}
+// Keep endpoint and parameters intact. The generated queryFn accepts the
+// optional sync mode even for a cache key originally created without init.
+function observationKey(key: K): K {
+ const [method, path, init] = key;
+
+ return [method, path, {
+ ...init,
+ params: { ...init?.params, query: { ...init?.params?.query, sync: "observe" } },
+ }] as unknown as K;
+}
+
function unwrapResourceResult(
result: UseQueryResult,
) {
@@ -86,7 +109,7 @@ function unwrapResourceResult(
}
// Observation changes transport mode, never the generated cache identity.
-function useResourceQuery(
+function useResourceQuery(
options: UseQueryOptions,
) {
const client = useQueryClient();
@@ -137,23 +160,7 @@ function useResourceQuery(
return client.query({
...options,
queryFn: async (context: QueryFunctionContext) => {
- const [method, path, input] = context.queryKey;
-
- const init = input as
- undefined
- | { params?: { query?: Record } };
-
- const queryKey = [
- method,
- path,
- {
- ...init,
- params: {
- ...init?.params,
- query: { ...init?.params?.query, sync: "observe" },
- },
- },
- ] as unknown as K;
+ const queryKey = observationKey(context.queryKey);
const response = await queryFunction({ ...context, queryKey });
diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts
index a3e2668..9931999 100644
--- a/src/shared/lib/index.ts
+++ b/src/shared/lib/index.ts
@@ -8,3 +8,4 @@ export {
formatSnapshotTime,
instrumentLogoUrl,
} from "./display";
+export { useUrlPeriod } from "./useUrlPeriod";
diff --git a/src/shared/lib/useUrlPeriod.ts b/src/shared/lib/useUrlPeriod.ts
new file mode 100644
index 0000000..0129edd
--- /dev/null
+++ b/src/shared/lib/useUrlPeriod.ts
@@ -0,0 +1,27 @@
+import { useEffect } from "react";
+import { useSearchParams } from "react-router";
+
+import { isValidPeriod } from "./calendar";
+
+function useUrlPeriod() {
+ const [parameters, setParameters] = useSearchParams();
+ const from = parameters.get("from");
+ const to = parameters.get("to");
+ const isValid = isValidPeriod(from, to);
+ useEffect(() => {
+ if (isValid || (!parameters.has("from") && !parameters.has("to"))) return;
+ const normalized = new URLSearchParams(parameters);
+ normalized.delete("from");
+ normalized.delete("to");
+ setParameters(normalized, { replace: true });
+ }, [isValid, parameters, setParameters]);
+
+ return {
+ from: isValid ? from : null,
+ parameters,
+ setParameters,
+ to: isValid ? to : null,
+ };
+}
+
+export { useUrlPeriod };
diff --git a/src/test/storybook.test.tsx b/src/test/storybook.test.tsx
index 9a39756..e5ba09e 100644
--- a/src/test/storybook.test.tsx
+++ b/src/test/storybook.test.tsx
@@ -8,9 +8,9 @@ import * as UiLoadingStories from "../../packages/ui/src/LoadingSkeleton/Loading
import * as StateStories from "../../packages/ui/src/StateView/StateView.stories";
import * as AccountStories from "../entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories";
import * as EventStories from "../entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories";
+import * as RefreshStories from "../features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories";
import * as IncomePeriodStories from "../pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories";
import * as LoadingStories from "../pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.stories";
-import * as RefreshStories from "../pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories";
import * as PositionStories from "../pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories";
import * as OperationStories from "../pages/card/ui/OperationForm/OperationForm.stories";
diff --git a/vitest.config.ts b/vitest.config.ts
index 5bb7f95..f50277b 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -59,7 +59,7 @@ export default defineConfig({
],
provider: playwright(),
},
- include: ["src/**/*.browser.test.tsx"],
+ include: ["src/**/*.browser.test.tsx", "packages/*/src/**/*.browser.test.tsx"],
name: "browser",
},
},