feat: show persisted investment data and sync state
Consume the provided resource-envelope API snapshot. Preserve financial snapshots during refresh, observe synchronization without importing, and fence late responses across account and connection changes.
This commit is contained in:
@@ -14,6 +14,7 @@ import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import { AccountPage } from "./AccountPage";
|
||||
import type {
|
||||
@@ -144,27 +145,31 @@ describe("AccountPage states", () => {
|
||||
};
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json({
|
||||
...portfolio,
|
||||
positions:
|
||||
accountId === "all"
|
||||
? ["account-1", "account-2"].map((id) => ({
|
||||
...position,
|
||||
account: { id, name: id },
|
||||
}))
|
||||
: [position],
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
...portfolio,
|
||||
positions:
|
||||
accountId === "all"
|
||||
? ["account-1", "account-2"].map((id) => ({
|
||||
...position,
|
||||
account: { id, name: id },
|
||||
}))
|
||||
: [position],
|
||||
}),
|
||||
),
|
||||
),
|
||||
http.get(`*/api/v1/${collection}/:uid`, () => {
|
||||
requests();
|
||||
return HttpResponse.json({
|
||||
instrumentUid: uid,
|
||||
slug,
|
||||
name: "Название из карточки инструмента",
|
||||
ticker: "OTHER-TICKER",
|
||||
isin: "OTHER-ISIN",
|
||||
logoName: "other.png",
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
instrumentUid: uid,
|
||||
slug,
|
||||
name: "Название из карточки инструмента",
|
||||
ticker: "OTHER-TICKER",
|
||||
isin: "OTHER-ISIN",
|
||||
logoName: "other.png",
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
configureTestAuth();
|
||||
@@ -204,22 +209,24 @@ describe("AccountPage states", () => {
|
||||
const requests = vi.fn();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json({
|
||||
...portfolio,
|
||||
positions: [
|
||||
{
|
||||
...portfolio.positions[0],
|
||||
instrumentUid: uid,
|
||||
ticker: "SBERP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
...portfolio,
|
||||
positions: [
|
||||
{
|
||||
...portfolio.positions[0],
|
||||
instrumentUid: uid,
|
||||
ticker: "SBERP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/stocks/:uid", () => {
|
||||
requests();
|
||||
return scenario === "unavailable"
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json({ instrumentUid: uid, slug: uid });
|
||||
: HttpResponse.json(resource({ instrumentUid: uid, slug: uid }));
|
||||
}),
|
||||
);
|
||||
renderAccountRoute(createTestQueryClient());
|
||||
@@ -234,7 +241,7 @@ describe("AccountPage states", () => {
|
||||
it("keeps the account and positions visible for an older HTTP response without structure", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json({ ...portfolio, structure: undefined }),
|
||||
HttpResponse.json(resource({ ...portfolio, structure: undefined })),
|
||||
),
|
||||
);
|
||||
renderAccountRoute(createTestQueryClient());
|
||||
@@ -322,11 +329,13 @@ describe("AccountPage states", () => {
|
||||
async (hasPositions) => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json({
|
||||
...portfolio,
|
||||
totalsByCurrency: [],
|
||||
positions: hasPositions ? portfolio.positions : [],
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
...portfolio,
|
||||
totalsByCurrency: [],
|
||||
positions: hasPositions ? portfolio.positions : [],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderAccountRoute(createTestQueryClient());
|
||||
@@ -358,7 +367,7 @@ describe("AccountPage states", () => {
|
||||
};
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(snapshot),
|
||||
HttpResponse.json(resource(snapshot)),
|
||||
),
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
@@ -380,7 +389,7 @@ describe("AccountPage states", () => {
|
||||
).toEqual(totalsByCurrency.length ? ["100 ₽"] : []);
|
||||
expect(
|
||||
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
|
||||
).toEqual(snapshot);
|
||||
).toEqual(resource(snapshot));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -887,7 +896,7 @@ describe("AccountPage states", () => {
|
||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([503, 404])(
|
||||
it.each([503])(
|
||||
"preserves totals and UTC snapshot through refresh error %i, retry and success",
|
||||
async (status) => {
|
||||
const initialPortfolio: Portfolio = {
|
||||
@@ -907,9 +916,36 @@ describe("AccountPage states", () => {
|
||||
let requestCount = 0;
|
||||
let resolveRetry: ((response: Response) => void) | undefined;
|
||||
server.use(
|
||||
http.post("*/api/v1/tbank-sync", () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
id: "overview",
|
||||
scope: { screen: "account", accountId: "account-1" },
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
},
|
||||
{ status: 202 },
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||
HttpResponse.json({
|
||||
id: "overview",
|
||||
scope: { screen: "account", accountId: "account-1" },
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
state: "succeeded",
|
||||
errorCode: null,
|
||||
progress: { total: 1, completed: 1, discovering: false },
|
||||
resources: [
|
||||
resource(null, { resourceKey: "portfolio", stateVersion: "2" })
|
||||
.meta,
|
||||
],
|
||||
}),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) return HttpResponse.json(initialPortfolio);
|
||||
if (requestCount === 1)
|
||||
return HttpResponse.json(
|
||||
resource(initialPortfolio, { resourceKey: "portfolio" }),
|
||||
);
|
||||
if (requestCount === 3)
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveRetry = resolve;
|
||||
@@ -961,14 +997,19 @@ describe("AccountPage states", () => {
|
||||
snapshotText,
|
||||
);
|
||||
resolveRetry?.(
|
||||
HttpResponse.json({
|
||||
...portfolio,
|
||||
isStale: false,
|
||||
lastSuccessfulAt: "2026-08-14T11:30:00Z",
|
||||
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [{ currency: "EUR", value: "2.50" }],
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
{
|
||||
...portfolio,
|
||||
isStale: false,
|
||||
lastSuccessfulAt: "2026-08-14T11:30:00Z",
|
||||
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [{ currency: "EUR", value: "2.50" }],
|
||||
},
|
||||
{ resourceKey: "portfolio", stateVersion: "2" },
|
||||
),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -993,7 +1034,7 @@ describe("AccountPage states", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) return HttpResponse.json(portfolio);
|
||||
if (requestCount === 1) return HttpResponse.json(resource(portfolio));
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
@@ -1020,6 +1061,6 @@ describe("AccountPage states", () => {
|
||||
screen.getByRole("status", { name: "Обновляем портфель" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
resolveRefresh?.(HttpResponse.json(portfolio));
|
||||
resolveRefresh?.(HttpResponse.json(resource(portfolio)));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ const AccountPage: FC<AccountPageProps> = (props) => {
|
||||
account={viewModel.account}
|
||||
accountId={viewModel.accountId}
|
||||
refreshing={viewModel.refreshing}
|
||||
sourceError={viewModel.sourceError}
|
||||
refreshError={viewModel.refreshError}
|
||||
retry={viewModel.retry}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
@@ -28,9 +29,11 @@ const AccountPage: FC<AccountPageProps> = (props) => {
|
||||
<AccountPageLoaded
|
||||
account={viewModel.account}
|
||||
accountId={viewModel.accountId}
|
||||
partial={viewModel.partial}
|
||||
portfolio={viewModel.portfolio}
|
||||
positionSections={viewModel.positionSections}
|
||||
refreshing={viewModel.refreshing}
|
||||
sourceError={viewModel.sourceError}
|
||||
refreshError={viewModel.refreshError}
|
||||
retry={viewModel.retry}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
import {
|
||||
toAccountPagePositionSections,
|
||||
@@ -86,49 +87,53 @@ function createWrapper() {
|
||||
function mockLoadedAccountPage() {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(portfolio),
|
||||
HttpResponse.json(resource(portfolio)),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
items: [
|
||||
{
|
||||
id: "operation-1",
|
||||
occurredAt: "2026-08-13T10:00:00Z",
|
||||
type: "Buy",
|
||||
category: "trade",
|
||||
description: "Покупка акции",
|
||||
figi: "FIGI-STOCK",
|
||||
instrumentUid: null,
|
||||
ticker: "STK",
|
||||
instrumentType: "share",
|
||||
payment: { currency: "RUB", value: "-100" },
|
||||
quantity: "1",
|
||||
price: null,
|
||||
accruedInterest: null,
|
||||
commission: null,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-13T10:00:00Z",
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
items: [
|
||||
{
|
||||
id: "operation-1",
|
||||
occurredAt: "2026-08-13T10:00:00Z",
|
||||
type: "Buy",
|
||||
category: "trade",
|
||||
description: "Покупка акции",
|
||||
figi: "FIGI-STOCK",
|
||||
instrumentUid: null,
|
||||
ticker: "STK",
|
||||
instrumentType: "share",
|
||||
payment: { currency: "RUB", value: "-100" },
|
||||
quantity: "1",
|
||||
price: null,
|
||||
accruedInterest: null,
|
||||
commission: null,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-13T10:00:00Z",
|
||||
}),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
calculatedAt: "2026-08-13T10:00:00Z",
|
||||
events: Array.from({ length: 6 }, (_, index) => ({
|
||||
type: "coupon",
|
||||
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
|
||||
dateKind: "event",
|
||||
figi: `FIGI-EVENT-${index}`,
|
||||
instrumentUid: null,
|
||||
ticker: `BND${index}`,
|
||||
displayName: `Купон ${index}`,
|
||||
projectedAmount: { currency: "RUB", value: String(index + 1) },
|
||||
})),
|
||||
}),
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
accountId: "account-1",
|
||||
calculatedAt: "2026-08-13T10:00:00Z",
|
||||
events: Array.from({ length: 6 }, (_, index) => ({
|
||||
type: "coupon",
|
||||
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
|
||||
dateKind: "event",
|
||||
figi: `FIGI-EVENT-${index}`,
|
||||
instrumentUid: null,
|
||||
ticker: `BND${index}`,
|
||||
displayName: `Купон ${index}`,
|
||||
projectedAmount: { currency: "RUB", value: String(index + 1) },
|
||||
})),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -281,7 +286,7 @@ describe("useAccountPageViewModel", () => {
|
||||
configureTestAuth();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(portfolio),
|
||||
HttpResponse.json(resource(portfolio)),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||
@@ -304,3 +309,111 @@ describe("useAccountPageViewModel", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps overview sections independent when their source data is incomplete", async () => {
|
||||
configureTestAuth();
|
||||
mockLoadedAccountPage();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(resource(portfolio, { availability: "partial" })),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
{ items: [], hasNext: false, nextCursor: null },
|
||||
{ availability: "partial" },
|
||||
),
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
{ events: [], incomeTotalsByCurrency: [] },
|
||||
{ availability: "partial" },
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current.state).toBe("loaded"));
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
partial: true,
|
||||
recentOperations: { state: "partial", items: [] },
|
||||
recentEvents: { state: "partial", items: [] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes every overview section through one account descriptor and observational reads", async () => {
|
||||
configureTestAuth();
|
||||
const sections: Record<string, unknown> = {
|
||||
portfolio,
|
||||
operations: { items: [], hasNext: false, nextCursor: null },
|
||||
events: { events: [], incomeTotalsByCurrency: [] },
|
||||
"income-summary": { totalsByCurrency: [] },
|
||||
};
|
||||
const observed: string[] = [];
|
||||
const scopes: unknown[] = [];
|
||||
let version = "1";
|
||||
server.use(
|
||||
...Object.entries(sections).map(([path, data]) =>
|
||||
http.get(
|
||||
`*/api/v1/brokerage-accounts/:accountId/${path}`,
|
||||
({ request }) => {
|
||||
if (new URL(request.url).searchParams.get("sync") === "observe")
|
||||
observed.push(path);
|
||||
return HttpResponse.json(
|
||||
resource(data, { resourceKey: path, stateVersion: version }),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
scopes.push(await request.json());
|
||||
version = "2";
|
||||
return HttpResponse.json(
|
||||
{
|
||||
id: "overview",
|
||||
scope: { screen: "account", accountId: "account-1" },
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
},
|
||||
{ status: 202 },
|
||||
);
|
||||
}),
|
||||
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||
HttpResponse.json({
|
||||
id: "overview",
|
||||
scope: null,
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
state: "succeeded",
|
||||
errorCode: null,
|
||||
progress: { total: 4, completed: 4, discovering: false },
|
||||
resources: Object.keys(sections).map(
|
||||
(resourceKey) =>
|
||||
resource(null, { resourceKey, stateVersion: version }).meta,
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
state: "loaded",
|
||||
recentOperations: { state: "empty" },
|
||||
recentEvents: { state: "empty" },
|
||||
incomeSummary: { state: "empty" },
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
if (result.current.state === "loaded") await result.current.retry();
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(new Set(observed)).toEqual(new Set(Object.keys(sections))),
|
||||
);
|
||||
expect(scopes).toEqual([{ screen: "account", accountId: "account-1" }]);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router";
|
||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||
import { useBrokerageOperations } from "@/entities/broker-operation";
|
||||
import {
|
||||
type BrokerageAccount,
|
||||
brokerageAccountsQueryKey,
|
||||
useBrokerageAccounts,
|
||||
} from "@/entities/brokerage-account";
|
||||
import {
|
||||
type BrokerageEventsPreviewState,
|
||||
@@ -14,6 +13,7 @@ import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { formatShare, 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";
|
||||
|
||||
@@ -46,19 +46,31 @@ type AccountPageRecentOperations =
|
||||
to: string;
|
||||
}
|
||||
| {
|
||||
state: "loaded";
|
||||
state: "loaded" | "partial";
|
||||
to: string;
|
||||
items: BrokerageOperation[];
|
||||
lastSuccessfulAt?: string | null;
|
||||
stale?: boolean;
|
||||
refreshError?: boolean;
|
||||
};
|
||||
|
||||
type AccountPageRecentEvents = BrokerageEventsPreviewState;
|
||||
|
||||
type AccountPageIncomeSummary =
|
||||
| { state: "loading" | "empty"; to: string }
|
||||
| { state: "loading" | "partial" | "empty"; to: string }
|
||||
| { state: "error"; to: string; retry: () => void }
|
||||
| { state: "loaded"; to: string; summary: IncomeSummary };
|
||||
| {
|
||||
state: "loaded";
|
||||
to: string;
|
||||
summary: IncomeSummary;
|
||||
lastSuccessfulAt?: string | null;
|
||||
stale?: boolean;
|
||||
refreshError?: boolean;
|
||||
};
|
||||
|
||||
type AccountPageContent = {
|
||||
sourceError?: boolean;
|
||||
partial?: boolean;
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
refreshing: boolean;
|
||||
@@ -102,27 +114,39 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
||||
});
|
||||
const events = useBrokerageEvents(accountId);
|
||||
const income = useIncomeSummary(accountId);
|
||||
const account = useQueryClient()
|
||||
.getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey)
|
||||
?.find((item) => item.id === accountId);
|
||||
const accounts = useBrokerageAccounts();
|
||||
const sync = useScreenSync({ screen: "account", accountId }, [
|
||||
accounts,
|
||||
portfolio,
|
||||
operations,
|
||||
events,
|
||||
income,
|
||||
]);
|
||||
const account = accounts.data?.find((item) => item.id === accountId);
|
||||
const recentOperations = toRecentOperations(accountId, operations);
|
||||
const recentEvents = toRecentEvents(accountId, events);
|
||||
const incomeSummary = toIncomeSummary(accountId, income);
|
||||
if (!portfolio.data) {
|
||||
if (portfolio.isError)
|
||||
if (portfolio.isError || portfolio.meta?.sync.state === "failed")
|
||||
return {
|
||||
state: "error" as const,
|
||||
code: apiErrorCode(portfolio.error),
|
||||
retry: () => portfolio.refetch(),
|
||||
retry: sync.refresh,
|
||||
};
|
||||
return { state: "loading" as const };
|
||||
}
|
||||
const refresh = {
|
||||
refreshing: portfolio.isFetching,
|
||||
refreshError: portfolio.isError,
|
||||
retry: () => portfolio.refetch(),
|
||||
partial: portfolio.meta?.availability === "partial",
|
||||
refreshing: portfolio.isFetching || sync.refreshing,
|
||||
sourceError: sync.sourceError,
|
||||
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
|
||||
retry: sync.refresh,
|
||||
};
|
||||
if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
|
||||
if (
|
||||
portfolio.meta?.availability === "complete" &&
|
||||
!portfolio.data.positions.length &&
|
||||
!portfolio.data.cashBalances.length
|
||||
)
|
||||
return {
|
||||
state: "empty" as const,
|
||||
account,
|
||||
@@ -153,11 +177,20 @@ function toIncomeSummary(
|
||||
income: ReturnType<typeof useIncomeSummary>,
|
||||
): AccountPageIncomeSummary {
|
||||
const to = `/accounts/${accountId}/income`;
|
||||
if (income.isLoading) return { state: "loading", to };
|
||||
if (income.isError)
|
||||
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 { state: "error", to, retry: () => income.refetch() };
|
||||
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
|
||||
return { state: "loaded", to, summary: income.data };
|
||||
return {
|
||||
state: "loaded",
|
||||
to,
|
||||
summary: income.data,
|
||||
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
|
||||
stale: income.meta?.freshness === "stale",
|
||||
refreshError: income.isError || income.meta?.sync.state === "failed",
|
||||
};
|
||||
}
|
||||
|
||||
function toRecentOperations(
|
||||
@@ -165,10 +198,29 @@ function toRecentOperations(
|
||||
operations: ReturnType<typeof useBrokerageOperations>,
|
||||
): AccountPageRecentOperations {
|
||||
const to = `/accounts/${accountId}/operations`;
|
||||
if (operations.isLoading) return { state: "loading", to };
|
||||
if (operations.isError) return { state: "error", to };
|
||||
if (
|
||||
!operations.data &&
|
||||
!operations.isError &&
|
||||
operations.meta?.sync.state !== "failed"
|
||||
)
|
||||
return { state: "loading", to };
|
||||
if (operations.meta?.availability === "partial")
|
||||
return { state: "partial", to, items: operations.data?.items ?? [] };
|
||||
if (
|
||||
!operations.data &&
|
||||
(operations.isError || operations.meta?.sync.state === "failed")
|
||||
)
|
||||
return { state: "error", to };
|
||||
if (!operations.data?.items.length) return { state: "empty", to };
|
||||
return { state: "loaded", to, items: operations.data.items };
|
||||
return {
|
||||
state: "loaded",
|
||||
to,
|
||||
items: operations.data.items,
|
||||
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
|
||||
stale: operations.meta?.freshness === "stale",
|
||||
refreshError:
|
||||
operations.isError || operations.meta?.sync.state === "failed",
|
||||
};
|
||||
}
|
||||
|
||||
function toRecentEvents(
|
||||
@@ -176,11 +228,26 @@ function toRecentEvents(
|
||||
events: ReturnType<typeof useBrokerageEvents>,
|
||||
): AccountPageRecentEvents {
|
||||
const to = `/accounts/${accountId}/events`;
|
||||
if (events.isLoading) return { state: "loading", to };
|
||||
if (events.isError) return { state: "error", to };
|
||||
if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
|
||||
return { state: "loading", to };
|
||||
if (events.meta?.availability === "partial")
|
||||
return {
|
||||
state: "partial",
|
||||
to,
|
||||
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
|
||||
};
|
||||
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) return { state: "empty", to };
|
||||
return { state: "loaded", to, items };
|
||||
return {
|
||||
state: "loaded",
|
||||
to,
|
||||
items,
|
||||
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
|
||||
stale: events.meta?.freshness === "stale",
|
||||
refreshError: events.isError || events.meta?.sync.state === "failed",
|
||||
};
|
||||
}
|
||||
|
||||
function toAccountPagePositionSections(
|
||||
|
||||
@@ -19,6 +19,7 @@ type AccountPageEmptyProps = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
refreshing?: boolean;
|
||||
sourceError?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
@@ -33,6 +34,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
<PortfolioRefreshState
|
||||
refreshing={props.refreshing ?? false}
|
||||
sourceError={props.sourceError}
|
||||
refreshError={props.refreshError ?? false}
|
||||
retry={props.retry}
|
||||
/>
|
||||
|
||||
@@ -23,9 +23,11 @@ import { RecentOperations } from "./RecentOperations.view";
|
||||
type AccountPageLoadedProps = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
partial?: boolean;
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
refreshing?: boolean;
|
||||
sourceError?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
@@ -40,6 +42,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
<PortfolioRefreshState
|
||||
refreshing={props.refreshing ?? false}
|
||||
sourceError={props.sourceError}
|
||||
refreshError={props.refreshError ?? false}
|
||||
retry={props.retry}
|
||||
/>
|
||||
@@ -56,11 +59,17 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
||||
<PortfolioStructure
|
||||
key={props.accountId}
|
||||
structure={props.portfolio.structure}
|
||||
/>
|
||||
{props.partial ? (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
) : (
|
||||
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
||||
)}
|
||||
{!props.partial ? (
|
||||
<PortfolioStructure
|
||||
key={props.accountId}
|
||||
structure={props.portfolio.structure}
|
||||
/>
|
||||
) : null}
|
||||
<Accordion
|
||||
multiple
|
||||
defaultValue={[
|
||||
|
||||
@@ -4,18 +4,34 @@ import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
type IncomeSummaryProps =
|
||||
| { state: "loading"; to: string }
|
||||
| { state: "partial"; to: string }
|
||||
| { state: "error"; to: string; retry: () => void }
|
||||
| { state: "empty"; to: string }
|
||||
| { state: "loaded"; to: string; summary: IncomeSummaryData };
|
||||
| {
|
||||
state: "loaded";
|
||||
to: string;
|
||||
summary: IncomeSummaryData;
|
||||
lastSuccessfulAt?: string | null;
|
||||
stale?: boolean;
|
||||
refreshError?: boolean;
|
||||
};
|
||||
|
||||
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
if (props.state === "loading")
|
||||
return <Text c="dimmed">{t("income.summaryLoading")}</Text>;
|
||||
if (props.state === "loading" || props.state === "partial")
|
||||
return (
|
||||
<Text role="status" c="dimmed">
|
||||
{t(
|
||||
props.state === "partial"
|
||||
? "investment.partial"
|
||||
: "income.summaryLoading",
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
if (props.state === "error")
|
||||
return (
|
||||
<StateView
|
||||
@@ -38,6 +54,17 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
);
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{props.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{props.stale ? <Text size="xs">{t("investment.stale")}</Text> : null}
|
||||
{props.refreshError ? (
|
||||
<Text role="status">{t("income.refreshError")}</Text>
|
||||
) : null}
|
||||
{props.summary.totalsByCurrency.map((total) => (
|
||||
<Paper key={total.currency} p="sm" withBorder>
|
||||
<Text fw={700}>{formatMoney(total.total)}</Text>
|
||||
|
||||
+5
@@ -47,3 +47,8 @@ export const RefreshError: Story = {
|
||||
name: "Ошибка обновления с retry",
|
||||
args: { refreshError: true },
|
||||
};
|
||||
|
||||
export const SourceFailure: Story = {
|
||||
name: "Ошибка синхронизации сохраняет суммы",
|
||||
args: { refreshError: true, sourceError: true },
|
||||
};
|
||||
|
||||
+17
-6
@@ -3,6 +3,7 @@ import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type PortfolioRefreshStateProps = {
|
||||
sourceError?: boolean;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry?: () => void;
|
||||
@@ -11,10 +12,17 @@ type PortfolioRefreshStateProps = {
|
||||
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
|
||||
const { refreshing, refreshError, retry } = props;
|
||||
const { t } = useTranslation();
|
||||
if (!refreshing && !refreshError) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={retry}
|
||||
disabled={refreshing}
|
||||
variant="light"
|
||||
w="fit-content"
|
||||
>
|
||||
{t(refreshError ? "accounts.retry" : "investment.refresh")}
|
||||
</Button>
|
||||
{refreshing ? (
|
||||
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
|
||||
<Loader size="sm" />
|
||||
@@ -24,12 +32,15 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
|
||||
</Group>
|
||||
) : null}
|
||||
{refreshError ? (
|
||||
<Alert color="orange">
|
||||
<Alert color="orange" role="status" aria-live="polite">
|
||||
<Stack gap="sm">
|
||||
<Text>{t("portfolio.refreshError")}</Text>
|
||||
<Button onClick={retry} variant="light">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
<Text>
|
||||
{t(
|
||||
props.sourceError
|
||||
? "investment.syncFailed"
|
||||
: "portfolio.refreshError",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import type { AccountPageRecentOperations } from "../AccountPage.vm";
|
||||
|
||||
type RecentOperationsProps = {
|
||||
@@ -16,6 +17,23 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{operations.state === "loaded" ? (
|
||||
<>
|
||||
{operations.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(operations.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{operations.stale ? (
|
||||
<Text size="xs">{t("investment.stale")}</Text>
|
||||
) : null}
|
||||
{operations.refreshError ? (
|
||||
<Text role="status">{t("accounts.refreshError")}</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{operations.state === "loading" ? (
|
||||
<LoadingSkeleton label={t("ledger.recentLoading")}>
|
||||
{[0, 1, 2].map((index) => (
|
||||
@@ -29,7 +47,10 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
||||
{operations.state === "empty" ? (
|
||||
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
|
||||
) : null}
|
||||
{operations.state === "loaded"
|
||||
{operations.state === "partial" ? (
|
||||
<Text role="status">{t("investment.partial")}</Text>
|
||||
) : null}
|
||||
{operations.state === "loaded" || operations.state === "partial"
|
||||
? operations.items.map((operation) => (
|
||||
<LedgerEntryRow
|
||||
key={JSON.stringify([
|
||||
|
||||
Reference in New Issue
Block a user