diff --git a/docs/UI-PATTERN.md b/docs/UI-PATTERN.md
index de0eacc..2b5013c 100644
--- a/docs/UI-PATTERN.md
+++ b/docs/UI-PATTERN.md
@@ -4,6 +4,6 @@
Во время submit все действия формы блокируются. Ошибки Zod отображаются у поля; ошибки сети сохраняют введённые значения и показываются отдельным `Alert`.
-Запросные экраны выбирают состояние в порядке loading, error, empty, loaded. Состояния имеют отдельные компоненты, если повторяются или содержат самостоятельную разметку.
+Запросные экраны без успешных данных выбирают состояние в порядке loading, error, empty, loaded. Если query уже содержит данные, они остаются видимыми при фоновом refetch и его ошибке: рядом показываются неблокирующий progress и локальное предупреждение с retry. Такое состояние называется Client Cached Snapshot; оно не заменяет доменное server-side stale/fallback состояние.
Для finance-экранов container вызывает query hooks, VM выбирает состояние и передает только presentation props в Loading/Error/Empty/Loaded view. Known API errors, retry и stale snapshot остаются local views, а непойманные route errors обрабатывает общий route-keyed ErrorBoundary.
diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx
index 19158a4..bf7458d 100644
--- a/src/features/auth/model/auth.test.tsx
+++ b/src/features/auth/model/auth.test.tsx
@@ -34,4 +34,58 @@ describe("AuthProvider", () => {
expect(authorization).toBe("Bearer restored-token");
});
+
+ it("settles unauthenticated and clears cached data when cold-start refresh has a transport error", async () => {
+ server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error()));
+ const queryClient = new QueryClient();
+ queryClient.setQueryData(["cached-query"], { value: "stale" });
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+ const { result } = renderHook(() => useAuth(), { wrapper });
+
+ await waitFor(() => expect(result.current.ready).toBe(true));
+
+ expect(result.current.user).toBeNull();
+ expect(result.current.accessToken).toBeNull();
+ expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
+ });
+
+ it("keeps the original protected 401 when serialized refresh has a transport error", async () => {
+ let refreshCalls = 0;
+ server.use(
+ http.post("*/api/v1/auth/refresh", () => {
+ refreshCalls += 1;
+ return refreshCalls === 1
+ ? HttpResponse.json({
+ accessToken: "restored-token",
+ user: { id: "user-1", email: "user@example.com" },
+ })
+ : HttpResponse.error();
+ }),
+ http.get("*/api/v1/brokerage-accounts", () =>
+ HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
+ ),
+ );
+ const queryClient = new QueryClient();
+ queryClient.setQueryData(["cached-query"], { value: "stale" });
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+ const { result } = renderHook(() => useAuth(), { wrapper });
+
+ await waitFor(() =>
+ expect(result.current.accessToken).toBe("restored-token"),
+ );
+ const response = await apiClient.GET("/api/v1/brokerage-accounts");
+
+ expect(response.response.status).toBe(401);
+ await waitFor(() => expect(result.current.user).toBeNull());
+ expect(result.current.accessToken).toBeNull();
+ expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
+ });
});
diff --git a/src/features/auth/model/auth.tsx b/src/features/auth/model/auth.tsx
index 88c9829..1b6b17c 100644
--- a/src/features/auth/model/auth.tsx
+++ b/src/features/auth/model/auth.tsx
@@ -1,5 +1,12 @@
import { useQueryClient } from "@tanstack/react-query";
-import { createContext, useContext, useEffect, useRef, useState } from "react";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
import { apiClient, configureAuthClient } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema";
@@ -41,40 +48,52 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const refreshPromise = useRef | null>(null);
+ const applyAuthentication = useCallback((authentication: Authentication) => {
+ setUser(authentication.user);
+ setAccessToken(authentication.accessToken);
+ accessTokenRef.current = authentication.accessToken;
+ }, []);
+ const clearSession = useCallback(() => {
+ setUser(null);
+ setAccessToken(null);
+ accessTokenRef.current = null;
+ queryClient.clear();
+ }, [queryClient]);
- async function refresh(): Promise {
+ const refresh = useCallback(async (): Promise => {
if (refreshPromise.current) return refreshPromise.current;
refreshPromise.current = (async () => {
const run = async () => {
- const { data } = await apiClient.POST("/api/v1/auth/refresh");
- if (!data) return false;
- const authentication = data as Authentication;
- setUser(authentication.user);
- setAccessToken(authentication.accessToken);
- accessTokenRef.current = authentication.accessToken;
- return true;
+ try {
+ const { data } = await apiClient.POST("/api/v1/auth/refresh");
+ if (!data) {
+ clearSession();
+ return false;
+ }
+ applyAuthentication(data as Authentication);
+ return true;
+ } catch {
+ clearSession();
+ return false;
+ }
};
- return "locks" in navigator
- ? navigator.locks.request("wealth-ledger-refresh", run)
- : run();
+ try {
+ return "locks" in navigator
+ ? navigator.locks.request("wealth-ledger-refresh", run)
+ : run();
+ } catch {
+ clearSession();
+ return false;
+ }
})().finally(() => {
refreshPromise.current = null;
});
return refreshPromise.current;
- }
+ }, [applyAuthentication, clearSession]);
useEffect(() => {
- void apiClient
- .POST("/api/v1/auth/refresh")
- .then(({ data }) => {
- if (!data) return;
- const authentication = data as Authentication;
- setUser(authentication.user);
- setAccessToken(authentication.accessToken);
- accessTokenRef.current = authentication.accessToken;
- })
- .finally(() => setReady(true));
- }, []);
+ void refresh().finally(() => setReady(true));
+ }, [refresh]);
async function authenticate(
path: "/api/v1/auth/login" | "/api/v1/auth/register",
@@ -82,19 +101,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
password: string,
) {
const authentication = await request(path, email, password);
- setUser(authentication.user);
- setAccessToken(authentication.accessToken);
- accessTokenRef.current = authentication.accessToken;
+ applyAuthentication(authentication);
}
async function logout() {
try {
await apiClient.POST("/api/v1/auth/logout");
} finally {
- setUser(null);
- setAccessToken(null);
- accessTokenRef.current = null;
- queryClient.clear();
+ clearSession();
}
}
@@ -102,12 +116,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
configureAuthClient({
accessToken: () => accessTokenRef.current,
refresh,
- clear: () => {
- setUser(null);
- setAccessToken(null);
- accessTokenRef.current = null;
- queryClient.clear();
- },
+ clear: clearSession,
});
return () => configureAuthClient(null);
});
diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx
index 8c445d4..751c125 100644
--- a/src/pages/account/ui/AccountPage.test.tsx
+++ b/src/pages/account/ui/AccountPage.test.tsx
@@ -2,17 +2,22 @@ import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
-import { MemoryRouter } from "react-router";
+import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { server } from "@/test/server";
import {
+ AccountPage,
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
} from "./AccountPage";
+vi.mock("@/features/auth", () => ({
+ useAuth: () => ({ accessToken: "test-token" }),
+}));
+
function renderPage(ui: React.ReactNode) {
return render(
@@ -23,6 +28,20 @@ function renderPage(ui: React.ReactNode) {
);
}
+function renderAccountRoute(queryClient: QueryClient) {
+ return render(
+
+
+
+
+ } />
+
+
+
+ ,
+ );
+}
+
const portfolio: Portfolio = {
lastSuccessfulAt: "2026-08-13T10:00:00Z",
isStale: true,
@@ -169,4 +188,68 @@ describe("AccountPage states", () => {
);
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
+
+ it("keeps the cached portfolio visible with distinct server and refresh warnings", async () => {
+ let requestCount = 0;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
+ requestCount += 1;
+ if (requestCount === 1) return HttpResponse.json(portfolio);
+ return HttpResponse.json(
+ { code: "TBANK_UNAVAILABLE" },
+ { status: 503 },
+ );
+ }),
+ );
+ const queryClient = new QueryClient();
+ renderAccountRoute(queryClient);
+
+ await screen.findByText("Вторая");
+ void queryClient.invalidateQueries({
+ queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"],
+ });
+
+ expect(
+ await screen.findByText(
+ "Не удалось обновить портфель. Показан последний полученный снимок.",
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "T-Bank временно недоступен. Показан последний успешный снимок.",
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Вторая")).toBeInTheDocument();
+ screen.getByRole("button", { name: "Повторить" }).click();
+ await waitFor(() => expect(requestCount).toBe(3));
+ });
+
+ it("keeps the cached portfolio visible during a background refresh", async () => {
+ let requestCount = 0;
+ let resolveRefresh: ((response: Response) => void) | undefined;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
+ requestCount += 1;
+ if (requestCount === 1) return HttpResponse.json(portfolio);
+ return new Promise((resolve) => {
+ resolveRefresh = resolve;
+ });
+ }),
+ );
+ const queryClient = new QueryClient();
+ renderAccountRoute(queryClient);
+
+ await screen.findByText("Вторая");
+ void queryClient.invalidateQueries({
+ queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"],
+ });
+
+ await waitFor(() => expect(requestCount).toBe(2));
+ expect(screen.getByText("Вторая")).toBeInTheDocument();
+ expect(
+ screen.getByRole("status", { name: "Обновляем портфель" }),
+ ).toBeInTheDocument();
+
+ resolveRefresh?.(HttpResponse.json(portfolio));
+ });
});
diff --git a/src/pages/account/ui/AccountPage.tsx b/src/pages/account/ui/AccountPage.tsx
index 4f36ea6..cb2abbc 100644
--- a/src/pages/account/ui/AccountPage.tsx
+++ b/src/pages/account/ui/AccountPage.tsx
@@ -16,6 +16,9 @@ export function AccountPage() {
account={viewModel.account}
accountId={viewModel.accountId}
accessToken={viewModel.accessToken}
+ refreshing={viewModel.refreshing}
+ refreshError={viewModel.refreshError}
+ retry={viewModel.retry}
/>
);
return (
@@ -24,6 +27,9 @@ export function AccountPage() {
accountId={viewModel.accountId}
accessToken={viewModel.accessToken}
portfolio={viewModel.portfolio}
+ refreshing={viewModel.refreshing}
+ refreshError={viewModel.refreshError}
+ retry={viewModel.retry}
/>
);
}
diff --git a/src/pages/account/ui/AccountPage.view.tsx b/src/pages/account/ui/AccountPage.view.tsx
index 1fd0366..758280c 100644
--- a/src/pages/account/ui/AccountPage.view.tsx
+++ b/src/pages/account/ui/AccountPage.view.tsx
@@ -3,6 +3,7 @@ import {
Button,
Card,
Group,
+ Loader,
Paper,
SimpleGrid,
Stack,
@@ -93,15 +94,26 @@ export function AccountPageEmpty({
account,
accountId,
accessToken = null,
+ refreshing = false,
+ refreshError = false,
+ retry,
}: {
account?: BrokerageAccount;
accountId?: string;
accessToken?: string | null;
+ refreshing?: boolean;
+ refreshError?: boolean;
+ retry?: () => void;
}) {
const { t } = useTranslation();
return (
+
void;
}) {
const { t } = useTranslation();
const stocks = portfolio.positions.filter((p) => p.type === "STOCK");
@@ -140,6 +158,11 @@ export function AccountPageLoaded({
return (
+
{portfolio.isStale ? (
{t("portfolio.staleWarning")}
) : null}
@@ -197,6 +220,40 @@ export function AccountPageLoaded({
);
}
+function PortfolioRefreshState({
+ refreshing,
+ refreshError,
+ retry,
+}: {
+ refreshing: boolean;
+ refreshError: boolean;
+ retry?: () => void;
+}) {
+ const { t } = useTranslation();
+ if (!refreshing && !refreshError) return null;
+ return (
+ <>
+ {refreshing ? (
+
+
+
+ {t("portfolio.refreshing")}
+
+
+ ) : null}
+ {refreshError ? (
+
+
+ {t("portfolio.refreshError")}
+
+
+
+ ) : null}
+ >
+ );
+}
function RecentOperations({
accountId,
accessToken,
diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts
index 7930afe..88703fd 100644
--- a/src/pages/account/ui/AccountPage.vm.ts
+++ b/src/pages/account/ui/AccountPage.vm.ts
@@ -14,21 +14,34 @@ export function useAccountPageViewModel() {
const account = useQueryClient()
.getQueryData(brokerageAccountsQueryKey)
?.find((item) => item.id === accountId);
- if (portfolio.isError)
- return {
- state: "error" as const,
- code: portfolioErrorCode(portfolio.error),
- retry: () => portfolio.refetch(),
- };
- if (portfolio.isFetching || !portfolio.data)
+ if (!portfolio.data) {
+ if (portfolio.isError)
+ return {
+ state: "error" as const,
+ code: portfolioErrorCode(portfolio.error),
+ retry: () => portfolio.refetch(),
+ };
return { state: "loading" as const };
+ }
+ const refresh = {
+ refreshing: portfolio.isFetching,
+ refreshError: portfolio.isError,
+ retry: () => portfolio.refetch(),
+ };
if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
- return { state: "empty" as const, account, accountId, accessToken };
+ return {
+ state: "empty" as const,
+ account,
+ accountId,
+ accessToken,
+ ...refresh,
+ };
return {
state: "loaded" as const,
account,
accountId,
accessToken,
portfolio: portfolio.data,
+ ...refresh,
};
}
diff --git a/src/pages/accounts/ui/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage.test.tsx
new file mode 100644
index 0000000..40644f3
--- /dev/null
+++ b/src/pages/accounts/ui/AccountsPage.test.tsx
@@ -0,0 +1,125 @@
+import { MantineProvider } from "@mantine/core";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import { HttpResponse, http } from "msw";
+import { MemoryRouter } from "react-router";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
+import { server } from "@/test/server";
+import { AccountsPage } from "./AccountsPage";
+
+vi.mock("@/features/auth", () => ({
+ useAuth: () => ({ accessToken: "test-token" }),
+}));
+
+const account = {
+ id: "account-1",
+ name: "Основной",
+ type: "BROKERAGE",
+ openedOn: "2026-01-01",
+} as const;
+
+function renderPage(queryClient: QueryClient) {
+ return render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+describe("AccountsPage", () => {
+ afterEach(cleanup);
+
+ it("keeps the cached account list visible during a background refresh", async () => {
+ let requestCount = 0;
+ let resolveRefresh: ((response: Response) => void) | undefined;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", () => {
+ requestCount += 1;
+ if (requestCount === 1) return HttpResponse.json([account]);
+ return new Promise((resolve) => {
+ resolveRefresh = resolve;
+ });
+ }),
+ );
+ const queryClient = new QueryClient();
+ renderPage(queryClient);
+
+ await screen.findByText(account.name);
+ void queryClient.invalidateQueries({
+ queryKey: brokerageAccountsQueryKey,
+ });
+
+ await waitFor(() => expect(requestCount).toBe(2));
+ expect(screen.getByText(account.name)).toBeInTheDocument();
+ expect(
+ screen.getByRole("status", { name: "Обновляем данные" }),
+ ).toBeInTheDocument();
+
+ resolveRefresh?.(HttpResponse.json([account]));
+ });
+
+ it("keeps the cached account list and offers retry after a refresh error", async () => {
+ let requestCount = 0;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", () => {
+ requestCount += 1;
+ if (requestCount === 1) return HttpResponse.json([account]);
+ if (requestCount === 2)
+ return HttpResponse.json(
+ { code: "TBANK_UNAVAILABLE" },
+ { status: 503 },
+ );
+ return HttpResponse.json([account]);
+ }),
+ );
+ const queryClient = new QueryClient();
+ renderPage(queryClient);
+
+ await screen.findByText(account.name);
+ void queryClient.invalidateQueries({
+ queryKey: brokerageAccountsQueryKey,
+ });
+
+ const warning = await screen.findByText(
+ "Не удалось обновить данные. Показаны последние полученные.",
+ );
+ expect(warning).toBeInTheDocument();
+ expect(screen.getByText(account.name)).toBeInTheDocument();
+
+ screen.getByRole("button", { name: "Повторить" }).click();
+ await waitFor(() => expect(requestCount).toBe(3));
+ });
+
+ it("keeps a cached empty account list visible after a refresh error", async () => {
+ let requestCount = 0;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", () => {
+ requestCount += 1;
+ if (requestCount === 1) return HttpResponse.json([]);
+ return HttpResponse.json(
+ { code: "TBANK_UNAVAILABLE" },
+ { status: 503 },
+ );
+ }),
+ );
+ const queryClient = new QueryClient();
+ renderPage(queryClient);
+
+ await screen.findByText("Нет инвестиционных счетов");
+ void queryClient.invalidateQueries({
+ queryKey: brokerageAccountsQueryKey,
+ });
+
+ expect(
+ await screen.findByText(
+ "Не удалось обновить данные. Показаны последние полученные.",
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/accounts/ui/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage.tsx
index 2ca3de0..965a608 100644
--- a/src/pages/accounts/ui/AccountsPage.tsx
+++ b/src/pages/accounts/ui/AccountsPage.tsx
@@ -10,8 +10,22 @@ export function AccountsPage() {
const viewModel = useAccountsPageViewModel();
if (viewModel.state === "loading") return ;
if (viewModel.state === "error") return ;
- if (viewModel.state === "empty") return ;
- return ;
+ if (viewModel.state === "empty")
+ return (
+
+ );
+ return (
+
+ );
}
export {
diff --git a/src/pages/accounts/ui/AccountsPage.view.tsx b/src/pages/accounts/ui/AccountsPage.view.tsx
index fdefe56..2927320 100644
--- a/src/pages/accounts/ui/AccountsPage.view.tsx
+++ b/src/pages/accounts/ui/AccountsPage.view.tsx
@@ -1,4 +1,12 @@
-import { Button, Stack, Title } from "@mantine/core";
+import {
+ Alert,
+ Button,
+ Group,
+ Loader,
+ Stack,
+ Text,
+ Title,
+} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageAccount } from "@/entities/brokerage-account";
@@ -71,26 +79,87 @@ export function AccountsPageError({
);
}
-export function AccountsPageEmpty() {
+export function AccountsPageEmpty({
+ refreshing = false,
+ refreshError = false,
+ retry,
+}: {
+ refreshing?: boolean;
+ refreshError?: boolean;
+ retry?: () => void;
+}) {
const { t } = useTranslation();
return (
-
+
+
+
+
);
}
export function AccountsPageLoaded({
accounts,
+ refreshing = false,
+ refreshError = false,
+ retry,
}: {
accounts: BrokerageAccount[];
+ refreshing?: boolean;
+ refreshError?: boolean;
+ retry?: () => void;
}) {
const { t } = useTranslation();
return (
{t("accounts.title")}
+
);
}
+
+function AccountsRefreshState({
+ refreshing,
+ refreshError,
+ retry,
+}: {
+ refreshing: boolean;
+ refreshError: boolean;
+ retry?: () => void;
+}) {
+ const { t } = useTranslation();
+ if (!refreshing && !refreshError) return null;
+ return (
+ <>
+ {refreshing ? (
+
+
+
+ {t("accounts.refreshing")}
+
+
+ ) : null}
+ {refreshError ? (
+
+
+ {t("accounts.refreshError")}
+
+
+
+ ) : null}
+ >
+ );
+}
diff --git a/src/pages/accounts/ui/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage.vm.ts
index 86c5357..d1ac50c 100644
--- a/src/pages/accounts/ui/AccountsPage.vm.ts
+++ b/src/pages/accounts/ui/AccountsPage.vm.ts
@@ -4,13 +4,27 @@ import { useAuth } from "@/features/auth";
export function useAccountsPageViewModel() {
const { accessToken } = useAuth();
const accounts = useBrokerageAccounts(accessToken);
- if (accounts.isFetching) return { state: "loading" as const };
- if (accounts.isError)
+ if (!accounts.data) {
+ if (accounts.isFetching) return { state: "loading" as const };
+ if (accounts.isError)
+ return {
+ state: "error" as const,
+ code: errorCode(accounts.error),
+ retry: () => accounts.refetch(),
+ };
+ }
+ if (!accounts.data?.length)
return {
- state: "error" as const,
- code: errorCode(accounts.error),
+ state: "empty" as const,
+ refreshing: accounts.isFetching,
+ refreshError: accounts.isError,
retry: () => accounts.refetch(),
};
- if (!accounts.data?.length) return { state: "empty" as const };
- return { state: "loaded" as const, accounts: accounts.data };
+ return {
+ state: "loaded" as const,
+ accounts: accounts.data,
+ refreshing: accounts.isFetching,
+ refreshError: accounts.isError,
+ retry: () => accounts.refetch(),
+ };
}
diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts
index 3a03113..358ffeb 100644
--- a/src/shared/i18n/locales/ru.ts
+++ b/src/shared/i18n/locales/ru.ts
@@ -41,6 +41,9 @@ export const ru = {
unavailableTitle: "T-Bank временно недоступен",
unavailableText:
"Попробуйте загрузить счета еще раз или проверьте подключение.",
+ refreshing: "Обновляем данные",
+ refreshError:
+ "Не удалось обновить данные. Показаны последние полученные.",
retry: "Повторить",
types: {
BROKERAGE: "Брокерский счет",
@@ -61,6 +64,9 @@ export const ru = {
back: "К счетам",
staleWarning:
"T-Bank временно недоступен. Показан последний успешный снимок.",
+ refreshing: "Обновляем портфель",
+ refreshError:
+ "Не удалось обновить портфель. Показан последний полученный снимок.",
updatedAt: "Данные получены {{date}}",
positions: "Позиции",
stocks: "Акции",