chore: finish frontend ESLint migration #18

Enable every plugin recommended preset and replace Biome with ESLint formatting.

Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
2026-09-28 16:36:40 +03:00
committed by ksv741
parent 57630102e4
commit 72d7835227
253 changed files with 10059 additions and 8864 deletions
@@ -1,3 +1,5 @@
import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
@@ -8,8 +10,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC } from "react";
import { http, HttpResponse } from "msw";
import {
MemoryRouter,
Route,
@@ -18,10 +19,12 @@ import {
useNavigate,
} from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper, createTestQueryClient } from "@/test/query";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountEventsPage } from "./AccountEventsPage";
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
@@ -31,10 +34,10 @@ const Navigation: FC = () => {
return (
<>
<output aria-label="URL">{location.search}</output>
<button type="button" onClick={() => navigate(-1)}>
<button onClick={() => navigate(-1)} type="button">
History back
</button>
<button type="button" onClick={() => navigate(1)}>
<button onClick={() => navigate(1)} type="button">
History forward
</button>
</>
@@ -45,8 +48,8 @@ describe("Upcoming event date filters", () => {
it("keeps reads and sync disabled without an account scope", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const requests: string[] = [];
server.use(
@@ -55,17 +58,17 @@ describe("Upcoming event date filters", () => {
return new HttpResponse(null, { status: 503 });
}),
);
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => (
<QueryWrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["?from=2099-02-01"]}>
{children}
</MemoryRouter>
</QueryWrapper>
</QueryClientProvider>
),
});
await act(async () => {});
await new Promise(resolve => setTimeout(resolve, 0));
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
expect(requests).toEqual([]);
@@ -80,8 +83,8 @@ describe("Upcoming event date filters", () => {
async (search) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const requested: string[] = [];
const syncRequests: unknown[] = [];
@@ -89,13 +92,15 @@ describe("Upcoming event date filters", () => {
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, {
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed",
},
}
/* eslint-enable unicorn/no-null */)),
);
}),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
@@ -107,16 +112,16 @@ describe("Upcoming event date filters", () => {
}),
);
const period = Object.fromEntries(new URLSearchParams(search));
const { wrapper: QueryWrapper } = createQueryWrapper();
const queryClient = createTestQueryClient();
const { result } = renderHook(
() => useAccountEventsPageViewModel("all"),
{
wrapper: ({ children }) => (
<QueryWrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`?${search}`]}>
{children}
</MemoryRouter>
</QueryWrapper>
</QueryClientProvider>
),
},
);
@@ -128,7 +133,7 @@ describe("Upcoming event date filters", () => {
});
await waitFor(() =>
expect(syncRequests).toEqual([
{ screen: "events", accountId: "all", ...period },
{ accountId: "all", screen: "events", ...period },
]),
);
},
@@ -139,8 +144,8 @@ describe("Upcoming event date filters", () => {
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const searches: string[] = [];
server.use(
@@ -156,20 +161,20 @@ describe("Upcoming event date filters", () => {
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialIndex={0}
initialEntries={[
"?from=bad&to=2099-02-28",
"?from=2099-02-30",
"?from=2099-03-01&to=2099-02-28",
"?from=2099-02-01",
"?to=2099-02-28",
].map((search) => `/accounts/${accountId}/events${search}`)}
].map(search => `/accounts/${accountId}/events${search}`)}
initialIndex={0}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
@@ -182,6 +187,7 @@ describe("Upcoming event date filters", () => {
screen.getByRole("button", { name: "History forward" }),
);
await waitFor(() =>
// eslint-disable-next-line jest-dom/prefer-to-have-text-content -- URL history requires exact equality, including empty search.
expect(screen.getByLabelText("URL").textContent).toBe(expected),
);
await screen.findByLabelText("Период");
@@ -202,8 +208,8 @@ describe("Upcoming event date filters", () => {
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
server.use(
http.get(
@@ -223,8 +229,8 @@ describe("Upcoming event date filters", () => {
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
@@ -234,7 +240,7 @@ describe("Upcoming event date filters", () => {
await screen.findByRole("button", { name: "Повторить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL").textContent).toBe("");
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
},
);
@@ -243,8 +249,8 @@ describe("Upcoming event date filters", () => {
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const searches: string[] = [];
server.use(
@@ -274,8 +280,8 @@ describe("Upcoming event date filters", () => {
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
@@ -324,41 +330,41 @@ describe("Upcoming event date filters", () => {
it("retains the previous complete forecast and timestamp when its source refresh fails", async () => {
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const events = [
{
date: "2026-10-01",
displayName: "Synthetic coupon",
type: "coupon",
projectedAmount: { currency: "RUB", value: "123.45" },
type: "coupon",
},
];
const snapshot = resource(
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
{ sync: { id: "failed", state: "failed", errorCode: "TBANK_UNAVAILABLE" } },
{ sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } },
);
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json(snapshot),
),
);
const { wrapper: QueryWrapper } = createQueryWrapper();
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageViewModel("all"), {
wrapper: ({ children }) => (
<QueryWrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter>{children}</MemoryRouter>
</QueryWrapper>
</QueryClientProvider>
),
});
await waitFor(() =>
expect(result.current).toMatchObject({
state: "loaded",
events,
sourceError: true,
refreshError: true,
lastSuccessfulAt: snapshot.meta.lastSuccessfulAt,
refreshError: true,
sourceError: true,
state: "loaded",
}),
);
});
@@ -1,21 +1,23 @@
import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useBrokerageEvents } from "@/entities/brokerage-event";
import { AccountEventsPage } from "./AccountEventsPage";
const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() }));
vi.mock("@/features/tbank-connection", () => ({
useScreenSync: () => ({
refresh,
retry: refresh,
refreshing: false,
retry: refresh,
sourceError: false,
transportError: false,
}),
}));
vi.mock("@/entities/brokerage-event", async (importOriginal) => ({
vi.mock("@/entities/brokerage-event", async importOriginal => ({
...(await importOriginal<typeof import("@/entities/brokerage-event")>()),
useBrokerageEvents: vi.fn(),
}));
@@ -35,20 +37,22 @@ function renderPage() {
describe("AccountEventsPage", () => {
beforeEach(() => {
refresh.mockClear();
query.mockReturnValue({
data: {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
},
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false,
isLoading: false,
refetch: vi.fn(),
} as never);
query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
data: {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
},
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
});
it("passes the selected route account to the query", () => {
@@ -57,8 +61,8 @@ describe("AccountEventsPage", () => {
<MemoryRouter initialEntries={["/accounts/selected-account/events"]}>
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
@@ -68,16 +72,18 @@ describe("AccountEventsPage", () => {
});
it("keeps the loading fallback when no result or account is available", () => {
query.mockReturnValue({
data: undefined,
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false,
isLoading: false,
refetch: vi.fn(),
} as never);
query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
data: undefined,
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
renderPage();
expect(query).toHaveBeenCalledWith("", {});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
@@ -87,46 +93,48 @@ describe("AccountEventsPage", () => {
});
it("renders events and marks an offer without an amount", () => {
query.mockReturnValue({
data: {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [
{
date: "2026-08-20",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
projectedAmount: { currency: "RUB", value: "1250" },
ticker: "OFZ",
type: "coupon",
logoName: "ofz.png",
isin: "RU000A0JX0J2",
},
{
date: "2026-08-25",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
projectedAmount: null,
ticker: "OFZ",
type: "offer",
logoName: null,
isin: null,
},
],
},
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false,
isLoading: false,
refetch: vi.fn(),
} as never);
const { container } = renderPage();
query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
data: {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [
{
date: "2026-08-20",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: "RU000A0JX0J2",
logoName: "ofz.png",
projectedAmount: { currency: "RUB", value: "1250" },
ticker: "OFZ",
type: "coupon",
},
{
date: "2026-08-25",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: null,
logoName: null,
projectedAmount: null,
ticker: "OFZ",
type: "offer",
},
],
},
isError: false,
isLoading: false,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
renderPage();
expect(screen.getByText("1 250 ₽")).toBeInTheDocument();
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
expect(screen.getByText("Купон · ОФЗ 26238")).toBeVisible();
@@ -136,7 +144,7 @@ describe("AccountEventsPage", () => {
expect(screen.getByText("25.08.2026")).toBeVisible();
expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument();
expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2);
const logo = container.querySelector("img");
const logo = screen.queryByAltText("");
expect(logo).toHaveAttribute(
"src",
"https://invest-brands.cdn-tinkoff.ru/ofzx160.png",
@@ -144,7 +152,7 @@ describe("AccountEventsPage", () => {
expect(screen.getAllByText("O")).toHaveLength(1);
if (!logo) throw new Error("Expected the event logo");
fireEvent.error(logo);
expect(container.querySelector("img")).toBeNull();
expect(screen.queryByAltText("")).not.toBeInTheDocument();
expect(screen.getAllByText("O")).toHaveLength(2);
});
@@ -167,26 +175,28 @@ describe("AccountEventsPage", () => {
</MemoryRouter>
</MantineProvider>,
);
screen.getByRole("button", { name: "Повторить" }).click();
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(refresh).toHaveBeenCalledOnce();
});
it("shows a structural busy state before events are available", () => {
query.mockReturnValue({
data: undefined,
meta: {
availability: "complete",
sync: { state: "idle", id: null, errorCode: null },
},
isError: false,
isLoading: true,
refetch: vi.fn(),
} as never);
query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
data: undefined,
isError: false,
isLoading: true,
meta: {
availability: "complete",
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
renderPage();
const status = screen.getByRole("status", { name: "Загрузка" });
expect(status).toHaveAttribute("aria-busy", "true");
expect(status.querySelector("button")).toBeNull();
expect(within(status).queryByRole("button")).not.toBeInTheDocument();
});
});
@@ -1,4 +1,5 @@
import type { FC } from "react";
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
@@ -9,9 +10,7 @@ type AccountEventsPageProps = { accountId?: string };
const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
if (viewModel.state === "error")
return <AccountEventsPageError {...viewModel} />;
return <AccountEventsPageLoaded {...viewModel} />;
return viewModel.state === "error" ? <AccountEventsPageError {...viewModel} /> : <AccountEventsPageLoaded {...viewModel} />;
};
export { AccountEventsPage };
@@ -1,53 +1,54 @@
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection";
type AccountEventsPageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void; onReset: () => void }
| {
state: "loaded";
events: BrokerageEvent[];
sourceError?: boolean;
lastSuccessfulAt?: string | null;
partial?: boolean;
refreshing: boolean;
refreshError: boolean;
retry: () => void;
draftFrom: string | null;
draftTo: string | null;
canApply: boolean;
onDraftFromChange: (value: string | null) => void;
onDraftToChange: (value: string | null) => void;
onApply: () => void;
onReset: () => void;
};
type AccountEventsPageViewModel
= | {
canApply: boolean;
draftFrom: null | string;
draftTo: null | string;
events: BrokerageEvent[];
lastSuccessfulAt?: null | string;
onApply: () => void;
onDraftFromChange: (value: null | string) => void;
onDraftToChange: (value: null | string) => void;
onReset: () => void;
partial?: boolean;
refreshError: boolean;
refreshing: boolean;
retry: () => void;
sourceError?: boolean;
state: "loaded";
}
| { onReset: () => void; retry: () => void; state: "error" }
| { state: "loading" };
function useAccountEventsPageViewModel(
scopeId?: string,
): AccountEventsPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParams, setSearchParams] = useSearchParams();
const urlFrom = searchParams.get("from");
const urlTo = searchParams.get("to");
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = validUrlPeriod ? urlFrom : null;
const appliedTo = validUrlPeriod ? urlTo : null;
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
const [searchParameters, setSearchParameters] = useSearchParams();
const urlFrom = searchParameters.get("from");
const urlTo = searchParameters.get("to");
const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = isValidUrlPeriod ? urlFrom : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const appliedTo = isValidUrlPeriod ? urlTo : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const [draftFrom, setDraftFrom] = useState<null | string>(appliedFrom);
const [draftTo, setDraftTo] = useState<null | string>(appliedTo);
useEffect(() => {
if (validUrlPeriod) return;
const normalized = new URLSearchParams(searchParams);
if (isValidUrlPeriod) return;
const normalized = new URLSearchParams(searchParameters);
normalized.delete("from");
normalized.delete("to");
setSearchParams(normalized, { replace: true });
}, [validUrlPeriod, searchParams, setSearchParams]);
setSearchParameters(normalized, { replace: true });
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
const [previousPeriod, setPreviousPeriod] = useState([
appliedFrom,
appliedTo,
@@ -58,69 +59,69 @@ function useAccountEventsPageViewModel(
setDraftTo(appliedTo);
}
const events = useBrokerageEvents(accountId, {
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
...(appliedFrom && { from: appliedFrom }),
...(appliedTo && { to: appliedTo }),
});
const sync = useScreenSync(
{
screen: "events",
accountId,
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
screen: "events",
...(appliedFrom && { from: appliedFrom }),
...(appliedTo && { to: appliedTo }),
},
[events],
);
if (events.isLoading) return { state: "loading" };
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParams({});
setSearchParameters({});
};
if (events.isLoading) return { state: "loading" };
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
return {
state: "error",
onReset,
retry: () => {
void sync.retry();
},
state: "error",
};
if (!events.data) return { state: "loading" };
return {
state: "loaded",
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo)
&& isValidPeriod(draftFrom, draftTo),
draftFrom,
draftTo,
events: events.data.events,
sourceError: sync.sourceError,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({
...(draftFrom && { from: draftFrom }),
...(draftTo && { to: draftTo }),
});
},
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onReset,
partial: events.meta?.availability !== "complete",
refreshing: events.isFetching || sync.refreshing,
refreshError: events.isError || sync.sourceError || sync.transportError,
refreshing: events.isFetching || sync.refreshing,
retry: () => {
void sync.retry();
},
draftFrom,
draftTo,
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) &&
isValidPeriod(draftFrom, draftTo),
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParams({
...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}),
});
},
onReset,
sourceError: sync.sourceError,
state: "loaded",
};
}
const calendarDate = z.string().date();
function isValidPeriod(from: string | null, to: string | null) {
const calendarDate = z.iso.date();
function isValidPeriod(from: null | string, to: null | string) {
return (
[from, to].every(
(date) => date === null || calendarDate.safeParse(date).success,
) &&
(!from || !to || from <= to)
date => date === null || calendarDate.safeParse(date).success,
)
&& (!from || !to || from <= to)
);
}
@@ -1,11 +1,12 @@
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
return (
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
<StateView text={t("events.emptyText")} title={t("events.emptyTitle")} />
);
};
@@ -1,25 +1,26 @@
import type { FC } from "react";
import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
type AccountEventsPageErrorProps = { retry: () => void; onReset: () => void };
type AccountEventsPageErrorProps = { onReset: () => void; retry: () => void };
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation();
return (
<StateView
error
title={t("events.errorTitle")}
text={t("events.errorText")}
action={
action={(
<Group>
<Button onClick={props.retry}>{t("events.retry")}</Button>
<Button variant="default" onClick={props.onReset}>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
}
)}
error
text={t("events.errorText")}
title={t("events.errorTitle")}
/>
);
};
@@ -1,3 +1,5 @@
import type { FC } from "react";
import {
Alert,
Button,
@@ -8,11 +10,13 @@ import {
Title,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { BrokerageEventCard } from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
type AccountEventsPageLoadedProps = Extract<
@@ -22,68 +26,11 @@ type AccountEventsPageLoadedProps = Extract<
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { t } = useTranslation();
return (
<Stack gap="lg">
{props.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
) : null}
<Title order={1}>{t("events.title")}</Title>
<Button onClick={props.retry} disabled={props.refreshing} w="fit-content">
{t("investment.refresh")}
</Button>
{props.partial ? (
<Alert role="status">{t("investment.partial")}</Alert>
) : null}
{props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null}
{props.refreshError ? (
<Alert color="yellow" role="status" aria-live="polite">
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button variant="subtle" onClick={props.retry}>
{t("accounts.retry")}
</Button>
</Alert>
) : null}
<Text c="dimmed">{t("events.hint")}</Text>
<Group align="end">
<DatePickerInput
type="range"
allowSingleDateInRange
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
value={[props.draftFrom, props.draftTo]}
onChange={([from, to]) => {
props.onDraftFromChange(from);
props.onDraftToChange(to);
}}
valueFormat="DD.MM.YYYY"
popoverProps={{
returnFocus: true,
middlewares: { shift: { crossAxis: true } },
}}
/>
</Group>
<Group>
<Button onClick={props.onApply} disabled={!props.canApply}>
{t("ledger.apply")}
</Button>
<Button variant="default" onClick={props.onReset}>
{t("ledger.reset")}
</Button>
</Group>
{props.events.length ? (
function renderEvents() {
if (props.events.length > 0) {
return (
<Stack gap="sm">
{props.events.map((event) => (
{props.events.map(event => (
<BrokerageEventCard
event={event}
key={JSON.stringify([
@@ -98,9 +45,73 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
/>
))}
</Stack>
) : !props.partial ? (
<AccountEventsPageEmpty />
) : null}
);
}
if (props.partial) {
return;
}
return <AccountEventsPageEmpty />;
}
return (
<Stack gap="lg">
{!!(props.lastSuccessfulAt) && ((
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
))}
<Title order={1}>{t("events.title")}</Title>
<Button disabled={props.refreshing} onClick={props.retry} w="fit-content">
{t("investment.refresh")}
</Button>
{!!(props.partial) && ((
<Alert role="status">{t("investment.partial")}</Alert>
))}
{!!(props.refreshing) && ((
<Progress animated aria-label={t("accounts.refreshing")} value={100} />
))}
{!!(props.refreshError) && ((
<Alert aria-live="polite" color="yellow" role="status">
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button onClick={props.retry} variant="subtle">
{t("accounts.retry")}
</Button>
</Alert>
))}
<Text c="dimmed">{t("events.hint")}</Text>
<Group align="end">
<DatePickerInput
allowSingleDateInRange
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
onChange={([from, to]) => {
props.onDraftFromChange(from);
props.onDraftToChange(to);
}}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
returnFocus: true,
}}
type="range"
value={[props.draftFrom, props.draftTo]}
valueFormat="DD.MM.YYYY"
/>
</Group>
<Group>
<Button disabled={!props.canApply} onClick={props.onApply}>
{t("ledger.apply")}
</Button>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
{renderEvents()}
</Stack>
);
};
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { Group, Skeleton } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
const skeletonRowIds = ["first", "second", "third", "fourth"];
@@ -19,8 +20,8 @@ const AccountEventsPageLoading: FC = () => {
<Skeleton height={36} width={116} />
<Skeleton height={36} width={108} />
</Group>
{skeletonRowIds.map((id) => (
<Skeleton key={id} height={82} radius="md" />
{skeletonRowIds.map(id => (
<Skeleton height={82} key={id} radius="md" />
))}
</LoadingSkeleton>
);
+19 -17
View File
@@ -1,46 +1,48 @@
const ISO_DATE_LENGTH = 10;
const DATE_PART_COUNT = 3;
type IncomePeriod = { from: string; to: string };
type IncomeForm = IncomePeriod;
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
function formatDraftDate(value: string): string {
const [year, month, day] = value.split("-");
const [year, month, day] = value.split("-", DATE_PART_COUNT);
return `${day}.${month}.${year}`;
}
function parseDraftDate(value: string): string | null {
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null;
const [day, month, year] = value.split(".");
const date = `${year}-${month}-${day}`;
return isDate(date) ? date : null;
}
function isDate(value: string): boolean {
if (!datePattern.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return (
Number.isFinite(parsed.valueOf()) &&
parsed.toISOString().slice(0, 10) === value
Number.isFinite(parsed.valueOf())
&& parsed.toISOString().slice(0, ISO_DATE_LENGTH) === value
);
}
function moscowDate(value: Date): string {
const parts = new Intl.DateTimeFormat("en-CA", {
day: "2-digit",
month: "2-digit",
timeZone: "Europe/Moscow",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(value);
const part = (type: string) =>
parts.find((item) => item.type === type)?.value;
parts.find(item => item.type === type)?.value;
return `${part("year")}-${part("month")}-${part("day")}`;
}
export {
formatDraftDate,
type IncomeForm,
function parseDraftDate(value: string): null | string {
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return /* eslint-disable-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date. */ null;
const [day, month, year] = value.split(".", DATE_PART_COUNT);
const date = `${year}-${month}-${day}`;
return isDate(date) ? date : /* eslint-disable-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date. */ null;
}
export { formatDraftDate,
type IncomePeriod as IncomeForm,
type IncomePeriod,
isDate,
ISO_DATE_LENGTH,
moscowDate,
parseDraftDate,
};
@@ -1,7 +1,9 @@
import { resource } from "@/test/resource";
import type { FC } from "react";
import "dayjs/locale/ru";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import { QueryClientProvider } from "@tanstack/react-query";
import {
act,
fireEvent,
@@ -11,8 +13,7 @@ import {
waitFor,
within,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC } from "react";
import { http, HttpResponse } from "msw";
import {
MemoryRouter,
Route,
@@ -21,10 +22,14 @@ import {
useNavigate,
} from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { IncomeAnalytics } from "@/entities/income";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { isDate } from "../../model/period";
import { AccountIncomePage } from "./AccountIncomePage";
import {
@@ -33,16 +38,9 @@ import {
} from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
const analytics = {
const analytics = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accountId: "account-1",
period: { from: "2026-08-01", to: "2026-08-27" },
calculatedAt: "2026-08-27T00:00:00Z",
totalsByCurrency: [summary("RUB", "10", "2")],
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [comparison("RUB")],
},
yearComparison: null,
bucketGranularity: "day",
buckets: [
{
@@ -50,113 +48,124 @@ const analytics = {
totalsByCurrency: [summary("RUB", "10", "2")],
},
],
instruments: [
{
key: "income-group-opaque-v2",
figi: "FIGI",
instrumentUid: null,
ticker: "BOND",
instrumentType: "bond",
displayName: "BOND",
coupon: money("RUB", "10"),
dividend: money("RUB", "2"),
total: money("RUB", "12"),
receiptCount: 2,
},
],
calculatedAt: "2026-08-27T00:00:00Z",
entries: [
entry(),
{
...entry(),
id: "dividend-1",
type: "OPERATION_TYPE_DIVIDEND",
payment: money("RUB", "2"),
type: "OPERATION_TYPE_DIVIDEND",
},
],
} satisfies IncomeAnalytics;
instruments: [
{
coupon: money("RUB", "10"),
displayName: "BOND",
dividend: money("RUB", "2"),
figi: "FIGI",
instrumentType: "bond",
instrumentUid: null,
key: "income-group-opaque-v2",
receiptCount: 2,
ticker: "BOND",
total: money("RUB", "12"),
},
],
period: { from: "2026-08-01", to: "2026-08-27" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [comparison("RUB")],
},
totalsByCurrency: [summary("RUB", "10", "2")],
yearComparison: null,
}
/* eslint-enable unicorn/no-null */) satisfies IncomeAnalytics;
function comparison(currency: string) {
return {
compared: summary(currency, "1", "0"),
currency,
difference: summary(currency, "9", "2"),
selected: summary(currency, "10", "2"),
};
}
function entry(): IncomeAnalytics["entries"][number] {
return (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id: "coupon-1",
instrumentKey: "income-group-opaque-v2",
instrumentType: "bond",
instrumentUid: null,
logoName: null,
occurredAt: "2026-08-01T12:00:00Z",
payment: money("RUB", "10"),
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
}
/* eslint-enable unicorn/no-null */);
}
function money(currency: string, value: string) {
return { currency, value };
}
function summary(currency: string, coupon: string, dividend: string) {
return {
currency,
coupon: money(currency, coupon),
dividend: money(currency, dividend),
total: money(currency, String(Number(coupon) + Number(dividend))),
};
}
function comparison(currency: string) {
return {
currency,
selected: summary(currency, "10", "2"),
compared: summary(currency, "1", "0"),
difference: summary(currency, "9", "2"),
};
}
function entry(): IncomeAnalytics["entries"][number] {
return {
id: "coupon-1",
instrumentKey: "income-group-opaque-v2",
occurredAt: "2026-08-01T12:00:00Z",
type: "OPERATION_TYPE_COUPON",
category: "income",
description: "Купон",
figi: "FIGI",
instrumentUid: null,
ticker: "BOND",
instrumentType: "bond",
logoName: null,
payment: money("RUB", "10"),
quantity: null,
price: null,
accruedInterest: null,
commission: null,
};
}
function renderPage(search = "from=2026-08-01&to=2026-08-27") {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
const result = render(
const queryClient = createTestQueryClient();
const view = render(
<MantineProvider env="test">
<DatesProvider settings={{ locale: "ru" }}>
<QueryWrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter
initialEntries={[`/accounts/account-1/income?${search}`]}
>
<LocationProbe />
<Routes>
<Route
path="/accounts/:accountId/income"
element={<AccountIncomePage />}
path="/accounts/:accountId/income"
/>
<Route
path="/accounts/:accountId/events"
element={<h1>Events</h1>}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
</QueryWrapper>
</QueryClientProvider>
</DatesProvider>
</MantineProvider>,
);
return { ...result, queryClient };
return { ...view, queryClient };
}
function summary(currency: string, coupon: string, dividend: string) {
return {
coupon: money(currency, coupon),
currency,
dividend: money(currency, dividend),
total: money(currency, String(Number(coupon) + Number(dividend))),
};
}
describe("AccountIncomePage", () => {
it("keeps reads and sync disabled without an account scope", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const requests: string[] = [];
server.use(
@@ -165,17 +174,17 @@ describe("AccountIncomePage", () => {
return new HttpResponse(null, { status: 503 });
}),
);
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
wrapper: ({ children }) => (
<QueryWrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
{children}
</MemoryRouter>
</QueryWrapper>
</QueryClientProvider>
),
});
await act(async () => {});
await new Promise(resolve => setTimeout(resolve, 0));
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
expect(requests).toEqual([]);
@@ -190,13 +199,15 @@ describe("AccountIncomePage", () => {
({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, {
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed",
},
}
/* eslint-enable unicorn/no-null */)),
);
},
),
@@ -216,9 +227,9 @@ describe("AccountIncomePage", () => {
await waitFor(() =>
expect(syncRequests).toEqual([
{
screen: "income",
accountId: "account-1",
from: "2024-02-29",
screen: "income",
to: "2024-03-01",
},
]),
@@ -243,32 +254,32 @@ describe("AccountIncomePage", () => {
});
it("preserves every repeated-ID payment when an open card updates", async () => {
const first = { ...entry(), id: "same-id", description: "Выплата A" };
const first = { ...entry(), description: "Выплата A", id: "same-id" };
const second = {
...entry(),
id: "same-id",
description: "Выплата B",
id: "same-id",
payment: money("RUB", "-2"),
};
const third = {
...entry(),
id: "other-id",
description: "Выплата C",
id: "other-id",
payment: money("RUB", "1"),
};
function breakdown(entries: IncomeAnalytics["entries"], total: string) {
const display = toIncomeDisplayModel({
...analytics,
entries,
instruments: [
{
...analytics.instruments[0],
coupon: money("RUB", total),
dividend: money("RUB", "0"),
total: money("RUB", total),
receiptCount: entries.length,
total: money("RUB", total),
},
],
entries,
});
expect(display).not.toBeNull();
return (
@@ -282,7 +293,7 @@ describe("AccountIncomePage", () => {
const payments = () =>
within(panel)
.getAllByText(/^Выплата [ABC]$/)
.map((element) => element.textContent);
.map(element => element.textContent);
expect(payments()).toEqual(["Выплата A", "Выплата B", "Выплата C"]);
rerender(breakdown([third, first, second], "9"));
@@ -380,10 +391,10 @@ describe("AccountIncomePage", () => {
HttpResponse.json(
resource({
...analytics,
totalsByCurrency: [],
buckets: [],
instruments: [],
entries: [],
instruments: [],
totalsByCurrency: [],
}),
),
),
@@ -398,33 +409,35 @@ describe("AccountIncomePage", () => {
});
it("renders the signed drill-down using opaque membership rather than instrument identity", async () => {
const response = {
...analytics,
instruments: [
{
...analytics.instruments[0],
key: "server-only-key",
total: money("RUB", "8"),
dividend: money("RUB", "-2"),
},
],
entries: [
{
...analytics.entries[0],
instrumentKey: "server-only-key",
figi: null,
ticker: null,
description: "Исходная выплата",
},
{
...analytics.entries[1],
instrumentKey: "server-only-key",
instrumentUid: "unrelated-uid",
payment: money("RUB", "-2"),
description: "Отрицательная корректировка",
},
],
};
const response = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...analytics,
entries: [
{
...analytics.entries[0],
description: "Исходная выплата",
figi: null,
instrumentKey: "server-only-key",
ticker: null,
},
{
...analytics.entries[1],
description: "Отрицательная корректировка",
instrumentKey: "server-only-key",
instrumentUid: "unrelated-uid",
payment: money("RUB", "-2"),
},
],
instruments: [
{
...analytics.instruments[0],
dividend: money("RUB", "-2"),
key: "server-only-key",
total: money("RUB", "8"),
},
],
}
/* eslint-enable unicorn/no-null */);
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(response)),
@@ -485,7 +498,7 @@ describe("AccountIncomePage", () => {
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Годом ранее", level: 2 }),
screen.getByRole("heading", { level: 2, name: "Годом ранее" }),
).toBeInTheDocument();
expect(screen.queryByText("Сравнения")).not.toBeInTheDocument();
expect(screen.queryByText("RUB", { exact: true })).not.toBeInTheDocument();
@@ -501,6 +514,7 @@ describe("AccountIncomePage", () => {
expect(within(difference).queryByText("нет базы")).not.toBeInTheDocument();
expect(within(difference).getAllByText(/%$/)).toHaveLength(2);
expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
within(difference).getByText("+2 ₽").closest("dd"),
).toHaveTextContent(/^\+2 ₽$/);
});
@@ -509,11 +523,13 @@ describe("AccountIncomePage", () => {
const dependencies = [
{ ...resource(analytics).meta, role: "selected" as const },
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
(
{
...resource(null).meta,
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
}
),
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
@@ -556,12 +572,12 @@ describe("income comparison display", () => {
["1", "3", "+33,3 %"],
["0.01", "0.04", "+25 %"],
["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"],
["10", "0", null],
["0", "0", null],
["10", "-0.00", null],
["10", "-100", null],
["9".repeat(400), "1", null],
["1", "9".repeat(400), null],
["10", "0", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
["0", "0", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
["10", "-0.00", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
["10", "-100", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
["9".repeat(400), "1", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
["1", "9".repeat(400), /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
])("formats change %s against base %s", (difference, base, expected) => {
const value = comparison("RUB");
value.difference.coupon = money("RUB", difference);
@@ -574,7 +590,7 @@ describe("income comparison display", () => {
},
});
const result = display?.comparison?.values[0];
expect(result?.percentages.coupon?.replaceAll("\u00a0", " ") ?? null).toBe(
expect(result?.percentages.coupon?.replaceAll("\u{A0}", " ") ?? /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null).toBe(
expected,
);
expect(result?.difference.coupon.value).toBe(difference);
@@ -596,7 +612,7 @@ describe("income comparison display", () => {
expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe(
"01.12.2024 – 09.01.2025",
);
expect(display?.comparison?.values.map((value) => value.currency)).toEqual([
expect(display?.comparison?.values.map(value => value.currency)).toEqual([
"RUB",
"USD",
]);
@@ -609,19 +625,13 @@ const LocationProbe: FC = () => {
return (
<>
<output data-testid="location">{location.search}</output>
<button type="button" onClick={() => navigate(-1)}>
<button onClick={() => navigate(-1)} type="button">
Back
</button>
</>
);
};
function selectPeriod() {
fireEvent.click(screen.getByLabelText("Период"));
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
fireEvent.click(screen.getByRole("button", { name: /^27 августа 2026$/i }));
}
function respondWithRequestedPeriod(requests: string[]) {
server.use(
http.get(
@@ -643,6 +653,12 @@ function respondWithRequestedPeriod(requests: string[]) {
);
}
function selectPeriod() {
fireEvent.click(screen.getByLabelText("Период"));
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
fireEvent.click(screen.getByRole("button", { name: /^27 августа 2026$/i }));
}
describe("income period and snapshot boundary", () => {
afterEach(() => vi.useRealTimers());
@@ -664,6 +680,7 @@ describe("income period and snapshot boundary", () => {
fireEvent.submit(
screen
.getByRole("button", { name: "Применить" })
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
.closest("form") as HTMLFormElement,
);
await waitFor(() => expect(requests).toHaveLength(2));
@@ -735,10 +752,8 @@ describe("income period and snapshot boundary", () => {
const { queryClient } = renderPage();
await screen.findByLabelText("Период");
selectPeriod();
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
@@ -768,22 +783,24 @@ describe("income period and snapshot boundary", () => {
HttpResponse.json(
{
id: "income",
scope: { screen: "income", accountId: "account-1" },
now: "2026-09-25T00:00:00Z",
scope: { accountId: "account-1", screen: "income" },
},
{ status: 202 },
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json({
id: "income",
scope: null,
now: "2026-09-25T00:00:00Z",
state: "succeeded",
errorCode: null,
progress: { total: 1, completed: 1, discovering: false },
resources: [resource(null, { stateVersion: "2" }).meta],
}),
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
errorCode: null,
id: "income",
now: "2026-09-25T00:00:00Z",
progress: { completed: 1, discovering: false, total: 1 },
resources: [resource(null, { stateVersion: "2" }).meta],
scope: null,
state: "succeeded",
}
/* eslint-enable unicorn/no-null */)),
),
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { stateVersion: "2" })),
@@ -804,10 +821,8 @@ describe("income period and snapshot boundary", () => {
respondWithRequestedPeriod([]);
renderPage();
await screen.findByLabelText("Период");
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
@@ -843,39 +858,39 @@ describe("income period and snapshot boundary", () => {
const instruments = [
{
...analytics.instruments[0],
key: "usd",
total: money("USD", "10"),
coupon: money("USD", "10"),
dividend: money("USD", "0"),
key: "usd",
receiptCount: 1,
total: money("USD", "10"),
},
{
...analytics.instruments[0],
key: "rub",
total: money("RUB", "-2"),
coupon: money("RUB", "-2"),
dividend: money("RUB", "0"),
key: "rub",
receiptCount: 1,
total: money("RUB", "-2"),
},
{
...analytics.instruments[0],
key: "rub-negative",
displayName: "Другой инструмент",
total: money("RUB", "-3"),
coupon: money("RUB", "-3"),
displayName: "Другой инструмент",
dividend: money("RUB", "0"),
key: "rub-negative",
receiptCount: 1,
total: money("RUB", "-3"),
},
];
const display = toIncomeDisplayModel({
...analytics,
totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")],
instruments,
entries: instruments.map((instrument) => ({
entries: instruments.map(instrument => ({
...entry(),
instrumentKey: instrument.key,
payment: instrument.total,
})),
instruments,
totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")],
});
render(
<MantineProvider env="test">
@@ -902,22 +917,26 @@ describe("income period and snapshot boundary", () => {
it("maps comparison notices by role regardless of dependency order or resource key", () => {
const selected = { ...resource(analytics).meta, role: "selected" as const };
const previous = {
...resource(null).meta,
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
sync: {
id: null,
state: "failed" as const,
errorCode: "TBANK_UNAVAILABLE",
},
};
const year = {
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
};
const previous = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...resource(null).meta,
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed" as const,
},
}
/* eslint-enable unicorn/no-null */);
const year = (
{
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
}
);
for (const dependencies of [
[selected, previous, year],
[year, selected, previous],
@@ -939,16 +958,20 @@ it.each([false, true])(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(
{ ...analytics, previous: null, yearComparison: null },
{
dependencies: legacy
? [
resource(null).meta,
resource(null).meta,
resource(null).meta,
]
: [],
},
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...analytics, previous: null, yearComparison: null }
/* eslint-enable unicorn/no-null */),
(
{
dependencies: legacy
? [
resource(null).meta,
resource(null).meta,
resource(null).meta,
]
: [],
}
),
),
),
),
@@ -969,16 +992,20 @@ it.each([false, true])(
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
const dependencies = (["year", "selected", "previous"] as const).map(
(role) => ({
...resource(role === "selected" ? analytics : null).meta,
role,
}),
role => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...resource(role === "selected" ? analytics : null).meta,
role,
}
/* eslint-enable unicorn/no-null */)),
);
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(
{ ...analytics, previous: null, yearComparison: null },
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...analytics, previous: null, yearComparison: null }
/* eslint-enable unicorn/no-null */),
{ dependencies },
),
),
@@ -1005,15 +1032,17 @@ it("shows selected income before comparison coverage without inventing a zero ba
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
sync: {
id: null,
state: "failed" as const,
errorCode: "TBANK_UNAVAILABLE",
},
},
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...resource(null).meta,
role: "year" as const,
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed" as const,
},
}
/* eslint-enable unicorn/no-null */),
{ ...resource(analytics).meta, role: "selected" as const },
];
server.use(
@@ -1,4 +1,5 @@
import type { FC } from "react";
import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm";
import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
@@ -9,9 +10,7 @@ type AccountIncomePageProps = { accountId?: string };
const AccountIncomePage: FC<AccountIncomePageProps> = (props) => {
const viewModel = useAccountIncomePageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountIncomePageLoading />;
if (viewModel.state === "error")
return <AccountIncomePageError {...viewModel} />;
return <AccountIncomePageLoaded {...viewModel} />;
return viewModel.state === "error" ? <AccountIncomePageError {...viewModel} /> : <AccountIncomePageLoaded {...viewModel} />;
};
export { AccountIncomePage };
@@ -3,70 +3,73 @@ import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection";
import { validationMessage } from "@/shared/i18n";
import { formatDate } from "@/shared/lib";
import {
formatDraftDate,
type IncomeForm,
type IncomePeriod,
isDate,
ISO_DATE_LENGTH,
moscowDate,
parseDraftDate,
} from "../../model/period";
const ISO_MONTH_LENGTH = 7;
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
type ComparisonNotice =
| "comparisonPending"
| "comparisonFailed"
| "previousUnavailable"
| "yearUnavailable";
type AccountIncomePageViewModel
= | {
analytics: IncomeAnalytics;
display: IncomeDisplayModel;
form: ReturnType<typeof useForm<IncomeForm>>;
formRevision: string;
lastSuccessfulAt?: null | string;
onReset: () => void;
onSubmit: (values: IncomeForm) => void;
partial?: boolean;
refreshError: boolean;
refreshing: boolean;
retry: () => Promise<unknown> | undefined;
sourceError?: boolean;
state: "loaded";
}
| {
period: IncomePeriod;
retry: () => Promise<unknown> | undefined;
state: "error";
}
| { state: "loading" };
type ComparisonNotice
= | "comparisonFailed"
| "comparisonPending"
| "previousUnavailable"
| "yearUnavailable";
type IncomeDisplayModel = {
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
yearNotice: ComparisonNotice;
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
comparison: {
selectedPeriod: string;
comparison: null | {
comparedPeriod: string;
selectedPeriod: string;
values: (NonNullable<
IncomeAnalytics["yearComparison"]
>["totalsByCurrency"][number] & {
percentages: Record<"total" | "coupon" | "dividend", string | null>;
percentages: Record<"coupon" | "dividend" | "total", null | string>;
})[];
} | null;
};
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
yearNotice: ComparisonNotice;
};
type AccountIncomePageViewModel =
| { state: "loading" }
| {
state: "error";
retry: () => undefined | Promise<unknown>;
period: IncomePeriod;
}
| {
state: "loaded";
analytics: IncomeAnalytics;
display: IncomeDisplayModel;
formRevision: string;
sourceError?: boolean;
lastSuccessfulAt?: string | null;
partial?: boolean;
refreshing: boolean;
refreshError: boolean;
retry: () => undefined | Promise<unknown>;
form: ReturnType<typeof useForm<IncomeForm>>;
onReset: () => void;
onSubmit: (values: IncomeForm) => void;
};
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
const draftDate = z
.string()
.refine((value) => parseDraftDate(value) !== null, {
.refine(value => parseDraftDate(value) !== null, {
error: validationMessage("income.validation.date"),
})
.refine(
@@ -90,16 +93,16 @@ const useAccountIncomePageViewModel = (
): AccountIncomePageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParams, setSearchParams] = useSearchParams();
const [searchParameters, setSearchParameters] = useSearchParams();
const requested = useMemo(
() => ({
from: searchParams.get("from") ?? "",
to: searchParams.get("to") ?? "",
from: searchParameters.get("from") ?? "",
to: searchParameters.get("to") ?? "",
}),
[searchParams],
[searchParameters],
);
const period = useMemo(
() => (validPeriod(requested) ? requested : defaultPeriod()),
() => (isValidPeriod(requested) ? requested : defaultPeriod()),
[requested],
);
const form = useForm<IncomeForm>({
@@ -109,60 +112,94 @@ const useAccountIncomePageViewModel = (
const [resetRevision, setResetRevision] = useState(0);
const analytics = useIncomeAnalytics(accountId, period);
const sync = useScreenSync(
{ screen: "income", accountId, from: period.from, to: period.to },
{ accountId, from: period.from, screen: "income", to: period.to },
[analytics],
);
useEffect(() => {
if (!validPeriod(requested)) setSearchParams(period, { replace: true });
}, [period, requested, setSearchParams]);
if (!isValidPeriod(requested)) setSearchParameters(period, { replace: true });
}, [period, requested, setSearchParameters]);
useEffect(() => {
form.reset(draftPeriod(period));
}, [form, period]);
if (analytics.isLoading || !analytics.data) {
return analytics.isError || analytics.meta?.sync.state === "failed" ? { period, retry: sync.retry, state: "error" } : { state: "loading" };
}
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
if (!display)
return {
period,
retry: () => analytics.observe(),
state: "error",
};
const onSubmit = (values: IncomeForm) => {
const from = parseDraftDate(values.from);
const to = parseDraftDate(values.to);
if (from && to) setSearchParams({ from, to });
if (from && to) setSearchParameters({ from, to });
};
const onReset = () => {
const next = defaultPeriod();
form.reset(draftPeriod(next));
// Reset the open calendar along with the form.
setResetRevision((revision) => revision + 1);
setSearchParams(next, { replace: true });
setResetRevision(revision => revision + 1);
setSearchParameters(next, { replace: true });
};
if (analytics.isLoading || !analytics.data) {
if (analytics.isError || analytics.meta?.sync.state === "failed")
return { state: "error", retry: sync.retry, period };
return { state: "loading" };
}
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
if (!display)
return {
state: "error",
retry: () => analytics.observe(),
period,
};
return {
state: "loaded",
analytics: analytics.data,
display,
formRevision: `${period.from}:${period.to}:${resetRevision}`,
sourceError: sync.sourceError,
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
partial: analytics.meta?.availability !== "complete",
refreshing: analytics.isFetching || sync.refreshing,
refreshError: analytics.isError || sync.sourceError || sync.transportError,
retry: sync.retry,
form,
onSubmit,
formRevision: `${period.from}:${period.to}:${resetRevision}`,
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
onReset,
onSubmit,
partial: analytics.meta?.availability !== "complete",
refreshError: analytics.isError || sync.sourceError || sync.transportError,
refreshing: analytics.isFetching || sync.refreshing,
retry: sync.retry,
sourceError: sync.sourceError,
state: "loaded",
};
};
function draftPeriod(period: IncomePeriod): IncomeForm {
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
if (!comparison) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
return {
comparedPeriod: formatComparisonPeriod(comparison.period),
selectedPeriod: formatComparisonPeriod(period),
values: comparison.totalsByCurrency.map(value => ({
...value,
percentages: {
coupon: formatIncomeChange(
value.difference.coupon.value,
value.compared.coupon.value,
),
dividend: formatIncomeChange(
value.difference.dividend.value,
value.compared.dividend.value,
),
total: formatIncomeChange(
value.difference.total.value,
value.compared.total.value,
),
},
})),
};
}
function comparisonNotice(
dependency:
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
| undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
function defaultPeriod(): IncomePeriod {
@@ -170,16 +207,40 @@ function defaultPeriod(): IncomePeriod {
return { from: `${today.slice(0, ISO_MONTH_LENGTH)}-01`, to: today };
}
function validPeriod(period: IncomePeriod): period is IncomePeriod {
function draftPeriod(period: IncomePeriod): IncomeForm {
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
}
function formatComparisonPeriod(period: IncomePeriod): string {
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
}
function formatIncomeChange(
difference: string,
compared: string,
): null | string {
// Display-only ratio; server-provided Money values remain exact decimal strings.
const base = Number(compared);
if (!Number.isFinite(base) || base <= 0) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const ratio = Number(difference) / base;
if (!Number.isFinite(ratio)) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
return new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
signDisplay: "exceptZero",
style: "percent",
}).format(ratio);
}
function isValidPeriod(period: IncomePeriod): period is IncomePeriod {
if (!isDate(period.from) || !isDate(period.to)) return false;
const from = new Date(`${period.from}T00:00:00Z`);
const to = new Date(`${period.to}T00:00:00Z`);
const today = new Date(`${moscowDate(new Date())}T00:00:00Z`);
return (
from.toISOString().slice(0, 10) === period.from &&
to.toISOString().slice(0, 10) === period.to &&
from <= to &&
to <= today
from.toISOString().slice(0, ISO_DATE_LENGTH) === period.from
&& to.toISOString().slice(0, ISO_DATE_LENGTH) === period.to
&& from <= to
&& to <= today
);
}
@@ -191,8 +252,8 @@ function toIncomeDisplayModel(
string,
{
currency: string;
receiptCount: number;
includedCount: number;
receiptCount: number;
}
>();
const currencies = new Set(
@@ -200,25 +261,28 @@ function toIncomeDisplayModel(
);
for (const instrument of analytics.instruments) {
if (
typeof instrument.key !== "string" ||
!instrument.key.trim() ||
instruments.has(instrument.key) ||
!currencies.has(instrument.total.currency)
typeof instrument.key !== "string"
|| !instrument.key.trim()
|| instruments.has(instrument.key)
|| !currencies.has(instrument.total.currency)
)
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
return null;
instruments.set(instrument.key, {
currency: instrument.total.currency,
receiptCount: instrument.receiptCount,
includedCount: 0,
receiptCount: instrument.receiptCount,
});
}
const entries: IncomeDisplayModel["entries"] = [];
const occurrences = new Map<string, number>();
for (const entry of analytics.entries) {
if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim())
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
return null;
const instrument = instruments.get(entry.instrumentKey);
if (!instrument || entry.payment?.currency !== instrument.currency)
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
return null;
const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]);
const occurrence = occurrences.get(occurrenceKey) ?? 0;
@@ -235,80 +299,20 @@ function toIncomeDisplayModel(
});
}
for (const instrument of instruments.values()) {
if (instrument.includedCount !== instrument.receiptCount) return null;
if (instrument.includedCount !== instrument.receiptCount) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
}
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
const year = meta?.dependencies?.find(({ role }) => role === "year");
return {
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
entries,
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
previousNotice: comparisonNotice(previous, "previousUnavailable"),
yearNotice: comparisonNotice(year, "yearUnavailable"),
};
}
function comparisonNotice(
dependency:
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
| undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
if (!comparison) return null;
return {
selectedPeriod: formatComparisonPeriod(period),
comparedPeriod: formatComparisonPeriod(comparison.period),
values: comparison.totalsByCurrency.map((value) => ({
...value,
percentages: {
total: formatIncomeChange(
value.difference.total.value,
value.compared.total.value,
),
coupon: formatIncomeChange(
value.difference.coupon.value,
value.compared.coupon.value,
),
dividend: formatIncomeChange(
value.difference.dividend.value,
value.compared.dividend.value,
),
},
})),
};
}
function formatComparisonPeriod(period: IncomePeriod): string {
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
}
function formatIncomeChange(
difference: string,
compared: string,
): string | null {
// Display-only ratio; server-provided Money values remain exact decimal strings.
const base = Number(compared);
if (!Number.isFinite(base) || base <= 0) return null;
const ratio = Number(difference) / base;
if (!Number.isFinite(ratio)) return null;
return new Intl.NumberFormat("ru-RU", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "exceptZero",
}).format(ratio);
}
export {
type AccountIncomePageViewModel,
type IncomeDisplayModel,
@@ -1,7 +1,9 @@
import type { FC } from "react";
import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
@@ -10,10 +12,10 @@ const AccountIncomePageError: FC<ErrorProps> = (props) => {
const { t } = useTranslation();
return (
<StateView
error
title={t("income.errorTitle")}
text={t("income.errorText")}
action={<Button onClick={props.retry}>{t("income.retry")}</Button>}
error
text={t("income.errorText")}
title={t("income.errorTitle")}
/>
);
};
@@ -1,3 +1,5 @@
import type { FC } from "react";
import {
Alert,
Button,
@@ -8,12 +10,14 @@ import {
Title,
} from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib";
import classes from "../AccountIncomePage.module.css";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import classes from "../AccountIncomePage.module.css";
import { IncomeComparisons } from "./IncomeComparisons.view";
import { IncomePayments } from "./IncomePayments.view";
import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view";
@@ -26,41 +30,55 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
analytics,
display,
form,
refreshing,
refreshError,
retry,
onSubmit,
onReset,
onSubmit,
refreshError,
refreshing,
retry,
} = props;
const { t } = useTranslation();
function renderSummary() {
if (props.partial) {
return <Alert role="status">{t("investment.partial")}</Alert>;
}
if (analytics.totalsByCurrency.length > 0) {
return <IncomeSummary totals={analytics.totalsByCurrency} />;
}
return (
<StateView
text={t("income.emptyText")}
title={t("income.emptyTitle")}
/>
);
}
return (
<Stack gap="lg" className={classes.page}>
{props.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
<Stack className={classes.page} gap="lg">
{!!(props.lastSuccessfulAt) && ((
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
) : null}
))}
<Title order={1}>{t("income.title")}</Title>
<Button onClick={retry} disabled={refreshing} w="fit-content">
<Button disabled={refreshing} onClick={retry} w="fit-content">
{t("investment.refresh")}
</Button>
<FormProvider {...form}>
<IncomePeriodForm
key={props.formRevision}
onSubmit={form.handleSubmit(onSubmit)}
onReset={onReset}
onSubmit={form.handleSubmit(onSubmit)}
/>
</FormProvider>
{refreshing ? (
<Group gap="xs" role="status" aria-label={t("income.refreshing")}>
{!!(refreshing) && ((
<Group aria-label={t("income.refreshing")} gap="xs" role="status">
<Loader size="sm" />
<Text>{t("income.refreshing")}</Text>
</Group>
) : null}
{refreshError ? (
<Alert color="orange" role="status" aria-live="polite">
))}
{!!(refreshError) && ((
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="sm">
<Text>
{t(
@@ -74,27 +92,18 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
</Button>
</Stack>
</Alert>
) : null}
{props.partial ? (
<Alert role="status">{t("investment.partial")}</Alert>
) : analytics.totalsByCurrency.length ? (
<IncomeSummary totals={analytics.totalsByCurrency} />
) : (
<StateView
title={t("income.emptyTitle")}
text={t("income.emptyText")}
/>
)}
))}
{renderSummary()}
<Alert color="blue">{t("income.taxBoundary")}</Alert>
<IncomeComparisons
title={t("income.previousPeriod")}
comparison={display.previousComparison}
notice={t(`income.${display.previousNotice}`)}
title={t("income.previousPeriod")}
/>
<IncomeComparisons
title={t("income.yearAgo")}
comparison={display.comparison}
notice={t(`income.${display.yearNotice}`)}
title={t("income.yearAgo")}
/>
<IncomePayments entries={display.entries} />
</Stack>
@@ -1,7 +1,9 @@
import type { FC } from "react";
import { Paper, Skeleton, Stack } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import classes from "../AccountIncomePage.module.css";
const AccountIncomePageLoading: FC = () => {
@@ -1,38 +1,44 @@
import { Paper, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { Paper, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountIncomePage.module.css";
import type { IncomeDisplayModel } from "../AccountIncomePage.vm";
type IncomeComparisonsProps = {
comparison: IncomeDisplayModel["comparison"];
title: string;
notice: string;
};
import classes from "../AccountIncomePage.module.css";
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]> & {
title: string;
};
type ComparisonValuesProps = {
kind: "selected" | "compared" | "difference";
kind: "compared" | "difference" | "selected";
label: string;
values: ComparisonProps["values"][number]["selected"];
percentages?: ComparisonProps["values"][number]["percentages"];
values: ComparisonProps["values"][number]["selected"];
};
type IncomeComparisonsProps = {
comparison: IncomeDisplayModel["comparison"];
notice: string;
title: string;
};
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
return (
<Stack>
<Title order={2}>{props.title}</Title>
{props.comparison ? (
<Comparison {...props.comparison} title={props.title} />
) : (
<Paper p="sm" radius="md" className={classes.comparisonNotice}>
<Text size="sm" c="gray.7" role="status" aria-live="polite">
{props.notice}
</Text>
</Paper>
)}
{props.comparison
? (
<Comparison {...props.comparison} title={props.title} />
)
: (
<Paper className={classes.comparisonNotice} p="sm" radius="md">
<Text aria-live="polite" c="gray.7" role="status" size="sm">
{props.notice}
</Text>
</Paper>
)}
</Stack>
);
};
@@ -42,12 +48,12 @@ const Comparison: FC<ComparisonProps> = (props) => {
return (
<section aria-label={props.title} className={classes.comparison}>
<Stack gap="md">
{props.values.map((value) => (
{props.values.map(value => (
<Stack
aria-label={value.currency}
gap="xs"
key={value.currency}
role="group"
aria-label={value.currency}
>
<div className={classes.comparisonGrid}>
<ComparisonValues
@@ -63,17 +69,17 @@ const Comparison: FC<ComparisonProps> = (props) => {
<ComparisonValues
kind="difference"
label={t("income.difference")}
values={value.difference}
percentages={value.percentages}
values={value.difference}
/>
</div>
</Stack>
))}
{props.values.length === 0 ? (
<Text size="sm" c="gray.7">
{props.values.length === 0 && ((
<Text c="gray.7" size="sm">
{t("income.comparisonEmpty")}
</Text>
) : null}
))}
</Stack>
</section>
);
@@ -87,30 +93,28 @@ const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
className={classes.comparisonPanel}
data-kind={props.kind}
data-tone={
props.kind === "difference"
? differenceTone(props.values.total.value)
: undefined
(props.kind === "difference") ? (differenceTone(props.values.total.value)) : undefined
}
>
<Text size="sm" fw={600} className={classes.comparisonLabel}>
<Text className={classes.comparisonLabel} fw={600} size="sm">
{props.label}
</Text>
<dl className={classes.comparisonValues}>
{(["total", "coupon", "dividend"] as const).map((key) => {
const money = props.values[key];
const percentage = props.percentages?.[key];
const tone =
props.kind === "difference"
const money = /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ props.values[key];
const percentage = /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ props.percentages?.[key];
const tone
= props.kind === "difference"
? differenceTone(money.value)
: undefined;
return (
<div
key={key}
className={
key === "total"
? classes.comparisonTotal
: classes.comparisonDetail
}
key={key}
>
<dt>{t(`income.${key}`)}</dt>
<dd data-tone={tone}>
@@ -118,11 +122,11 @@ const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
{tone === "positive" ? "+" : ""}
{formatMoney(money)}
</span>
{percentage ? (
{!!(percentage) && ((
<span className={classes.comparisonPercent}>
{percentage}
</span>
) : null}
))}
</dd>
</div>
);
@@ -132,7 +136,7 @@ const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
);
};
function differenceTone(value: string): "positive" | "negative" | "neutral" {
function differenceTone(value: string): "negative" | "neutral" | "positive" {
if (!/[1-9]/.test(value)) return "neutral";
return value.startsWith("-") ? "negative" : "positive";
}
@@ -1,19 +1,23 @@
import { Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { LedgerEntryRow } from "@/entities/broker-operation";
import classes from "../AccountIncomePage.module.css";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
import classes from "../AccountIncomePage.module.css";
type IncomePaymentsProps = { entries: LoadedProps["display"]["entries"] };
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
const IncomePayments: FC<IncomePaymentsProps> = (props) => {
const { t } = useTranslation();
return (
<Stack component="section" aria-label={t("income.payments")}>
<Stack aria-label={t("income.payments")} component="section">
<Title order={2}>{t("income.payments")}</Title>
<Stack component="ul" gap="sm" className={classes.entries}>
<Stack className={classes.entries} component="ul" gap="sm">
{props.entries.map(({ key, operation }) => (
<li key={key}>
<LedgerEntryRow operation={operation} />
@@ -1,12 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { FC } from "react";
import { FormProvider, useForm, useFormState } from "react-hook-form";
import type { IncomeForm } from "../../../../model/period";
import { IncomePeriodForm } from "./IncomePeriodForm.view";
const period = { from: "01.08.2026", to: "27.08.2026" };
const errors = {
to: { type: "manual", message: "Синтетическая ошибка периода" },
to: { message: "Синтетическая ошибка периода", type: "manual" },
};
type PeriodExampleProps = { invalid?: boolean };
@@ -17,14 +20,14 @@ const PeriodExample: FC<PeriodExampleProps> = (props) => {
errors: props.invalid ? errors : undefined,
});
const { isSubmitSuccessful } = useFormState({ control: methods.control });
const submitted = isSubmitSuccessful ? methods.getValues() : null;
const submitted = isSubmitSuccessful ? methods.getValues() : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null;
return (
<FormProvider {...methods}>
<IncomePeriodForm
onSubmit={methods.handleSubmit(() => undefined)}
onReset={() => {
methods.reset(period);
}}
onSubmit={methods.handleSubmit(() => {})}
/>
<output aria-live="polite">
{submitted
@@ -36,9 +39,7 @@ const PeriodExample: FC<PeriodExampleProps> = (props) => {
};
const meta = {
title: "Product/IncomePeriodForm",
component: PeriodExample,
tags: ["autodocs"],
parameters: {
docs: {
description: {
@@ -48,6 +49,8 @@ const meta = {
},
},
render: () => <PeriodExample />,
tags: ["autodocs"],
title: "Product/IncomePeriodForm",
} satisfies Meta<typeof PeriodExample>;
export default meta;
@@ -1,8 +1,11 @@
import type { FC, SubmitEventHandler } from "react";
import { Button, Paper, Stack, Text } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import type { FC, FormEventHandler } from "react";
import { useState } from "react";
import { useController, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
formatDraftDate,
type IncomeForm,
@@ -12,8 +15,8 @@ import {
import classes from "../../AccountIncomePage.module.css";
type IncomePeriodFormProps = {
onSubmit: FormEventHandler<HTMLFormElement>;
onReset: () => void;
onSubmit: SubmitEventHandler<HTMLFormElement>;
};
const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
@@ -23,15 +26,15 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
formState: { isSubmitting },
} = useFormContext<IncomeForm>();
const {
field: { ref: fromRef, onChange: onFromChange, onBlur: onFromBlur },
field: { onBlur: onFromBlur, onChange: onFromChange, ref: fromRef },
fieldState: { error: fromError },
} = useController({ control, name: "from" });
const {
field: { onChange: onToChange, onBlur: onToBlur },
field: { onBlur: onToBlur, onChange: onToChange },
fieldState: { error: toError },
} = useController({ control, name: "to" });
const [from, to] = useWatch({ control, name: ["from", "to"] });
const today = moscowDate(new Date());
const [today, setToday] = useState(() => moscowDate(new Date()));
const start = parseDraftDate(from);
const end = parseDraftDate(to);
return (
@@ -46,39 +49,40 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
<Text fw={700}>{t("income.period")}</Text>
<div className={classes.fields}>
<DatePickerInput
type="range"
allowSingleDateInRange
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
ref={fromRef}
disabled={isSubmitting}
error={fromError?.message ?? toError?.message}
label={t("income.period")}
valueFormat="DD.MM.YYYY"
value={[start, end]}
onChange={([from, to]) => {
onFromChange(from ? formatDraftDate(from) : "");
onToChange(to ? formatDraftDate(to) : "");
}}
maxDate={today}
onBlur={() => {
onFromBlur();
onToBlur();
}}
maxDate={today}
error={fromError?.message ?? toError?.message}
disabled={isSubmitting}
popoverProps={{
returnFocus: true,
middlewares: { shift: { crossAxis: true } },
onChange={([from, to]) => {
onFromChange(from ? formatDraftDate(from) : "");
onToChange(to ? formatDraftDate(to) : "");
}}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
onOpen: () => setToday(moscowDate(new Date())),
returnFocus: true,
}}
ref={fromRef}
type="range"
value={[start, end]}
valueFormat="DD.MM.YYYY"
/>
</div>
<div className={classes.actions}>
<Button type="submit" loading={isSubmitting}>
<Button loading={isSubmitting} type="submit">
{t("income.apply")}
</Button>
<Button
type="button"
onClick={props.onReset}
disabled={isSubmitting}
onClick={props.onReset}
type="button"
variant="default"
>
{t("income.reset")}
@@ -1,7 +1,10 @@
import { Paper, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { Paper, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { IncomeAnalytics } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
type IncomeSummaryProps = { totals: IncomeAnalytics["totalsByCurrency"] };
@@ -11,12 +14,21 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
return (
<Stack>
<Title order={2}>{t("income.summaryTitle")}</Title>
{props.totals.map((total) => (
{props.totals.map(total => (
<Paper key={total.currency} p="md" radius="md" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
<Text c="dimmed" size="sm">
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(total.dividend)}
{t("income.coupon")}
:
{" "}
{formatMoney(total.coupon)}
{" "}
·
{" "}
{t("income.dividend")}
:
{" "}
{formatMoney(total.dividend)}
</Text>
</Paper>
))}
@@ -1,8 +1,9 @@
import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC } from "react";
import { http, HttpResponse } from "msw";
import {
MemoryRouter,
Route,
@@ -11,6 +12,7 @@ import {
useNavigate,
} from "react-router";
import { describe, expect, it, vi } from "vitest";
import {
type BrokerageOperation,
LedgerEntryRow,
@@ -19,25 +21,28 @@ import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountOperationsPage } from "./AccountOperationsPage";
const operation: BrokerageOperation = {
id: "operation-1",
occurredAt: "2026-08-14T10:00:00Z",
type: "OPERATION_TYPE_BUY",
category: "trade",
description: "Покупка акций",
figi: null,
instrumentUid: null,
ticker: "SBER",
instrumentType: "share",
logoName: null,
payment: null,
quantity: "2",
price: { currency: "RUB", value: "100" },
accruedInterest: null,
commission: { currency: "RUB", value: "1" },
};
const operation: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accruedInterest: null,
category: "trade",
commission: { currency: "RUB", value: "1" },
description: "Покупка акций",
figi: null,
id: "operation-1",
instrumentType: "share",
instrumentUid: null,
logoName: null,
occurredAt: "2026-08-14T10:00:00Z",
payment: null,
price: { currency: "RUB", value: "100" },
quantity: "2",
ticker: "SBER",
type: "OPERATION_TYPE_BUY",
}
/* eslint-enable unicorn/no-null */);
const Navigation: FC = () => {
const location = useLocation();
@@ -45,10 +50,10 @@ const Navigation: FC = () => {
return (
<>
<output aria-label="URL">{location.search}</output>
<button type="button" onClick={() => navigate(-1)}>
<button onClick={() => navigate(-1)} type="button">
History back
</button>
<button type="button" onClick={() => navigate(1)}>
<button onClick={() => navigate(1)} type="button">
History forward
</button>
</>
@@ -58,8 +63,8 @@ const Navigation: FC = () => {
function configureTestAuth() {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
}
@@ -72,8 +77,8 @@ function renderPage(search = "") {
>
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
path="/accounts/:accountId/operations"
/>
</Routes>
</MemoryRouter>
@@ -95,9 +100,9 @@ describe("LedgerEntryRow", () => {
expect(screen.getByText("Без движения денег")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Показать детали" }),
).toBeNull();
expect(screen.queryByText("Цена: 100 ₽")).toBeNull();
expect(screen.queryByText("Количество: 2")).toBeNull();
).not.toBeInTheDocument();
expect(screen.queryByText("Цена: 100 ₽")).not.toBeInTheDocument();
expect(screen.queryByText("Количество: 2")).not.toBeInTheDocument();
});
it("labels a positive payment with its category type", () => {
@@ -125,9 +130,9 @@ describe("LedgerEntryRow", () => {
<LedgerEntryRow
operation={{
...operation,
type: "OPERATION_TYPE_COUPON",
category: "income",
payment: { currency: "RUB", value: "8" },
type: "OPERATION_TYPE_COUPON",
}}
/>
</MemoryRouter>
@@ -145,9 +150,9 @@ describe("LedgerEntryRow", () => {
<LedgerEntryRow
operation={{
...operation,
type: "OPERATION_TYPE_DIVIDEND",
category: "income",
payment: { currency: "RUB", value: "20" },
type: "OPERATION_TYPE_DIVIDEND",
}}
/>
</MemoryRouter>
@@ -165,9 +170,9 @@ describe("LedgerEntryRow", () => {
<LedgerEntryRow
operation={{
...operation,
type: "OPERATION_TYPE_SERVICE_FEE",
category: "fee",
payment: { currency: "RUB", value: "-3" },
type: "OPERATION_TYPE_SERVICE_FEE",
}}
/>
</MemoryRouter>
@@ -193,7 +198,7 @@ describe("LedgerEntryRow", () => {
);
expect(screen.getByText("Без движения денег")).toBeInTheDocument();
expect(screen.queryByText("Сделка")).toBeNull();
expect(screen.queryByText("Сделка")).not.toBeInTheDocument();
});
it("uses an instrument initial when the ledger entry has no logo", () => {
@@ -222,9 +227,9 @@ describe("AccountOperationsPage", () => {
searches.push(new URL(request.url).searchParams);
return HttpResponse.json(
resource({
hasNext: true,
items: [operation],
nextCursor: "next",
hasNext: true,
}),
);
},
@@ -234,20 +239,20 @@ describe("AccountOperationsPage", () => {
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialIndex={0}
initialEntries={[
"?from=bad&to=2026-02-28&categories=trade",
"?from=2026-02-30&categories=trade",
"?from=2026-03-01&to=2026-02-28&categories=trade",
"?from=2026-02-01&categories=trade",
"?to=2026-02-28&categories=trade",
].map((search) => `/accounts/${accountId}/operations${search}`)}
].map(search => `/accounts/${accountId}/operations${search}`)}
initialIndex={0}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
path="/accounts/:accountId/operations"
/>
</Routes>
</MemoryRouter>
@@ -266,6 +271,7 @@ describe("AccountOperationsPage", () => {
screen.getByRole("button", { name: "History forward" }),
);
await waitFor(() =>
// eslint-disable-next-line jest-dom/prefer-to-have-text-content -- URL history requires exact equality, including empty search.
expect(screen.getByLabelText("URL").textContent).toBe(expected),
);
await screen.findByLabelText("Период");
@@ -287,11 +293,11 @@ describe("AccountOperationsPage", () => {
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2026"),
);
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
expect(searches.map((query) => query.get("from"))).not.toContain(
expect(searches.map(query => query.get("from"))).not.toContain(
"badT00:00:00.000Z",
);
expect(
searches.every((query) => query.get("categories") === "trade"),
searches.every(query => query.get("categories") === "trade"),
).toBe(true);
},
);
@@ -307,7 +313,9 @@ describe("AccountOperationsPage", () => {
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(
resource({ items: [], hasNext: false, nextCursor: null }),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ hasNext: false, items: [], nextCursor: null }
/* eslint-enable unicorn/no-null */)),
),
),
);
@@ -322,8 +330,8 @@ describe("AccountOperationsPage", () => {
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
path="/accounts/:accountId/operations"
/>
</Routes>
</MemoryRouter>
@@ -333,7 +341,7 @@ describe("AccountOperationsPage", () => {
await screen.findByRole("button", { name: "Повторить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL").textContent).toBe("");
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
},
);
@@ -347,13 +355,15 @@ describe("AccountOperationsPage", () => {
const url = new URL(request.url);
searches.push(url.search);
return HttpResponse.json(
resource({
accountId: "account-1",
items: [operation],
nextCursor: url.searchParams.get("cursor") ? null : "next-1",
hasNext: !url.searchParams.get("cursor"),
observedAt: "2026-08-14T00:00:00Z",
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accountId: "account-1",
hasNext: !url.searchParams.get("cursor"),
items: [operation],
nextCursor: url.searchParams.get("cursor") ? null : "next-1",
observedAt: "2026-08-14T00:00:00Z",
}
/* eslint-enable unicorn/no-null */)),
);
},
),
@@ -385,13 +395,15 @@ describe("AccountOperationsPage", () => {
const url = new URL(request.url);
searches.push(url.search);
return HttpResponse.json(
resource({
accountId: "account-1",
items: [operation],
nextCursor: null,
hasNext: false,
observedAt: "2026-08-14T00:00:00Z",
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accountId: "account-1",
hasNext: false,
items: [operation],
nextCursor: null,
observedAt: "2026-08-14T00:00:00Z",
}
/* eslint-enable unicorn/no-null */)),
);
},
),
@@ -405,9 +417,9 @@ describe("AccountOperationsPage", () => {
const countBefore = searches.length;
fireEvent.click(screen.getByRole("combobox", { name: "Категории" }));
fireEvent.click(await screen.findByText("Налог"));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(searches.length).toBe(countBefore);
expect(searches.some((s) => s.includes("categories=tax"))).toBe(false);
await new Promise(resolve => setTimeout(resolve, 100));
expect(searches).toHaveLength(countBefore);
expect(searches.some(s => s.includes("categories=tax"))).toBe(false);
});
it("reset clears filters and refetches unfiltered", async () => {
@@ -420,13 +432,15 @@ describe("AccountOperationsPage", () => {
const url = new URL(request.url);
searches.push(url.search);
return HttpResponse.json(
resource({
accountId: "account-1",
items: [operation],
nextCursor: null,
hasNext: false,
observedAt: "2026-08-14T00:00:00Z",
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accountId: "account-1",
hasNext: false,
items: [operation],
nextCursor: null,
observedAt: "2026-08-14T00:00:00Z",
}
/* eslint-enable unicorn/no-null */)),
);
},
),
@@ -440,7 +454,7 @@ describe("AccountOperationsPage", () => {
fireEvent.click(await screen.findByText("Налог"));
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
await waitFor(() =>
expect(searches.some((s) => s.includes("categories=tax"))).toBe(true),
expect(searches.some(s => s.includes("categories=tax"))).toBe(true),
);
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await waitFor(() => expect(searches.at(-1)).not.toContain("categories="));
@@ -471,9 +485,11 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
({ request }) => {
requested.push(Object.fromEntries(new URL(request.url).searchParams));
return HttpResponse.json(
resource(null, {
sync: { id: null, state: "failed", errorCode: "TBANK_UNAVAILABLE" },
}),
resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
sync: { errorCode: "TBANK_UNAVAILABLE", id: null, state: "failed" },
}
/* eslint-enable unicorn/no-null */)),
);
},
),
@@ -486,10 +502,10 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(requested).toEqual([
{
from: "2024-02-29T00:00:00.000Z",
to: "2024-03-01T23:59:59.999Z",
categories: "trade",
from: "2024-02-29T00:00:00.000Z",
limit: "100",
to: "2024-03-01T23:59:59.999Z",
},
]);
expect(syncRequests).toEqual([]);
@@ -497,12 +513,12 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
await waitFor(() =>
expect(syncRequests).toEqual([
{
screen: "operations",
accountId: "account-1",
from: "2024-02-29T00:00:00.000Z",
to: "2024-03-01T23:59:59.999Z",
categories: ["trade"],
from: "2024-02-29T00:00:00.000Z",
limit: 100,
screen: "operations",
to: "2024-03-01T23:59:59.999Z",
},
]),
);
@@ -514,12 +530,14 @@ it("does not label incomplete empty operations as a confirmed empty period", asy
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(
resource(
{
accountId: "account-1",
items: [],
hasNext: false,
nextCursor: null,
},
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accountId: "account-1",
hasNext: false,
items: [],
nextCursor: null,
}
/* eslint-enable unicorn/no-null */),
{ availability: "partial" },
),
),
@@ -537,17 +555,17 @@ it("recovers an invalid cursor at the first page without clearing applied filter
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
const params = new URL(request.url).searchParams;
searches.push(params);
if (params.has("cursor"))
const parameters = new URL(request.url).searchParams;
searches.push(parameters);
if (parameters.has("cursor"))
return HttpResponse.json(
{ code: "INVALID_OPERATIONS_CURSOR" },
{ status: 400 },
);
return HttpResponse.json(
resource({
items: [operation],
hasNext: true,
items: [operation],
nextCursor: "old-cursor",
}),
);
@@ -557,7 +575,7 @@ it("recovers an invalid cursor at the first page without clearing applied filter
renderPage("?from=2026-08-01&to=2026-08-14&categories=trade");
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
await waitFor(() => expect(searches.length).toBe(3));
await waitFor(() => expect(searches).toHaveLength(3));
expect(searches[2].get("cursor")).toBeNull();
for (const key of ["from", "to", "categories"])
expect(searches[2].get(key)).toBe(searches[0].get(key));
@@ -1,4 +1,5 @@
import type { FC } from "react";
import { useAccountOperationsPageVm } from "./AccountOperationsPage.vm";
import { AccountOperationsPageError } from "./components/AccountOperationsPageError.view";
import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view";
@@ -13,11 +14,7 @@ const AccountOperationsPage: FC<AccountOperationsPageProps> = (props) => {
return <AccountOperationsPageLoading />;
}
if (viewModel.state === "error") {
return <AccountOperationsPageError {...viewModel} />;
}
return <AccountOperationsPageLoaded {...viewModel} />;
return viewModel.state === "error" ? <AccountOperationsPageError {...viewModel} /> : <AccountOperationsPageLoaded {...viewModel} />;
};
export { AccountOperationsPage };
@@ -2,44 +2,44 @@ import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import {
type BrokerageOperation,
brokerageOperationsQueryOptions,
type OperationCategory,
operationCategories,
type OperationCategory,
useBrokerageOperations,
} from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
type AccountOperationsPageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void; onReset: () => void }
| {
state: "loaded";
sourceError?: boolean;
lastSuccessfulAt?: string | null;
partial?: boolean;
historyLoading?: boolean;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
draftFrom: string | null;
draftTo: string | null;
draftCategories: OperationCategory[];
items: BrokerageOperation[];
hasNext: boolean;
hasPrevious: boolean;
canApply: boolean;
onDraftFromChange: (value: string | null) => void;
onDraftToChange: (value: string | null) => void;
onDraftCategoriesChange: (value: OperationCategory[]) => void;
onApply: () => void;
onReset: () => void;
onNext: () => void;
onPrevious: () => void;
};
type AccountOperationsPageViewModel
= | {
canApply: boolean;
draftCategories: OperationCategory[];
draftFrom: null | string;
draftTo: null | string;
hasNext: boolean;
hasPrevious: boolean;
historyLoading?: boolean;
items: BrokerageOperation[];
lastSuccessfulAt?: null | string;
onApply: () => void;
onDraftCategoriesChange: (value: OperationCategory[]) => void;
onDraftFromChange: (value: null | string) => void;
onDraftToChange: (value: null | string) => void;
onNext: () => void;
onPrevious: () => void;
onReset: () => void;
partial?: boolean;
refreshError?: boolean;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
state: "loaded";
}
| { onReset: () => void; retry: () => void; state: "error" }
| { state: "loading" };
const useAccountOperationsPageVm = (
scopeId?: string,
@@ -47,19 +47,19 @@ const useAccountOperationsPageVm = (
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const urlFrom = searchParams.get("from");
const urlTo = searchParams.get("to");
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = validUrlPeriod ? urlFrom : null;
const appliedTo = validUrlPeriod ? urlTo : null;
const appliedCategories = operationCategories.filter((category) =>
(searchParams.get("categories") ?? "").split(",").includes(category),
const [searchParameters, setSearchParameters] = useSearchParams();
const urlFrom = searchParameters.get("from");
const urlTo = searchParameters.get("to");
const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = isValidUrlPeriod ? urlFrom : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const appliedTo = isValidUrlPeriod ? urlTo : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const appliedCategories = operationCategories.filter(category =>
(searchParameters.get("categories") ?? "").split(",").includes(category),
);
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
const [draftCategories, setDraftCategories] =
useState<OperationCategory[]>(appliedCategories);
const [draftFrom, setDraftFrom] = useState<null | string>(appliedFrom);
const [draftTo, setDraftTo] = useState<null | string>(appliedTo);
const [draftCategories, setDraftCategories]
= useState<OperationCategory[]>(appliedCategories);
const scopeKey = JSON.stringify([
accountId,
urlFrom,
@@ -67,147 +67,146 @@ const useAccountOperationsPageVm = (
appliedCategories,
]);
const [pagination, setPagination] = useState<{
scopeKey: string;
cursors: string[];
}>({ scopeKey, cursors: [] });
scopeKey: string;
}>({ cursors: [], scopeKey });
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
const cursor = cursors.at(-1);
if (pagination.scopeKey !== scopeKey) {
setPagination({ scopeKey, cursors: [] });
setPagination({ cursors: [], scopeKey });
}
useEffect(() => {
if (validUrlPeriod) return;
const normalized = new URLSearchParams(searchParams);
if (isValidUrlPeriod) return;
const normalized = new URLSearchParams(searchParameters);
normalized.delete("from");
normalized.delete("to");
setSearchParams(normalized, { replace: true });
}, [validUrlPeriod, searchParams, setSearchParams]);
const [previousSearch, setPreviousSearch] = useState(searchParams);
if (previousSearch !== searchParams) {
setPreviousSearch(searchParams);
setSearchParameters(normalized, { replace: true });
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
const [previousSearch, setPreviousSearch] = useState(searchParameters);
if (previousSearch !== searchParameters) {
setPreviousSearch(searchParameters);
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
setDraftCategories(
operationCategories.filter((category) =>
(searchParams.get("categories") ?? "").split(",").includes(category),
operationCategories.filter(category =>
(searchParameters.get("categories") ?? "").split(",").includes(category),
),
);
}
const firstPageQuery = {
from: dateStart(appliedFrom),
to: dateEnd(appliedTo),
...(appliedCategories.length ? { categories: appliedCategories } : {}),
...((appliedCategories.length > 0) && { categories: appliedCategories }),
limit: 100,
};
const operations = useBrokerageOperations(accountId, {
...firstPageQuery,
...(cursor ? { cursor } : {}),
...(cursor && { cursor }),
});
const sync = useScreenSync(
{ screen: "operations", accountId, ...firstPageQuery },
{ accountId, screen: "operations", ...firstPageQuery },
[operations],
);
if (operations.isLoading) return { state: "loading" };
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
limit: 100,
}).queryKey;
const resetCursors = () => setPagination({ scopeKey, cursors: [] });
const resetCursors = () => setPagination({ cursors: [], scopeKey });
const retry = () => {
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
void queryClient.invalidateQueries({
exact: true,
queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery)
.queryKey,
exact: true,
});
resetCursors();
} else void sync.retry();
}
else void sync.retry();
};
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setDraftCategories([]);
setSearchParams({});
setSearchParameters({});
resetCursors();
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
};
if (operations.isLoading) return { state: "loading" };
if (
(operations.isError || operations.meta?.sync.state === "failed") &&
(!operations.data ||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
(operations.isError || operations.meta?.sync.state === "failed")
&& (!operations.data
|| apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
)
return { state: "error", retry, onReset };
return { onReset, retry, state: "error" };
if (!operations.data) return { state: "loading" };
const nextCursor = operations.data.nextCursor;
const isDirty =
draftFrom !== appliedFrom ||
draftTo !== appliedTo ||
!sameCategories(draftCategories, appliedCategories);
const isDirty
= draftFrom !== appliedFrom
|| draftTo !== appliedTo
|| !isSameCategories(draftCategories, appliedCategories);
return {
state: "loaded",
sourceError: sync.sourceError,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
partial: operations.meta?.availability !== "complete",
historyLoading:
operations.meta?.coverage?.historyState === "loading" ||
operations.meta?.coverage?.historyState === "partial",
refreshing: operations.isFetching || sync.refreshing,
refreshError: operations.isError || sync.sourceError || sync.transportError,
retry,
canApply: isDirty && isValidPeriod(draftFrom, draftTo),
draftCategories,
draftFrom,
draftTo,
draftCategories,
hasNext: Boolean(nextCursor && operations.data.hasNext),
hasPrevious: cursors.length > 0,
historyLoading:
operations.meta?.coverage?.historyState === "loading"
|| operations.meta?.coverage?.historyState === "partial",
items: operations.data.items,
hasNext: Boolean(operations.data.hasNext && nextCursor),
hasPrevious: Boolean(cursors.length),
canApply: isDirty && isValidPeriod(draftFrom, draftTo),
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onDraftCategoriesChange: setDraftCategories,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParams({
...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}),
...(draftCategories.length
? { categories: draftCategories.join(",") }
: {}),
setSearchParameters({
...(draftFrom && { from: draftFrom }),
...(draftTo && { to: draftTo }),
...((draftCategories.length > 0) && { categories: draftCategories.join(",") }),
});
resetCursors();
},
onReset,
onDraftCategoriesChange: setDraftCategories,
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onNext: () => {
if (nextCursor)
setPagination({ scopeKey, cursors: [...cursors, nextCursor] });
setPagination({ cursors: [...cursors, nextCursor], scopeKey });
},
onPrevious: () =>
setPagination({ scopeKey, cursors: cursors.slice(0, -1) }),
setPagination({ cursors: cursors.slice(0, -1), scopeKey }),
onReset,
partial: operations.meta?.availability !== "complete",
refreshError: operations.isError || sync.sourceError || sync.transportError,
refreshing: operations.isFetching || sync.refreshing,
retry,
sourceError: sync.sourceError,
state: "loaded",
};
};
const calendarDate = z.string().date();
function isValidPeriod(from: string | null, to: string | null) {
return (
[from, to].every(
(date) => date === null || calendarDate.safeParse(date).success,
) &&
(!from || !to || from <= to)
);
}
function dateStart(value: string | null) {
return value ? `${value}T00:00:00.000Z` : undefined;
}
function dateEnd(value: string | null) {
const calendarDate = z.iso.date();
function dateEnd(value: null | string) {
return value ? `${value}T23:59:59.999Z` : undefined;
}
function sameCategories(a: OperationCategory[], b: OperationCategory[]) {
return a.length === b.length && a.every((value) => b.includes(value));
function dateStart(value: null | string) {
return value ? `${value}T00:00:00.000Z` : undefined;
}
function isSameCategories(a: OperationCategory[], b: OperationCategory[]) {
return a.length === b.length && a.every(value => b.includes(value));
}
function isValidPeriod(from: null | string, to: null | string) {
return (
[from, to].every(
date => date === null || calendarDate.safeParse(date).success,
)
&& (!from || !to || from <= to)
);
}
export { type AccountOperationsPageViewModel, useAccountOperationsPageVm };
@@ -1,7 +1,9 @@
import type { FC } from "react";
import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageErrorProps = Extract<
@@ -17,17 +19,17 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
return (
<StateView
error
title={t("ledger.errorTitle")}
text={t("ledger.errorText")}
action={
action={(
<Group>
<Button onClick={retry}>{t("ledger.retry")}</Button>
<Button variant="default" onClick={props.onReset}>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
}
)}
error
text={t("ledger.errorText")}
title={t("ledger.errorTitle")}
/>
);
};
@@ -1,3 +1,5 @@
import type { FC, ReactNode } from "react";
import {
Alert,
Button,
@@ -10,14 +12,15 @@ import {
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
LedgerEntryRow,
type OperationCategory,
operationCategories,
type OperationCategory,
} from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageLoadedProps = Extract<
@@ -54,99 +57,106 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
} = props;
const { t } = useTranslation();
const categoryData = operationCategories.map((category) => ({
value: category,
const categoryData = operationCategories.map(category => ({
label: t(`ledger.category.${category}`),
value: category,
}));
// eslint-disable-next-line sonarjs/function-return-type -- ReactNode deliberately includes lists, elements and empty output.
function renderOperations(): ReactNode {
if (items.length > 0) {
return items.map(operation => (
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
));
}
if (props.partial) {
return;
}
return (
<StateView
text={t("ledger.emptyText")}
title={t("ledger.emptyTitle")}
/>
);
}
return (
<Stack gap="lg">
{props.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{!!(props.lastSuccessfulAt) && ((
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
) : null}
))}
<Title order={1}>{t("ledger.title")}</Title>
<Button onClick={props.retry} disabled={props.refreshing} w="fit-content">
<Button disabled={props.refreshing} onClick={props.retry} w="fit-content">
{t("investment.refresh")}
</Button>
{props.partial ? (
{!!(props.partial) && ((
<Alert role="status">{t("investment.partial")}</Alert>
) : null}
{props.historyLoading ? <Alert>{t("investment.history")}</Alert> : null}
{props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null}
{props.refreshError ? (
<Alert color="yellow" role="status" aria-live="polite">
))}
{!!(props.historyLoading) && (<Alert>{t("investment.history")}</Alert>)}
{!!(props.refreshing) && ((
<Progress animated aria-label={t("accounts.refreshing")} value={100} />
))}
{!!(props.refreshError) && ((
<Alert aria-live="polite" color="yellow" role="status">
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button variant="subtle" onClick={props.retry}>
<Button onClick={props.retry} variant="subtle">
{t("accounts.retry")}
</Button>
</Alert>
) : null}
))}
<Group align="end">
<DatePickerInput
type="range"
allowSingleDateInRange
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
value={[draftFrom, draftTo]}
onChange={([from, to]) => {
onDraftFromChange(from);
onDraftToChange(to);
}}
valueFormat="DD.MM.YYYY"
popoverProps={{
returnFocus: true,
middlewares: { shift: { crossAxis: true } },
returnFocus: true,
}}
type="range"
value={[draftFrom, draftTo]}
valueFormat="DD.MM.YYYY"
/>
</Group>
<MultiSelect
label={t("ledger.categories")}
placeholder={t("ledger.categoriesPlaceholder")}
data={categoryData}
value={draftCategories}
onChange={(value) =>
onDraftCategoriesChange(fromMultiSelectValue(value))
}
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
data={categoryData}
label={t("ledger.categories")}
onChange={value =>
onDraftCategoriesChange(fromMultiSelectValue(value))}
placeholder={t("ledger.categoriesPlaceholder")}
value={draftCategories}
/>
<Group>
<Button onClick={onApply} disabled={!canApply}>
<Button disabled={!canApply} onClick={onApply}>
{t("ledger.apply")}
</Button>
<Button variant="default" onClick={onReset}>
<Button onClick={onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
{items.length ? (
items.map((operation) => (
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
))
) : !props.partial ? (
<StateView
title={t("ledger.emptyTitle")}
text={t("ledger.emptyText")}
/>
) : null}
{hasNext || hasPrevious ? (
{renderOperations()}
{!!(hasNext || hasPrevious) && ((
<Group justify="space-between">
<Button disabled={!hasPrevious} onClick={onPrevious}>
{t("ledger.previous")}
@@ -155,7 +165,7 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
{t("ledger.next")}
</Button>
</Group>
) : null}
))}
</Stack>
);
};
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { Group, Skeleton, Stack } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
const skeletonRowIds = ["first", "second", "third", "fourth"];
@@ -17,12 +18,12 @@ const AccountOperationsPageLoading: FC = () => {
</Group>
<Skeleton height={48} radius="sm" />
<Group>
<Skeleton height={36} width={110} radius="sm" />
<Skeleton height={36} width={110} radius="sm" />
<Skeleton height={36} radius="sm" width={110} />
<Skeleton height={36} radius="sm" width={110} />
</Group>
<Stack gap="xs">
{skeletonRowIds.map((id) => (
<Skeleton key={id} height={64} radius="md" />
{skeletonRowIds.map(id => (
<Skeleton height={64} key={id} radius="md" />
))}
</Stack>
</LoadingSkeleton>
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import type { FC } from "react";
import { useAccountPageViewModel } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view";
@@ -16,29 +17,29 @@ const AccountPage: FC<AccountPageProps> = (props) => {
<AccountPageEmpty
account={viewModel.account}
accountId={viewModel.accountId}
refreshing={viewModel.refreshing}
sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError}
retry={viewModel.retry}
recentOperations={viewModel.recentOperations}
recentEvents={viewModel.recentEvents}
incomeSummary={viewModel.incomeSummary}
recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations}
refreshError={viewModel.refreshError}
refreshing={viewModel.refreshing}
retry={viewModel.retry}
sourceError={viewModel.sourceError}
/>
);
return (
<AccountPageLoaded
account={viewModel.account}
accountId={viewModel.accountId}
incomeSummary={viewModel.incomeSummary}
partial={viewModel.partial}
portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections}
refreshing={viewModel.refreshing}
sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError}
retry={viewModel.retry}
recentOperations={viewModel.recentOperations}
recentEvents={viewModel.recentEvents}
incomeSummary={viewModel.incomeSummary}
recentOperations={viewModel.recentOperations}
refreshError={viewModel.refreshError}
refreshing={viewModel.refreshing}
retry={viewModel.retry}
sourceError={viewModel.sourceError}
/>
);
};
@@ -1,81 +1,87 @@
import type { FC, PropsWithChildren } from "react";
import { MantineProvider } from "@mantine/core";
import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
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,
useAccountPageViewModel,
} from "./AccountPage.vm";
const portfolio: Portfolio = {
lastSuccessfulAt: "2026-08-13T10:00:00Z",
isStale: true,
totalsByCurrency: [{ currency: "RUB", value: "150" }],
positions: [
{
figi: "FIGI-STOCK",
displayName: "Акция",
type: "STOCK",
quantity: "2",
currentValue: { currency: "RUB", value: "100" },
averagePurchasePrice: null,
absoluteReturn: { currency: "RUB", value: "10" },
isin: null,
ticker: null,
logoName: "akciya.png",
},
{
figi: "FIGI-BOND",
displayName: "Облигация",
type: "BOND",
quantity: "1",
currentValue: { currency: "RUB", value: "50" },
averagePurchasePrice: { currency: "RUB", value: "45" },
absoluteReturn: { currency: "RUB", value: "-5" },
isin: "RU000A0JX0J2",
ticker: null,
logoName: null,
},
{
figi: "FIGI-FUND",
displayName: "Фонд",
type: "FUND",
quantity: "3",
currentValue: null,
averagePurchasePrice: null,
absoluteReturn: null,
isin: null,
ticker: null,
logoName: "fond.png",
},
],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [{ currency: "USD", value: "1" }],
};
const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
cashBalances: [{ currency: "USD", value: "1" }],
isStale: true,
lastSuccessfulAt: "2026-08-13T10:00:00Z",
positions: [
{
absoluteReturn: { currency: "RUB", value: "10" },
averagePurchasePrice: null,
currentValue: { currency: "RUB", value: "100" },
displayName: "Акция",
figi: "FIGI-STOCK",
isin: null,
logoName: "akciya.png",
quantity: "2",
ticker: null,
type: "STOCK",
},
{
absoluteReturn: { currency: "RUB", value: "-5" },
averagePurchasePrice: { currency: "RUB", value: "45" },
currentValue: { currency: "RUB", value: "50" },
displayName: "Облигация",
figi: "FIGI-BOND",
isin: "RU000A0JX0J2",
logoName: null,
quantity: "1",
ticker: null,
type: "BOND",
},
{
absoluteReturn: null,
averagePurchasePrice: null,
currentValue: null,
displayName: "Фонд",
figi: "FIGI-FUND",
isin: null,
logoName: "fond.png",
quantity: "3",
ticker: null,
type: "FUND",
},
],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "150" }],
}
/* eslint-enable unicorn/no-null */);
function configureTestAuth() {
configureAuthClient({
accessToken: () => "test-token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
}
function createWrapper() {
const { wrapper: QueryWrapper } = createQueryWrapper();
const Wrapper: FC<PropsWithChildren> = (props) => (
const Wrapper: FC<PropsWithChildren> = props => (
<QueryWrapper>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1"]}>
<Routes>
<Route path="/accounts/:accountId" element={props.children} />
<Route element={props.children} path="/accounts/:accountId" />
</Routes>
</MemoryRouter>
</MantineProvider>
@@ -91,30 +97,32 @@ function mockLoadedAccountPage() {
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
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",
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accountId: "account-1",
hasNext: false,
items: [
{
accruedInterest: null,
category: "trade",
commission: null,
description: "Покупка акции",
figi: "FIGI-STOCK",
id: "operation-1",
instrumentType: "share",
instrumentUid: null,
occurredAt: "2026-08-13T10:00:00Z",
payment: { currency: "RUB", value: "-100" },
price: null,
quantity: "1",
ticker: "STK",
type: "Buy",
},
],
nextCursor: null,
observedAt: "2026-08-13T10:00:00Z",
}
/* eslint-enable unicorn/no-null */)),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
@@ -122,16 +130,18 @@ function mockLoadedAccountPage() {
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) },
})),
events: Array.from({ length: 6 }, (_, index) => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
dateKind: "event",
displayName: `Купон ${index}`,
figi: `FIGI-EVENT-${index}`,
instrumentUid: null,
projectedAmount: { currency: "RUB", value: String(index + 1) },
ticker: `BND${index}`,
type: "coupon",
}
/* eslint-enable unicorn/no-null */))),
}),
),
),
@@ -150,38 +160,40 @@ describe("useAccountPageViewModel", () => {
] as const;
const snapshot: Portfolio = {
...portfolio,
totalsByCurrency: [{ currency: "RUB", value: "1250" }],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [{ currency: "RUB", value: "1000" }],
positions: [
...portfolio.positions,
{
...portfolio.positions[0],
currentValue: { currency: "RUB", value: "50" },
figi: "FIGI-CURRENCY",
type: "CURRENCY",
currentValue: { currency: "RUB", value: "50" },
},
...otherTypes.map((type) => ({
...portfolio.positions[2],
figi: `FIGI-${type}-NEXT`,
type,
currentValue:
...otherTypes.map(type => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...portfolio.positions[2],
currentValue:
type === "OPTION" ? { currency: "RUB", value: "50" } : null,
})),
figi: `FIGI-${type}-NEXT`,
type,
}
/* eslint-enable unicorn/no-null */))),
],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1250" }],
};
const original = structuredClone(snapshot);
const sections = toAccountPagePositionSections(snapshot);
expect(sections.map((section) => section.kind)).toEqual([
expect(sections.map(section => section.kind)).toEqual([
"stocks",
"bonds",
"other",
]);
expect(sections[2].groups.map((group) => group.positions[0].figi)).toEqual([
expect(sections[2].groups.map(group => group.positions[0].figi)).toEqual([
"FIGI-FUND",
...otherTypes.map((type) => `FIGI-${type}-NEXT`),
...otherTypes.map(type => `FIGI-${type}-NEXT`),
]);
expect(sections[0].groups[0].positions[0].shareText).toBe("40 %");
expect(sections[1].groups[0].positions[0].shareText).toBe("20 %");
@@ -203,9 +215,9 @@ describe("useAccountPageViewModel", () => {
{ state: "loaded" }
>;
expect(
loaded.positionSections.map(({ kind, groups }) => ({
loaded.positionSections.map(({ groups, kind }) => ({
kind,
positions: groups.flatMap((group) => group.positions),
positions: groups.flatMap(group => group.positions),
})),
).toMatchObject([
{
@@ -213,37 +225,41 @@ describe("useAccountPageViewModel", () => {
positions: [
{
displayName: "Акция",
shareText: "66,7 %",
logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png",
returnText: "+10 ₽",
returnTone: "positive",
logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png",
},
],
},
{
kind: "bonds",
positions: [
{
displayName: "Облигация",
shareText: "33,3 %",
returnText: "-5 ₽",
returnTone: "negative",
logoSrc: null,
},
],
},
{
kind: "other",
positions: [
{
displayName: "Фонд",
shareText: null,
returnText: null,
returnTone: undefined,
logoSrc: null,
shareText: "66,7 %",
},
],
},
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
kind: "bonds",
positions: [
{
displayName: "Облигация",
logoSrc: null,
returnText: "-5 ₽",
returnTone: "negative",
shareText: "33,3 %",
},
],
}
/* eslint-enable unicorn/no-null */),
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
kind: "other",
positions: [
{
displayName: "Фонд",
logoSrc: null,
returnText: null,
returnTone: undefined,
shareText: null,
},
],
}
/* eslint-enable unicorn/no-null */),
]);
});
@@ -265,13 +281,11 @@ describe("useAccountPageViewModel", () => {
{ state: "loaded" }
>;
expect(loaded.recentOperations).toMatchObject({
items: [{ description: "Покупка акции", id: "operation-1" }],
state: "loaded",
to: "/accounts/account-1/operations",
items: [{ id: "operation-1", description: "Покупка акции" }],
});
expect(loaded.recentEvents).toMatchObject({
state: "loaded",
to: "/accounts/account-1/events",
items: [
{ ticker: "BND0" },
{ ticker: "BND1" },
@@ -279,6 +293,8 @@ describe("useAccountPageViewModel", () => {
{ ticker: "BND3" },
{ ticker: "BND4" },
],
state: "loaded",
to: "/accounts/account-1/events",
});
});
@@ -302,9 +318,9 @@ describe("useAccountPageViewModel", () => {
await waitFor(() =>
expect(result.current).toMatchObject({
state: "loaded",
recentOperations: { state: "error" },
recentEvents: { state: "error" },
recentOperations: { state: "error" },
state: "loaded",
}),
);
});
@@ -320,7 +336,9 @@ it("keeps overview sections independent when their source data is incomplete", a
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(
resource(
{ items: [], hasNext: false, nextCursor: null },
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ hasNext: false, items: [], nextCursor: null }
/* eslint-enable unicorn/no-null */),
{ availability: "partial" },
),
),
@@ -341,20 +359,22 @@ it("keeps overview sections independent when their source data is incomplete", a
await waitFor(() =>
expect(result.current).toMatchObject({
partial: true,
recentOperations: { state: "partial", items: [] },
recentEvents: { state: "partial", items: [] },
recentEvents: { items: [], state: "partial" },
recentOperations: { items: [], state: "partial" },
}),
);
});
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 sections: Record<string, unknown> = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
"events": { events: [], incomeTotalsByCurrency: [] },
"income-summary": { totalsByCurrency: [] },
"operations": { hasNext: false, items: [], nextCursor: null },
portfolio,
}
/* eslint-enable unicorn/no-null */);
const observed: string[] = [];
const scopes: unknown[] = [];
let version = "1";
@@ -377,25 +397,27 @@ it("refreshes every overview section through one account descriptor and observat
return HttpResponse.json(
{
id: "overview",
scope: { screen: "account", accountId: "account-1" },
now: "2026-09-25T00:00:00Z",
scope: { accountId: "account-1", screen: "account" },
},
{ 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,
),
}),
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
errorCode: null,
id: "overview",
now: "2026-09-25T00:00:00Z",
progress: { completed: 4, discovering: false, total: 4 },
resources: Object.keys(sections).map(
resourceKey =>
resource(null, { resourceKey, stateVersion: version }).meta,
),
scope: null,
state: "succeeded",
}
/* eslint-enable unicorn/no-null */)),
),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
@@ -403,10 +425,10 @@ it("refreshes every overview section through one account descriptor and observat
});
await waitFor(() =>
expect(result.current).toMatchObject({
state: "loaded",
recentOperations: { state: "empty" },
recentEvents: { state: "empty" },
incomeSummary: { state: "empty" },
recentEvents: { state: "empty" },
recentOperations: { state: "empty" },
state: "loaded",
}),
);
await act(async () => {
@@ -415,5 +437,5 @@ it("refreshes every overview section through one account descriptor and observat
await waitFor(() =>
expect(new Set(observed)).toEqual(new Set(Object.keys(sections))),
);
expect(scopes).toEqual([{ screen: "account", accountId: "account-1" }]);
expect(scopes).toEqual([{ accountId: "account-1", screen: "account" }]);
});
+287 -284
View File
@@ -1,5 +1,8 @@
import { useParams } from "react-router";
import type { BrokerageOperation } from "@/entities/broker-operation";
import type { Portfolio } from "@/entities/portfolio";
import { useBrokerageOperations } from "@/entities/broker-operation";
import {
type BrokerageAccount,
@@ -10,7 +13,6 @@ import {
useBrokerageEvents,
} from "@/entities/brokerage-event";
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";
@@ -19,15 +21,46 @@ import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
const RECENT_EVENTS_LIMIT = 5;
type PositionSectionKind = "stocks" | "bonds" | "other";
type AccountPageReturnTone = "positive" | "negative";
type AccountPageContent = {
account?: BrokerageAccount;
accountId?: string;
incomeSummary: AccountPageIncomeSummary;
partial?: boolean;
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
refreshError: boolean;
refreshing: boolean;
retry: () => Promise<unknown> | undefined;
sourceError?: boolean;
};
type AccountPageEmptyViewModel = AccountPageContent & {
state: "empty";
};
type AccountPageIncomeSummary
= | {
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "loaded";
summary: IncomeSummary;
to: string;
}
| { retry: () => Promise<unknown> | undefined; state: "error"; to: string }
| { state: "empty" | "loading" | "partial"; to: string };
type AccountPageLoadedViewModel = AccountPageContent & {
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
state: "loaded";
};
type AccountPagePosition = Portfolio["positions"][number] & {
shareText: string | null;
returnText: string | null;
returnTone?: AccountPageReturnTone;
logoSrc: string | null;
instrumentTo?: string;
logoSrc: null | string;
returnText: null | string;
returnTone?: AccountPageReturnTone;
shareText: null | string;
};
type AccountPagePositionGroup = {
@@ -36,223 +69,72 @@ type AccountPagePositionGroup = {
};
type AccountPagePositionSection = {
kind: PositionSectionKind;
groups: AccountPagePositionGroup[];
kind: PositionSectionKind;
};
type AccountPageRecentOperations =
| {
state: "loading" | "error" | "empty";
to: string;
}
| {
state: "loaded" | "partial";
to: string;
items: BrokerageOperation[];
lastSuccessfulAt?: string | null;
stale?: boolean;
refreshError?: boolean;
};
type AccountPageRecentEvents = BrokerageEventsPreviewState;
type AccountPageIncomeSummary =
| { state: "loading" | "partial" | "empty"; to: string }
| { state: "error"; to: string; retry: () => undefined | Promise<unknown> }
| {
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;
refreshError: boolean;
retry: () => undefined | Promise<unknown>;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary: AccountPageIncomeSummary;
};
type AccountPageLoadedViewModel = AccountPageContent & {
state: "loaded";
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
};
type AccountPageEmptyViewModel = AccountPageContent & {
state: "empty";
};
type AccountPageViewModel =
| { state: "loading" }
| { state: "error"; code?: string; retry: () => undefined | Promise<unknown> }
| AccountPageEmptyViewModel
| AccountPageLoadedViewModel;
function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const portfolio = usePortfolio(accountId);
const stocks = useStocks(
portfolio.data?.positions.flatMap((position) =>
position.type === "STOCK" && position.instrumentUid
? [position.instrumentUid]
: [],
) ?? [],
);
const instrumentDetails = stocks;
const operations = useBrokerageOperations(accountId, {
limit: 5,
});
const events = useBrokerageEvents(accountId);
const income = useIncomeSummary(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 || portfolio.meta?.sync.state === "failed")
return {
state: "error" as const,
code: apiErrorCode(portfolio.error),
retry: sync.retry,
};
return { state: "loading" as const };
type AccountPageRecentOperations
= | {
items: BrokerageOperation[];
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "loaded" | "partial";
to: string;
}
const refresh = {
partial: portfolio.meta?.availability === "partial",
refreshing: portfolio.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
retry: sync.retry,
};
if (
portfolio.meta?.availability === "complete" &&
!portfolio.data.positions.length &&
!portfolio.data.cashBalances.length
)
return {
state: "empty" as const,
account,
accountId,
recentOperations,
recentEvents,
incomeSummary,
...refresh,
};
return {
state: "loaded" as const,
account,
accountId,
portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(
portfolio.data,
instrumentDetails,
),
recentOperations,
recentEvents,
incomeSummary,
...refresh,
| {
state: "empty" | "error" | "loading";
to: string;
};
type AccountPageReturnTone = "negative" | "positive";
type AccountPageViewModel
= | AccountPageEmptyViewModel
| AccountPageLoadedViewModel
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
| { state: "loading" };
type PositionSectionKind = "bonds" | "other" | "stocks";
function groupPositions(
positions: AccountPagePosition[],
): AccountPagePositionGroup[] {
const groups = new Map<string, AccountPagePositionGroup>();
for (const [index, position] of positions.entries()) {
const identity = positionIdentity(position);
const rowKey = JSON.stringify(["row", position.account?.id, index]);
const key
= identity && position.account
? JSON.stringify([position.type, ...identity])
: rowKey;
const group = groups.get(key);
if (
group
&& group.positions.every(item => item.account?.id !== position.account?.id)
) {
group.positions.push(position);
}
else {
const uniqueKey = group ? rowKey : key;
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
}
}
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline.
return [...groups.values()];
}
function toIncomeSummary(
accountId: string | undefined,
income: ReturnType<typeof useIncomeSummary>,
): 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 { state: "error", to, retry: () => income.refetch() };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
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(
accountId: string | undefined,
operations: ReturnType<typeof useBrokerageOperations>,
): 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 { 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,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
stale: operations.meta?.freshness === "stale",
refreshError:
operations.isError || operations.meta?.sync.state === "failed",
};
}
function toRecentEvents(
accountId: string | undefined,
events: ReturnType<typeof useBrokerageEvents>,
): 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 {
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,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
stale: events.meta?.freshness === "stale",
refreshError: events.isError || events.meta?.sync.state === "failed",
};
function positionIdentity(position: AccountPagePosition) {
if (position.instrumentUid?.trim()) return ["uid", position.instrumentUid.trim()];
if (position.figi?.trim()) return ["figi", position.figi.trim()];
return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined;
}
function toAccountPagePositionSections(
portfolio: Portfolio,
instrumentDetails: Map<string, { slug: string } | undefined> = new Map(),
instrumentDetails: Map<string, undefined | { slug: string }> = new Map(),
): AccountPagePositionSection[] {
const denominators = new Map<string, number>();
for (const position of portfolio.positions) {
@@ -271,104 +153,225 @@ function toAccountPagePositionSections(
);
const bonds = toSection("bonds", portfolio, denominators, instrumentDetails);
const other = toSection("other", portfolio, denominators, instrumentDetails);
return [stocks, bonds, other].filter((section) => section.groups.length > 0);
return [stocks, bonds, other].filter(section => section.groups.length > 0);
}
function toSection(
kind: PositionSectionKind,
portfolio: Portfolio,
denominators: Map<string, number>,
instrumentDetails: Map<string, { slug: string } | undefined>,
): AccountPagePositionSection {
function toIncomeSummary(
accountId: string | undefined,
income: ReturnType<typeof useIncomeSummary>,
): 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 {
kind,
groups: groupPositions(
portfolio.positions
.filter((position) => {
if (kind === "stocks") return position.type === "STOCK";
if (kind === "bonds") return position.type === "BOND";
return (
position.type !== "STOCK" &&
position.type !== "BOND" &&
position.type !== "CURRENCY"
);
})
.map((position) =>
toPosition(
position,
denominators,
kind,
instrumentDetails.get(position.instrumentUid ?? "")?.slug,
),
),
),
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
refreshError: income.isError || income.meta?.sync.state === "failed",
stale: income.meta?.freshness === "stale",
state: "loaded",
summary: income.data,
to,
};
}
function groupPositions(
positions: AccountPagePosition[],
): AccountPagePositionGroup[] {
const groups = new Map<string, AccountPagePositionGroup>();
for (const [index, position] of positions.entries()) {
const identity = position.instrumentUid?.trim()
? ["uid", position.instrumentUid.trim()]
: position.figi?.trim()
? ["figi", position.figi.trim()]
: position.isin?.trim()
? ["isin", position.isin.trim()]
: null;
const rowKey = JSON.stringify(["row", position.account?.id, index]);
const key =
position.account && identity
? JSON.stringify([position.type, ...identity])
: rowKey;
const group = groups.get(key);
if (
group &&
!group.positions.some((item) => item.account?.id === position.account?.id)
) {
group.positions.push(position);
} else {
const uniqueKey = group ? rowKey : key;
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
}
}
return [...groups.values()];
}
function toPosition(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
kind: PositionSectionKind,
instrumentSlug: string | undefined,
): AccountPagePosition {
const expanded = kind !== "other";
const isExpanded = kind !== "other";
const returnValue = position.absoluteReturn
? Number(position.absoluteReturn.value)
: null;
const returnTone =
returnValue === null || returnValue === 0
? undefined
: returnValue > 0
? "positive"
: "negative";
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
let returnTone: AccountPageReturnTone | undefined;
if (returnValue !== null && returnValue !== 0) returnTone = returnValue > 0 ? "positive" : "negative";
const returnPrefix = isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
return {
...position,
instrumentTo:
(kind === "stocks" || kind === "bonds") && position.instrumentUid
? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}`
: undefined,
logoSrc: isExpanded ? instrumentLogoUrl(position.logoName) : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
returnText: position.absoluteReturn
? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
returnTone,
shareText: position.currentValue?.currency
? formatShare(position.currentValue, {
currency: position.currentValue.currency,
value: String(denominators.get(position.currentValue.currency) ?? 0),
})
: null,
returnText: position.absoluteReturn
? `${expanded && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
: null,
returnTone,
logoSrc: expanded ? instrumentLogoUrl(position.logoName) : null,
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
};
}
function toRecentEvents(
accountId: string | undefined,
events: ReturnType<typeof useBrokerageEvents>,
): 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<typeof useBrokerageOperations>,
): 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 toSection(
kind: PositionSectionKind,
portfolio: Portfolio,
denominators: Map<string, number>,
instrumentDetails: Map<string, undefined | { slug: string }>,
): AccountPagePositionSection {
return {
groups: groupPositions(
portfolio.positions
.filter((position) => {
if (kind === "stocks") return position.type === "STOCK";
return kind === "bonds"
? position.type === "BOND"
: (
position.type !== "STOCK"
&& position.type !== "BOND"
&& position.type !== "CURRENCY"
);
})
.map(position =>
toPosition(
position,
denominators,
kind,
instrumentDetails.get(position.instrumentUid ?? "")?.slug,
),
),
),
kind,
};
}
function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const portfolio = usePortfolio(accountId);
const stocks = useStocks(
portfolio.data?.positions.flatMap(position =>
position.type === "STOCK" && position.instrumentUid
? [position.instrumentUid]
: [],
) ?? [],
);
const instrumentDetails = stocks;
const operations = useBrokerageOperations(accountId, {
limit: 5,
});
const events = useBrokerageEvents(accountId);
const income = useIncomeSummary(accountId);
const accounts = useBrokerageAccounts();
const sync = useScreenSync({ accountId, screen: "account" }, [
accounts,
portfolio,
operations,
events,
income,
]);
if (!portfolio.data) {
if (portfolio.isError || portfolio.meta?.sync.state === "failed")
return {
code: apiErrorCode(portfolio.error),
retry: sync.retry,
state: "error" as const,
};
return { state: "loading" as const };
}
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 refresh = {
partial: portfolio.meta?.availability === "partial",
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
refreshing: portfolio.isFetching || sync.refreshing,
retry: sync.retry,
sourceError: sync.sourceError,
};
if (
portfolio.meta?.availability === "complete"
&& portfolio.data.positions.length === 0
&& portfolio.data.cashBalances.length === 0
)
return {
account,
accountId,
incomeSummary,
recentEvents,
recentOperations,
state: "empty" as const,
...refresh,
};
return {
account,
accountId,
incomeSummary,
portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(
portfolio.data,
instrumentDetails,
),
recentEvents,
recentOperations,
state: "loaded" as const,
...refresh,
};
}
@@ -1,7 +1,10 @@
import { Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { accountTypeKey } from "@/entities/brokerage-account";
type AccountHeadingProps = {
@@ -13,6 +16,10 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => {
const { account } = props;
const { t } = useTranslation();
let description: string | undefined;
if (props.accountId === "all") description = t("accounts.allDescription");
else if (account) description = t(accountTypeKey(account.type));
return (
<Stack gap={2}>
<Title order={1}>
@@ -20,15 +27,7 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => {
? t("accounts.all")
: (account?.name ?? t("portfolio.title"))}
</Title>
{props.accountId === "all" ? (
<Text c="dimmed" size="sm">
{t("accounts.allDescription")}
</Text>
) : account ? (
<Text c="dimmed" size="sm">
{t(accountTypeKey(account.type))}
</Text>
) : null}
{description && <Text c="dimmed" size="sm">{description}</Text>}
</Stack>
);
};
@@ -1,14 +1,19 @@
import type { FC } from "react";
import { Stack } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import type {
AccountPageIncomeSummary,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../AccountPage.vm";
import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState/PortfolioRefreshState.view";
@@ -18,13 +23,13 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = {
account?: BrokerageAccount;
accountId?: string;
refreshing?: boolean;
sourceError?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
refreshError?: boolean;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
};
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
@@ -33,25 +38,25 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<Stack>
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
sourceError={props.sourceError}
refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false}
retry={props.retry}
sourceError={props.sourceError}
/>
<StateView
title={t("portfolio.emptyTitle")}
text={t(
props.accountId === "all"
? "portfolio.allEmptyText"
: "portfolio.emptyText",
)}
title={t("portfolio.emptyTitle")}
/>
<PortfolioStructure
structure={{ currencies: [], unvaluedCategories: [] }}
/>
<RecentOperations operations={props.recentOperations} />
<BrokerageEventsPreview events={props.recentEvents} />
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
{!!(props.incomeSummary) && (<IncomeSummary {...props.incomeSummary} />)}
</Stack>
);
};
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -12,44 +13,44 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
if (code === "NOT_FOUND")
return (
<StateView
error
title={t("portfolio.accountUnavailableTitle")}
text={t("portfolio.accountUnavailableText")}
action={
action={(
<Button component={Link} to="/accounts">
{t("portfolio.back")}
</Button>
}
)}
error
text={t("portfolio.accountUnavailableText")}
title={t("portfolio.accountUnavailableTitle")}
/>
);
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("portfolio.connectionErrorTitle")}
text={t("portfolio.connectionErrorText")}
action={
action={(
<Button component={Link} to="/settings">
{t("accounts.openSettings")}
</Button>
}
)}
error
text={t("portfolio.connectionErrorText")}
title={t("portfolio.connectionErrorTitle")}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
error
text={t("accounts.unavailableText")}
title={t("accounts.unavailableTitle")}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
error
text={t("states.errorText")}
title={t("states.errorTitle")}
/>
);
};
@@ -1,17 +1,22 @@
import { Accordion, Alert, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { Accordion, Alert, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import type { Portfolio } from "@/entities/portfolio";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type {
AccountPageIncomeSummary,
AccountPagePositionSection,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../AccountPage.vm";
import classes from "../AccountPage.module.css";
import { AccountHeading } from "./AccountHeading.view";
import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
import { IncomeSummary } from "./IncomeSummary.view";
@@ -23,16 +28,16 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = {
account?: BrokerageAccount;
accountId?: string;
incomeSummary?: AccountPageIncomeSummary;
partial?: boolean;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
sourceError?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
recentOperations: AccountPageRecentOperations;
refreshError?: boolean;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
@@ -41,14 +46,14 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Stack gap="lg">
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
sourceError={props.sourceError}
refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false}
retry={props.retry}
sourceError={props.sourceError}
/>
{props.portfolio.isStale ? (
{!!(props.portfolio.isStale) && ((
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null}
))}
<Text c="dimmed" size="sm">
{t(
props.accountId === "all"
@@ -59,34 +64,36 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
},
)}
</Text>
{props.partial ? (
<Alert role="status">{t("investment.partial")}</Alert>
) : (
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
)}
{!props.partial ? (
{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
classNames={{
chevron: classes.accordionChevron,
control: classes.accordionControl,
label: classes.accordionLabel,
panel: classes.accordionPanel,
}}
defaultValue={[
...props.positionSections.map((section) => section.kind),
...props.positionSections.map(section => section.kind),
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
]}
classNames={{
control: classes.accordionControl,
label: classes.accordionLabel,
chevron: classes.accordionChevron,
panel: classes.accordionPanel,
}}
multiple
>
{props.positionSections.map((section) => (
{props.positionSections.map(section => (
<Accordion.Item key={section.kind} value={section.kind}>
<Accordion.Control>
{t(positionSectionTitleKey(section.kind))}
@@ -117,9 +124,9 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Accordion.Item value="incomeSummary">
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
<Accordion.Panel>
{props.incomeSummary ? (
{!!(props.incomeSummary) && ((
<IncomeSummary {...props.incomeSummary} />
) : null}
))}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
@@ -129,8 +136,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks";
if (kind === "bonds") return "portfolio.bonds";
return "portfolio.otherPositions";
return kind === "bonds" ? "portfolio.bonds" : "portfolio.otherPositions";
}
export { AccountPageLoaded };
@@ -1,10 +1,9 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AccountPageLoading } from "./AccountPageLoading.view";
const meta = {
title: "Product/AccountPageLoading",
component: AccountPageLoading,
tags: ["autodocs"],
parameters: {
docs: {
description: {
@@ -13,6 +12,8 @@ const meta = {
},
},
},
tags: ["autodocs"],
title: "Product/AccountPageLoading",
} satisfies Meta<typeof AccountPageLoading>;
export default meta;
@@ -1,7 +1,9 @@
import type { FC } from "react";
import { Paper, SimpleGrid, Skeleton } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import classes from "../../AccountPage.module.css";
const AccountPageLoading: FC = () => {
@@ -10,25 +12,25 @@ const AccountPageLoading: FC = () => {
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="35%" />
<SimpleGrid
cols={{ base: 1, sm: 2, md: 3 }}
spacing={10}
aria-hidden="true"
cols={{ base: 1, md: 3, sm: 2 }}
spacing={10}
>
{[0, 1, 2].map((index) => (
<Paper key={index} className={classes.total} withBorder>
<Skeleton height={24} width={40} radius={4} />
{["first", "second", "third"].map(placeholder => (
<Paper className={classes.total} key={placeholder} withBorder>
<Skeleton height={24} radius={4} width={40} />
<Skeleton
height={21}
width={180}
radius={4}
className={classes.totalAmountSkeleton}
height={21}
radius={4}
width={180}
/>
</Paper>
))}
</SimpleGrid>
<Skeleton height={260} radius="sm" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={52} radius="sm" />
{["first", "second", "third"].map(placeholder => (
<Skeleton height={52} key={placeholder} radius="sm" />
))}
</LoadingSkeleton>
);
@@ -1,6 +1,7 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CurrencyCards } from "./CurrencyCards.view";
describe("CurrencyCards currency presentation", () => {
@@ -20,12 +21,14 @@ describe("CurrencyCards currency presentation", () => {
</MantineProvider>,
);
expect(
screen.queryAllByRole("definition").map((node) => node.textContent),
screen.queryAllByRole("definition").map(node => node.textContent),
).toEqual(
["3,00 €", "2,00 $", "1,00 ₽", "4,00 CAD", "5,00 XYZ"].slice(0, count),
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(container.querySelectorAll("dl")).toHaveLength(count);
expect(
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
container.querySelector("[tabindex], button, a, input"),
).toBeNull();
},
@@ -57,11 +60,14 @@ describe("CurrencyCards currency presentation", () => {
);
const term = screen.getByRole("term");
expect(term).toHaveTextContent(name);
expect(term.querySelector('[aria-hidden="true"]')).toHaveTextContent(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(term.querySelector("[aria-hidden=\"true\"]")).toHaveTextContent(
symbol,
);
expect(screen.getByRole("definition")).toHaveTextContent(expected);
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(term.parentElement).toBe(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
screen.getByRole("definition").parentElement,
);
},
@@ -79,7 +85,7 @@ describe("CurrencyCards currency presentation", () => {
</MantineProvider>,
);
expect(
screen.getAllByRole("definition").map((node) => node.textContent),
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1,00 $", "2,00 CAD"]);
});
});
@@ -1,10 +1,16 @@
import { Paper, SimpleGrid, Text, VisuallyHidden } from "@mantine/core";
import type { FC } from "react";
import { Paper, SimpleGrid, Text, VisuallyHidden } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
import { formatDecimal } from "@/shared/lib";
import classes from "../../AccountPage.module.css";
const SIGN_PREFIX_LENGTH = 2;
type CurrencyCardsProps = {
amounts: Portfolio["totalsByCurrency"];
};
@@ -12,25 +18,25 @@ type CurrencyCardsProps = {
const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
const { amounts } = props;
const { t } = useTranslation();
const currencies: Record<string, { symbol: string; name: string }> = {
RUB: { symbol: "₽", name: t("portfolio.currencies.RUB") },
USD: { symbol: "$", name: t("portfolio.currencies.USD") },
EUR: { symbol: "€", name: t("portfolio.currencies.EUR") },
const currencies: Record<string, { name: string; symbol: string }> = {
EUR: { name: t("portfolio.currencies.EUR"), symbol: "€" },
RUB: { name: t("portfolio.currencies.RUB"), symbol: "₽" },
USD: { name: t("portfolio.currencies.USD"), symbol: "$" },
};
return (
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing={10}>
<SimpleGrid cols={{ base: 1, md: 3, sm: 2 }} spacing={10}>
{amounts.map((money) => {
const amount = formatDecimal(money.value);
return (
<Paper
component="dl"
m={0}
className={classes.total}
component="dl"
key={money.currency}
m={0}
withBorder
>
<Text component="dt" className={classes.totalBadge}>
<Text className={classes.totalBadge} component="dt">
<span aria-hidden="true">
{currencies[money.currency]?.symbol ?? money.currency}
</span>
@@ -38,17 +44,20 @@ const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
{currencies[money.currency]?.name ?? money.currency}
</VisuallyHidden>
</Text>
<Text component="dd" m={0} className={classes.totalAmount}>
{amount.startsWith("-") ? (
<>
<span className={classes.totalLeadingDigits}>
{amount.slice(0, 2)}
</span>
{amount.slice(2)}
</>
) : (
amount
)}{" "}
<Text className={classes.totalAmount} component="dd" m={0}>
{amount.startsWith("-")
? (
<>
<span className={classes.totalLeadingDigits}>
{amount.slice(0, SIGN_PREFIX_LENGTH)}
</span>
{amount.slice(SIGN_PREFIX_LENGTH)}
</>
)
: (
amount
)}
{" "}
{currencies[money.currency]?.symbol ?? money.currency}
</Text>
</Paper>
@@ -1,30 +1,33 @@
import type { FC } from "react";
import { Button, Paper, Stack, Text } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
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, formatSnapshotTime } from "@/shared/lib";
type IncomeSummaryProps =
| { state: "loading"; to: string }
| { state: "partial"; to: string }
| { state: "error"; to: string; retry: () => void }
type IncomeSummaryProps
= | {
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "loaded";
summary: IncomeSummaryData;
to: string;
}
| { retry: () => void; state: "error"; to: string }
| { state: "empty"; to: string }
| {
state: "loaded";
to: string;
summary: IncomeSummaryData;
lastSuccessfulAt?: string | null;
stale?: boolean;
refreshError?: boolean;
};
| { state: "loading"; to: string }
| { state: "partial"; to: string };
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation();
if (props.state === "loading" || props.state === "partial")
return (
<Text role="status" c="dimmed">
<Text c="dimmed" role="status">
{t(
props.state === "partial"
? "investment.partial"
@@ -35,12 +38,12 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
if (props.state === "error")
return (
<StateView
error
title={t("income.summaryError")}
text={t("income.errorText")}
action={
<Button onClick={props.retry}>{t("income.summaryRetry")}</Button>
}
error
text={t("income.errorText")}
title={t("income.summaryError")}
/>
);
if (props.state === "empty")
@@ -54,23 +57,32 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
);
return (
<Stack gap="xs">
{props.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{!!(props.lastSuccessfulAt) && ((
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
) : null}
{props.stale ? <Text size="xs">{t("investment.stale")}</Text> : null}
{props.refreshError ? (
))}
{!!(props.stale) && (<Text size="xs">{t("investment.stale")}</Text>)}
{!!(props.refreshError) && ((
<Text role="status">{t("income.refreshError")}</Text>
) : null}
{props.summary.totalsByCurrency.map((total) => (
))}
{props.summary.totalsByCurrency.map(total => (
<Paper key={total.currency} p="sm" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
<Text c="dimmed" size="sm">
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(total.dividend)}
{t("income.coupon")}
:
{" "}
{formatMoney(total.coupon)}
{" "}
·
{" "}
{t("income.dividend")}
:
{" "}
{formatMoney(total.dividend)}
</Text>
</Paper>
))}
@@ -1,19 +1,19 @@
import { Stack } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Stack } from "@mantine/core";
import { fn } from "storybook/test";
import { CurrencyCards } from "../CurrencyCards/CurrencyCards.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
const meta = {
title: "Product/PortfolioRefreshState",
component: PortfolioRefreshState,
tags: ["autodocs"],
args: { refreshing: false, refreshError: false, retry: fn() },
args: { refreshError: false, refreshing: false, retry: fn() },
argTypes: {
refreshing: { control: "boolean" },
refreshError: { control: "boolean" },
refreshing: { control: "boolean" },
retry: { control: false },
},
component: PortfolioRefreshState,
parameters: {
docs: {
description: {
@@ -22,7 +22,7 @@ const meta = {
},
},
},
render: (args) => (
render: arguments_ => (
<Stack>
<CurrencyCards
amounts={[
@@ -30,9 +30,11 @@ const meta = {
{ currency: "USD", value: "320" },
]}
/>
<PortfolioRefreshState {...args} />
<PortfolioRefreshState {...arguments_} />
</Stack>
),
tags: ["autodocs"],
title: "Product/PortfolioRefreshState",
} satisfies Meta<typeof PortfolioRefreshState>;
export default meta;
@@ -40,15 +42,15 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Idle: Story = { name: "Сохранённые данные" };
export const Refreshing: Story = {
name: "Фоновое обновление",
args: { refreshing: true },
name: "Фоновое обновление",
};
export const RefreshError: Story = {
name: "Ошибка обновления с retry",
args: { refreshError: true },
name: "Ошибка обновления с retry",
};
export const SourceFailure: Story = {
name: "Ошибка синхронизации сохраняет суммы",
args: { refreshError: true, sourceError: true },
name: "Ошибка синхронизации сохраняет суммы",
};
@@ -1,38 +1,39 @@
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
type PortfolioRefreshStateProps = {
sourceError?: boolean;
refreshing: boolean;
refreshError: boolean;
refreshing: boolean;
retry?: () => void;
sourceError?: boolean;
};
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
const { refreshing, refreshError, retry } = props;
const { refreshError, refreshing, retry } = props;
const { t } = useTranslation();
return (
<>
<Button
onClick={retry}
disabled={refreshing}
onClick={retry}
variant="light"
w="fit-content"
>
{t(refreshError ? "accounts.retry" : "investment.refresh")}
</Button>
{refreshing ? (
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
{!!(refreshing) && ((
<Group aria-label={t("portfolio.refreshing")} gap="xs" role="status">
<Loader size="sm" />
<Text c="dimmed" size="sm">
{t("portfolio.refreshing")}
</Text>
</Group>
) : null}
{refreshError ? (
<Alert color="orange" role="status" aria-live="polite">
))}
{!!(refreshError) && ((
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="sm">
<Text>
{t(
@@ -43,7 +44,7 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
</Text>
</Stack>
</Alert>
) : null}
))}
</>
);
};
@@ -1,23 +1,23 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Portfolio } from "@/entities/portfolio";
import { PortfolioStructure } from "./PortfolioStructure";
const rub: Portfolio["structure"]["currencies"][number] = {
currency: "RUB",
totalValue: "1250000",
hasNegativeValues: false,
categories: [
{ type: "STOCK", value: "650000", sharePercent: "52" },
{ type: "BOND", value: "375000", sharePercent: "30" },
{ type: "OTHER", value: "125000", sharePercent: "10" },
{ type: "CASH", value: "100000", sharePercent: "8" },
{ sharePercent: "52", type: "STOCK", value: "650000" },
{ sharePercent: "30", type: "BOND", value: "375000" },
{ sharePercent: "10", type: "OTHER", value: "125000" },
{ sharePercent: "8", type: "CASH", value: "100000" },
],
currency: "RUB",
hasNegativeValues: false,
totalValue: "1250000",
};
const meta = {
title: "Product/PortfolioStructure",
component: PortfolioStructure,
tags: ["autodocs"],
args: { structure: { currencies: [rub], unvaluedCategories: [] } },
component: PortfolioStructure,
parameters: {
docs: {
description: {
@@ -26,6 +26,8 @@ const meta = {
},
},
},
tags: ["autodocs"],
title: "Product/PortfolioStructure",
} satisfies Meta<typeof PortfolioStructure>;
export default meta;
@@ -37,10 +39,10 @@ export const MultipleCurrencies: Story = {
currencies: [
rub,
{
categories: [{ sharePercent: "100", type: "CASH", value: "500" }],
currency: "USD",
totalValue: "500",
hasNegativeValues: false,
categories: [{ type: "CASH", value: "500", sharePercent: "100" }],
totalValue: "500",
},
],
unvaluedCategories: [],
@@ -52,14 +54,14 @@ export const CashOnly: Story = {
structure: {
currencies: [
{
currency: "RUB",
totalValue: "100000",
hasNegativeValues: false,
categories: rub.categories.map((row) => ({
categories: rub.categories.map(row => ({
...row,
value: row.type === "CASH" ? "100000" : "0",
sharePercent: row.type === "CASH" ? "100" : "0",
value: row.type === "CASH" ? "100000" : "0",
})),
currency: "RUB",
hasNegativeValues: false,
totalValue: "100000",
},
],
unvaluedCategories: [],
@@ -75,13 +77,15 @@ export const Incomplete: Story = {
currencies: [
{
...rub,
categories: rub.categories.map((row) => ({
...row,
sharePercent: null,
})),
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...row,
sharePercent: null,
}
/* eslint-enable unicorn/no-null */))),
},
],
unvaluedCategories: [{ type: "STOCK", count: 2 }],
unvaluedCategories: [{ count: 2, type: "STOCK" }],
},
},
};
@@ -89,7 +93,7 @@ export const UnknownOnly: Story = {
args: {
structure: {
currencies: [],
unvaluedCategories: [{ type: "BOND", count: 1 }],
unvaluedCategories: [{ count: 1, type: "BOND" }],
},
},
};
@@ -99,13 +103,15 @@ export const Negative: Story = {
currencies: [
{
...rub,
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...row,
sharePercent: null,
value: row.type === "OTHER" ? "25000" : row.value,
}
/* eslint-enable unicorn/no-null */))),
hasNegativeValues: true,
totalValue: "1150000",
categories: rub.categories.map((row) => ({
...row,
value: row.type === "OTHER" ? "25000" : row.value,
sharePercent: null,
})),
},
],
unvaluedCategories: [],
@@ -1,28 +1,30 @@
import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { PortfolioStructure } from "./PortfolioStructure";
const rub: Portfolio["structure"]["currencies"][number] = {
currency: "RUB",
totalValue: "100",
hasNegativeValues: false,
categories: [
{ type: "STOCK", value: "60", sharePercent: "60" },
{ type: "BOND", value: "20", sharePercent: "20" },
{ type: "OTHER", value: "0", sharePercent: "0" },
{ type: "CASH", value: "20", sharePercent: "20" },
{ sharePercent: "60", type: "STOCK", value: "60" },
{ sharePercent: "20", type: "BOND", value: "20" },
{ sharePercent: "0", type: "OTHER", value: "0" },
{ sharePercent: "20", type: "CASH", value: "20" },
],
currency: "RUB",
hasNegativeValues: false,
totalValue: "100",
};
const ui = (structure: Portfolio["structure"] | null | undefined) => (
const ui = (structure: null | Portfolio["structure"] | undefined) => (
<MantineProvider>
<PortfolioStructure structure={structure} />
</MantineProvider>
);
describe("portfolio structure", () => {
it.each([undefined, null])(
it.each([undefined, /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null])(
"keeps an older snapshot without structure available (%s) and recovers on refresh",
(structure) => {
const view = render(ui(structure));
@@ -66,15 +68,17 @@ describe("portfolio structure", () => {
currencies: [
{
...rub,
categories: rub.categories.map((row) => ({
...row,
sharePercent: null,
})),
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...row,
sharePercent: null,
}
/* eslint-enable unicorn/no-null */))),
},
],
unvaluedCategories: [
{ type: "OTHER", count: 2 },
{ type: "STOCK", count: 1 },
{ count: 2, type: "OTHER" },
{ count: 1, type: "STOCK" },
],
}),
);
@@ -93,12 +97,14 @@ describe("portfolio structure", () => {
currencies: [
{
...rub,
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...row,
sharePercent: null,
value: row.type === "STOCK" ? "-60" : row.value,
}
/* eslint-enable unicorn/no-null */))),
hasNegativeValues: true,
categories: rub.categories.map((row) => ({
...row,
value: row.type === "STOCK" ? "-60" : row.value,
sharePercent: null,
})),
},
],
unvaluedCategories: [],
@@ -115,7 +121,7 @@ describe("portfolio structure", () => {
});
it("handles unknown-only and empty compositions", () => {
const view = render(
ui({ currencies: [], unvaluedCategories: [{ type: "BOND", count: 1 }] }),
ui({ currencies: [], unvaluedCategories: [{ count: 1, type: "BOND" }] }),
);
expect(screen.getByText("Облигации: без оценки — 1")).toBeInTheDocument();
expect(screen.queryByRole("list")).not.toBeInTheDocument();
@@ -130,9 +136,9 @@ describe("portfolio structure", () => {
...rub,
categories: [
{
sharePercent: "0.000001",
type: "CASH",
value: "9007199254740993.000000001",
sharePercent: "0.000001",
},
],
},
@@ -1,72 +1,72 @@
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import { PortfolioStructureView } from "./PortfolioStructure.view";
const MIN_VISIBLE_SHARE_PERCENT = 0.01;
// A cached snapshot or an older running server can predate this response field.
type PortfolioStructureProps = { structure?: Portfolio["structure"] | null };
type PortfolioStructureProps = { structure?: null | Portfolio["structure"] };
const categoryPresentation = {
STOCK: { label: "portfolio.stocks", color: "blue.6" },
BOND: { label: "portfolio.bonds", color: "grape.6" },
OTHER: { label: "portfolio.structure.other", color: "orange.6" },
CASH: { label: "portfolio.structure.cash", color: "green.6" },
BOND: { color: "grape.6", label: "portfolio.bonds" },
CASH: { color: "green.6", label: "portfolio.structure.cash" },
OTHER: { color: "orange.6", label: "portfolio.structure.other" },
STOCK: { color: "blue.6", label: "portfolio.stocks" },
} as const;
const PortfolioStructure: FC<PortfolioStructureProps> = (props) => {
const { structure } = props;
const { t } = useTranslation();
const [currency, setCurrency] = useState<string | null>(null);
const selected =
structure?.currencies.find((item) => item.currency === currency) ??
structure?.currencies[0];
const incomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
const [currency, setCurrency] = useState<null | string>(null);
const selected
= structure?.currencies.find(item => item.currency === currency)
?? structure?.currencies[0];
const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
const rows = selected
? selected.categories.map((category) => {
const presentation = categoryPresentation[category.type];
const percentage =
category.sharePercent === null ? null : Number(category.sharePercent);
const percentage
= category.sharePercent === null ? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null : Number(category.sharePercent);
const unknown = structure?.unvaluedCategories.some(
(item) => item.type === category.type,
item => item.type === category.type,
);
const amount = formatMoney({
currency: selected.currency,
value: category.value,
});
let displayedAmount = amount;
if (unknown) displayedAmount = category.value === "0" ? t("portfolio.structure.unvalued") : t("portfolio.structure.knownAmount", { amount });
let share = t("portfolio.structure.shareUnavailable");
if (percentage !== null) share = percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT
? t("portfolio.structure.smallShare")
: `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`;
return {
name: t(presentation.label),
amount: displayedAmount,
color: presentation.color,
amount: unknown
? category.value === "0"
? t("portfolio.structure.unvalued")
: t("portfolio.structure.knownAmount", { amount })
: amount,
share:
percentage === null
? t("portfolio.structure.shareUnavailable")
: percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT
? t("portfolio.structure.smallShare")
: `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`,
name: t(presentation.label),
share,
value: percentage ?? 0,
};
})
: [];
const chartData = rows.filter((row) => row.value > 0);
const chartData = rows.filter(row => row.value > 0);
return (
<PortfolioStructureView
unavailable={!structure}
currencies={structure?.currencies.map((item) => item.currency) ?? []}
currency={selected?.currency ?? null}
chartData={chartData}
currencies={structure?.currencies.map(item => item.currency) ?? []}
currency={selected?.currency ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null}
incomplete={isIncomplete}
negative={selected?.hasNegativeValues ?? false}
onCurrencyChange={setCurrency}
rows={rows}
chartData={chartData}
incomplete={incomplete}
negative={selected?.hasNegativeValues ?? false}
unavailable={!structure}
unvalued={
structure?.unvaluedCategories.map((category) =>
structure?.unvaluedCategories.map(category =>
t("portfolio.structure.unvaluedCategory", {
category: t(categoryPresentation[category.type].label),
count: category.count,
@@ -2,123 +2,130 @@ import { DonutChart } from "@mantine/charts";
import { Box, Group, Paper, Select, Stack, Text, Title } from "@mantine/core";
import { type FC, useId } from "react";
import { useTranslation } from "react-i18next";
import classes from "./PortfolioStructure.module.css";
type StructureRow = {
name: string;
color: string;
amount: string;
share: string;
value: number;
};
type PortfolioStructureViewProps = {
unavailable: boolean;
currencies: string[];
currency: string | null;
onCurrencyChange: (currency: string | null) => void;
rows: StructureRow[];
chartData: StructureRow[];
currencies: string[];
currency: null | string;
incomplete: boolean;
negative: boolean;
onCurrencyChange: (currency: null | string) => void;
rows: StructureRow[];
unavailable: boolean;
unvalued: string[];
};
type StructureRow = {
amount: string;
color: string;
name: string;
share: string;
value: number;
};
const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
const { t } = useTranslation();
const titleId = useId();
return (
<Stack
component="section"
aria-labelledby={titleId}
gap="md"
className={classes.section}
component="section"
gap="md"
>
<Group justify="space-between" align="center">
<Title order={2} size="h4" id={titleId}>
<Group align="center" justify="space-between">
<Title id={titleId} order={2} size="h4">
{t("portfolio.structure.title")}
</Title>
{props.currencies.length > 1 ? (
{(props.currencies.length > 1) && ((
<Select
allowDeselect={false}
aria-label={t("portfolio.structure.currency")}
data={props.currencies}
value={props.currency}
onChange={props.onCurrencyChange}
allowDeselect={false}
value={props.currency}
w={112}
/>
) : null}
))}
</Group>
<Text size="sm" c="dimmed">
<Text c="dimmed" size="sm">
{t(
props.unavailable
? "portfolio.structure.unavailable"
: "portfolio.structure.basis",
)}
</Text>
{props.incomplete ? (
{!!(props.incomplete) && ((
<Stack gap={4}>
<Text size="sm">{t("portfolio.structure.incomplete")}</Text>
{props.unvalued.map((message) => (
<Text size="sm" c="dimmed" key={message}>
{props.unvalued.map(message => (
<Text c="dimmed" key={message} size="sm">
{message}
</Text>
))}
</Stack>
) : null}
{props.negative ? (
))}
{!!(props.negative) && ((
<Text size="sm">{t("portfolio.structure.negative")}</Text>
) : null}
{!props.unavailable &&
!props.chartData.length &&
!props.incomplete &&
!props.negative ? (
<Text size="sm" c="dimmed">
))}
{!!(!props.unavailable
&& props.chartData.length === 0
&& !props.incomplete
&& !props.negative) && ((
<Text c="dimmed" size="sm">
{t("portfolio.structure.empty")}
</Text>
) : null}
))}
<div
className={`${classes.content} ${props.chartData.length > 0 ? classes.layout : ""}`}
key={props.currency}
className={`${classes.content} ${props.chartData.length ? classes.layout : ""}`}
>
{props.chartData.length ? (
{props.chartData.length > 0 && ((
<DonutChart
data={props.chartData}
size={220}
thickness={32}
strokeWidth={2}
startAngle={90}
endAngle={-270}
chartLabel={props.currency ?? undefined}
tooltipDataSource="segment"
pieProps={{ isAnimationActive: false }}
pieChartProps={{ title: t("portfolio.structure.chartLabel") }}
aria-label={t("portfolio.structure.chartLabel")}
chartLabel={props.currency ?? undefined}
data={props.chartData}
endAngle={-270}
pieChartProps={{ title: t("portfolio.structure.chartLabel") }}
pieProps={{ isAnimationActive: false }}
size={220}
startAngle={90}
strokeWidth={2}
thickness={32}
tooltipDataSource="segment"
tooltipProps={{
content: ({ active, payload }) => {
const row = props.chartData.find(
(item) => item.name === payload?.[0]?.name,
item => item.name === payload?.[0]?.name,
);
return active && row ? (
<Paper shadow="sm" p="sm" radius="md">
<Text size="sm" fw={600}>
{row.name}
</Text>
<Text size="sm">
{row.amount} · {row.share}
</Text>
</Paper>
) : null;
return active && row
? (
<Paper p="sm" radius="md" shadow="sm">
<Text fw={600} size="sm">
{row.name}
</Text>
<Text size="sm">
{row.amount}
{" "}
·
{" "}
{row.share}
</Text>
</Paper>
)
: undefined;
},
}}
/>
) : null}
{props.rows.length ? (
))}
{props.rows.length > 0 && ((
<ul
className={classes.list}
aria-label={t("portfolio.structure.categories")}
className={classes.list}
>
{props.rows.map((row) => (
<li key={row.name} className={classes.row}>
{props.rows.map(row => (
<li className={classes.row} key={row.name}>
<Group gap="xs" wrap="nowrap">
<Box
aria-hidden="true"
@@ -128,17 +135,17 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
<Text size="sm">{row.name}</Text>
</Group>
<div className={classes.values}>
<Text size="sm" fw={600}>
<Text fw={600} size="sm">
{row.amount}
</Text>
<Text size="sm" c="dimmed">
<Text c="dimmed" size="sm">
{row.share}
</Text>
</div>
</li>
))}
</ul>
) : null}
))}
</div>
</Stack>
);
@@ -1,28 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { AccountPagePosition } from "../../AccountPage.vm";
import { PositionCard } from "./PositionCard.view";
const syntheticPosition = {
figi: null,
displayName: "Учебные технологии",
type: "STOCK",
quantity: "100",
currentValue: { currency: "RUB", value: "25000" },
averagePurchasePrice: { currency: "RUB", value: "200" },
const syntheticPosition = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
absoluteReturn: { currency: "RUB", value: "5000" },
averagePurchasePrice: { currency: "RUB", value: "200" },
currentValue: { currency: "RUB", value: "25000" },
displayName: "Учебные технологии",
figi: null,
isin: null,
ticker: "DEMO",
logoName: null,
shareText: "20%",
logoSrc: null,
quantity: "100",
returnText: "+5 000 ₽",
returnTone: "positive",
logoSrc: null,
} satisfies AccountPagePosition;
shareText: "20%",
ticker: "DEMO",
type: "STOCK",
}
/* eslint-enable unicorn/no-null */) satisfies AccountPagePosition;
const meta = {
title: "Product/PositionCard",
component: PositionCard,
tags: ["autodocs"],
args: { kind: "stocks", position: syntheticPosition },
argTypes: {
kind: {
control: "inline-radio",
@@ -30,6 +32,7 @@ const meta = {
},
position: { control: false },
},
component: PositionCard,
parameters: {
docs: {
description: {
@@ -38,7 +41,8 @@ const meta = {
},
},
},
args: { kind: "stocks", position: syntheticPosition },
tags: ["autodocs"],
title: "Product/PositionCard",
} satisfies Meta<typeof PositionCard>;
export default meta;
@@ -57,78 +61,84 @@ export const StockGain: Story = {
},
};
export const BondLoss: Story = {
name: "Облигация с отрицательным результатом",
args: {
kind: "bonds",
position: {
...syntheticPosition,
displayName: "Учебные облигации для долгосрочных накоплений, выпуск 2030",
type: "BOND",
quantity: "10",
currentValue: { currency: "RUB", value: "9500" },
averagePurchasePrice: { currency: "RUB", value: "1000" },
absoluteReturn: { currency: "RUB", value: "-500" },
isin: "DEMO-BOND-2030",
ticker: null,
shareText: "7,6%",
returnText: "−500 ₽",
returnTone: "negative",
export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
kind: "bonds",
position: {
...syntheticPosition,
absoluteReturn: { currency: "RUB", value: "-500" },
averagePurchasePrice: { currency: "RUB", value: "1000" },
currentValue: { currency: "RUB", value: "9500" },
displayName: "Учебные облигации для долгосрочных накоплений, выпуск 2030",
isin: "DEMO-BOND-2030",
quantity: "10",
returnText: "−500 ₽",
returnTone: "negative",
shareText: "7,6%",
ticker: null,
type: "BOND",
},
},
},
parameters: {
docs: {
description: {
story:
name: "Облигация с отрицательным результатом",
parameters: {
docs: {
description: {
story:
"Для облигации используется идентификатор выпуска. Длинное имя и отрицательная сумма позволяют проверить компактное представление на узком экране.",
},
},
},
},
};
}
/* eslint-enable unicorn/no-null */);
export const Other: Story = {
name: "Прочий инструмент",
args: {
kind: "other",
position: {
...syntheticPosition,
displayName: "Учебный фонд",
type: "FUND",
isin: "DEMO-FUND",
ticker: null,
export const Other: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
kind: "other",
position: {
...syntheticPosition,
displayName: "Учебный фонд",
isin: "DEMO-FUND",
ticker: null,
type: "FUND",
},
},
},
parameters: {
docs: {
description: {
story:
name: "Прочий инструмент",
parameters: {
docs: {
description: {
story:
"Компактный вариант для остальных инструментов сохраняет количество, стоимость и пояснение результата без оформления акции или облигации.",
},
},
},
},
};
}
/* eslint-enable unicorn/no-null */);
export const MissingValuation: Story = {
name: "Оценка и идентификаторы отсутствуют",
args: {
position: {
...syntheticPosition,
displayName: "Учебная позиция без текущей оценки",
currentValue: null,
averagePurchasePrice: null,
absoluteReturn: null,
ticker: null,
shareText: null,
returnText: null,
returnTone: undefined,
},
},
parameters: {
docs: {
description: {
story:
"При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.",
export const MissingValuation: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
position: {
...syntheticPosition,
absoluteReturn: null,
averagePurchasePrice: null,
currentValue: null,
displayName: "Учебная позиция без текущей оценки",
returnText: null,
returnTone: undefined,
shareText: null,
ticker: null,
},
},
},
};
name: "Оценка и идентификаторы отсутствуют",
parameters: {
docs: {
description: {
story:
"При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.",
},
},
},
}
/* eslint-enable unicorn/no-null */);
@@ -1,13 +1,17 @@
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import classes from "../../AccountPage.module.css";
import type {
AccountPagePosition,
AccountPagePositionSection,
} from "../../AccountPage.vm";
import classes from "../../AccountPage.module.css";
import { PositionLogo } from "../PositionLogo.view";
type PositionCardProps = {
@@ -22,41 +26,37 @@ const PositionCard: FC<PositionCardProps> = (props) => {
const returnClass = position.returnTone
? classes[position.returnTone]
: undefined;
const returnText =
position.returnText && isExpanded
? position.returnText
: position.returnText
? t("portfolio.return", {
value: position.returnText,
})
: null;
let returnText = position.returnText;
if (returnText && !isExpanded) returnText = t("portfolio.return", { value: returnText });
const instrumentTo = position.instrumentTo;
const identifier =
kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
const identifier
= kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
const identifierText = [
t("portfolio.quantity", { value: position.quantity }),
identifier,
]
.filter(Boolean)
.join(" · ");
const accountLink = position.account ? (
<Anchor
component={Link}
to={`/accounts/${encodeURIComponent(position.account.id)}`}
size="xs"
>
{position.account.name}
</Anchor>
) : null;
const accountLink = position.account
? (
<Anchor
component={Link}
size="xs"
to={`/accounts/${encodeURIComponent(position.account.id)}`}
>
{position.account.name}
</Anchor>
)
: undefined;
const secondaryText = [
position.averagePurchasePrice
? t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})
: null,
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null,
position.shareText
? t("portfolio.share", { value: position.shareText })
: null,
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null,
]
.filter(Boolean)
.join(" · ");
@@ -67,91 +67,93 @@ const PositionCard: FC<PositionCardProps> = (props) => {
<div className={classes.expandedMain}>
<PositionLogo
key={position.logoSrc ?? "no-logo"}
src={position.logoSrc}
label={position.displayName}
src={position.logoSrc}
to={instrumentTo}
/>
<Stack gap={2} miw={0} className={classes.expandedIdentity}>
{instrumentTo ? (
<Anchor component={Link} to={instrumentTo} c="inherit" fw={600}>
{position.displayName}
</Anchor>
) : (
<Text fw={600} lineClamp={1}>
{position.displayName}
</Text>
)}
<Stack className={classes.expandedIdentity} gap={2} miw={0}>
{instrumentTo
? (
<Anchor c="inherit" component={Link} fw={600} to={instrumentTo}>
{position.displayName}
</Anchor>
)
: (
<Text fw={600} lineClamp={1}>
{position.displayName}
</Text>
)}
{accountLink}
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
<Text c="dimmed" className={classes.expandedIdentifiers} size="xs">
{identifierText}
</Text>
</Stack>
<Stack gap={2} align="flex-end" className={classes.expandedValue}>
{position.currentValue ? (
<Stack align="flex-end" className={classes.expandedValue} gap={2}>
{!!(position.currentValue) && ((
<Text fw={700} size="lg">
{formatMoney(position.currentValue)}
</Text>
) : null}
{returnText ? (
<Text size="sm" fw={600} className={returnClass}>
))}
{!!(returnText) && ((
<Text className={returnClass} fw={600} size="sm">
{returnText}
</Text>
) : null}
))}
</Stack>
</div>
{secondaryText ? (
<Text c="dimmed" size="xs" className={classes.expandedMeta}>
{!!(secondaryText) && ((
<Text c="dimmed" className={classes.expandedMeta} size="xs">
{secondaryText}
</Text>
) : null}
))}
</Card>
);
return (
<Card className={classes.tile} withBorder>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group align="flex-start" justify="space-between" wrap="nowrap">
<Stack gap={2} miw={0}>
<Text fw={700} lineClamp={1}>
{position.displayName}
</Text>
{accountLink}
{position.isin ? (
<Text c="dimmed" size="xs" className={classes.mono}>
{!!(position.isin) && ((
<Text c="dimmed" className={classes.mono} size="xs">
{position.isin}
</Text>
) : null}
))}
<Text c="dimmed" size="xs">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
{position.currentValue ? (
<Stack align="flex-end" gap={2}>
{!!(position.currentValue) && ((
<Text fw={800} size="lg">
{formatMoney(position.currentValue)}
</Text>
) : null}
{returnText ? (
<Text size="sm" fw={600} className={returnClass}>
))}
{!!(returnText) && ((
<Text className={returnClass} fw={600} size="sm">
{returnText}
</Text>
) : null}
))}
</Stack>
</Group>
{position.averagePurchasePrice || position.shareText ? (
{!!(position.averagePurchasePrice || position.shareText) && ((
<Group justify="space-between" mt="xs">
{position.averagePurchasePrice ? (
{!!(position.averagePurchasePrice) && ((
<Text c="dimmed" size="xs">
{t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})}
</Text>
) : null}
{position.shareText ? (
))}
{!!(position.shareText) && ((
<Text c="dimmed" size="xs">
{t("portfolio.share", { value: position.shareText })}
</Text>
) : null}
))}
</Group>
) : null}
))}
</Card>
);
};
@@ -1,13 +1,17 @@
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type {
AccountPagePositionGroup,
AccountPagePositionSection,
} from "../AccountPage.vm";
import classes from "../AccountPage.module.css";
import { PositionLogo } from "./PositionLogo.view";
type PositionGroupCardProps = {
@@ -22,88 +26,88 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
const instrumentTo = identity.instrumentTo;
return (
<Card className={classes.tile} withBorder>
<Group wrap="nowrap" gap="sm">
{kind !== "other" ? (
<Group gap="sm" wrap="nowrap">
{kind !== "other" && ((
<PositionLogo
key={identity.logoSrc ?? "no-logo"}
src={identity.logoSrc}
label={identity.displayName}
src={identity.logoSrc}
to={instrumentTo}
/>
) : null}
))}
<Stack gap={2} miw={0}>
{instrumentTo ? (
<Anchor component={Link} to={instrumentTo} c="inherit" fw={600}>
{identity.displayName}
</Anchor>
) : (
<Text fw={600}>{identity.displayName}</Text>
)}
{instrumentTo
? (
<Anchor c="inherit" component={Link} fw={600} to={instrumentTo}>
{identity.displayName}
</Anchor>
)
: (
<Text fw={600}>{identity.displayName}</Text>
)}
<Text c="dimmed" size="xs">
{identity.ticker ?? identity.isin ?? identity.figi}
</Text>
</Stack>
</Group>
<Stack
aria-label={t("portfolio.byAccount")}
className={classes.accountPositions}
component="ul"
gap={0}
className={classes.accountPositions}
aria-label={t("portfolio.byAccount")}
>
{group.positions.map((position) => (
{group.positions.map(position => (
<Stack
component="li"
key={position.account?.id}
gap={4}
className={classes.accountPosition}
component="li"
gap={4}
key={position.account?.id}
>
<Group justify="space-between" align="flex-start" gap="xs">
{position.account ? (
<Group align="flex-start" gap="xs" justify="space-between">
{!!(position.account) && ((
<Anchor
component={Link}
to={`/accounts/${encodeURIComponent(position.account.id)}`}
size="sm"
className={classes.accountPositionName}
component={Link}
size="sm"
to={`/accounts/${encodeURIComponent(position.account.id)}`}
>
{position.account.name}
</Anchor>
) : null}
<Text size="sm" fw={600}>
))}
<Text fw={600} size="sm">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Group>
<Group justify="space-between" gap="xs">
{position.currentValue ? (
<Group gap="xs" justify="space-between">
{!!(position.currentValue) && ((
<Text size="sm">{formatMoney(position.currentValue)}</Text>
) : null}
{position.returnText ? (
))}
{!!(position.returnText) && ((
<Text
size="sm"
className={
position.returnTone
? classes[position.returnTone]
: undefined
(position.returnTone) ? (classes[position.returnTone]) : undefined
}
size="sm"
>
{kind === "other"
? t("portfolio.return", { value: position.returnText })
: position.returnText}
</Text>
) : null}
))}
</Group>
<Group gap="xs">
{position.averagePurchasePrice ? (
{!!(position.averagePurchasePrice) && ((
<Text c="dimmed" size="xs">
{t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})}
</Text>
) : null}
{position.shareText ? (
))}
{!!(position.shareText) && ((
<Text c="dimmed" size="xs">
{t("portfolio.share", { value: position.shareText })}
</Text>
) : null}
))}
</Group>
</Stack>
))}
@@ -1,47 +1,52 @@
import { Anchor, Avatar } from "@mantine/core";
import type { FC } from "react";
import { Anchor, Avatar } from "@mantine/core";
import { useState } from "react";
import { Link } from "react-router";
type PositionLogoProps = {
src: string | null;
label: string;
src: null | string;
to?: string;
};
const PositionLogo: FC<PositionLogoProps> = (props) => {
const { src, label } = props;
const { label, src } = props;
const [failed, setFailed] = useState(false);
const logo =
src && !failed ? (
<Avatar
src={src}
alt={label}
size={40}
radius="md"
onError={() => setFailed(true)}
/>
) : (
<Avatar size={40} radius="md">
{(label.trim()[0] ?? "?").toUpperCase()}
</Avatar>
);
return props.to ? (
<Anchor
component={Link}
to={props.to}
aria-label={label}
c="inherit"
underline="never"
display="block"
style={{ borderRadius: "var(--mantine-radius-md)" }}
>
{logo}
</Anchor>
) : (
logo
);
const logo
= src && !failed
? (
<Avatar
alt={label}
onError={() => setFailed(true)}
radius="md"
size={40}
src={src}
/>
)
: (
<Avatar radius="md" size={40}>
{(label.trim()[0] ?? "?").toUpperCase()}
</Avatar>
);
return props.to
? (
<Anchor
aria-label={label}
c="inherit"
component={Link}
display="block"
style={{ borderRadius: "var(--mantine-radius-md)" }}
to={props.to}
underline="never"
>
{logo}
</Anchor>
)
: (
logo
);
};
export { PositionLogo };
@@ -1,6 +1,9 @@
import { SimpleGrid } from "@mantine/core";
import type { FC } from "react";
import { SimpleGrid } from "@mantine/core";
import type { AccountPagePositionSection } from "../AccountPage.vm";
import { PositionCard } from "./PositionCard/PositionCard.view";
import { PositionGroupCard } from "./PositionGroupCard.view";
@@ -10,24 +13,26 @@ type PositionSectionProps = {
const PositionSection: FC<PositionSectionProps> = (props) => {
const { section } = props;
const expanded = section.kind !== "other";
const isExpanded = section.kind !== "other";
return (
<SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}>
{section.groups.map((group) =>
group.positions.length > 1 ? (
<PositionGroupCard
key={group.key}
group={group}
kind={section.kind}
/>
) : (
<PositionCard
key={group.key}
position={group.positions[0]}
kind={section.kind}
/>
),
<SimpleGrid cols={isExpanded ? 1 : { base: 1, md: 3, sm: 2 }}>
{section.groups.map(group =>
group.positions.length > 1
? (
<PositionGroupCard
group={group}
key={group.key}
kind={section.kind}
/>
)
: (
<PositionCard
key={group.key}
kind={section.kind}
position={group.positions[0]}
/>
),
)}
</SimpleGrid>
);
@@ -1,10 +1,13 @@
import type { FC } from "react";
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
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 = {
@@ -17,51 +20,49 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
return (
<Stack gap="sm">
{operations.state === "loaded" ? (
{(operations.state === "loaded") && ((
<>
{operations.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{!!(operations.lastSuccessfulAt) && ((
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(operations.lastSuccessfulAt),
})}
</Text>
) : null}
{operations.stale ? (
))}
{!!(operations.stale) && ((
<Text size="xs">{t("investment.stale")}</Text>
) : null}
{operations.refreshError ? (
))}
{!!(operations.refreshError) && ((
<Text role="status">{t("accounts.refreshError")}</Text>
) : null}
))}
</>
) : null}
{operations.state === "loading" ? (
))}
{(operations.state === "loading") && ((
<LoadingSkeleton label={t("ledger.recentLoading")}>
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={48} radius="sm" />
{["first", "second", "third"].map(placeholder => (
<Skeleton height={48} key={placeholder} radius="sm" />
))}
</LoadingSkeleton>
) : null}
{operations.state === "error" ? (
))}
{(operations.state === "error") && ((
<Alert color="red">{t("ledger.recentError")}</Alert>
) : null}
{operations.state === "empty" ? (
))}
{(operations.state === "empty") && ((
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
) : null}
{operations.state === "partial" ? (
))}
{(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([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
))
: null}
))}
{(operations.state === "loaded" || operations.state === "partial") && (operations.items.map(operation => (
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
)))}
<Button component={Link} to={operations.to} variant="subtle">
{t("ledger.open")}
</Button>
@@ -2,39 +2,45 @@ import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { toAccountPagePositionSections } from "./AccountPage.vm";
import { PositionSection } from "./components/PositionSection.view";
const position: Portfolio["positions"][number] = {
account: { id: "a", name: "Основной" },
figi: "FIGI-SBER",
displayName: "Сбербанк",
type: "STOCK",
quantity: "1.25",
currentValue: { currency: "RUB", value: "375" },
averagePurchasePrice: { currency: "RUB", value: "280" },
absoluteReturn: { currency: "RUB", value: "25" },
isin: "RU0009029540",
ticker: "SBER",
logoName: null,
};
const second = {
...position,
account: { id: "b", name: "ИИС" },
quantity: "-2",
currentValue: null,
averagePurchasePrice: null,
absoluteReturn: null,
};
const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
absoluteReturn: { currency: "RUB", value: "25" },
account: { id: "a", name: "Основной" },
averagePurchasePrice: { currency: "RUB", value: "280" },
currentValue: { currency: "RUB", value: "375" },
displayName: "Сбербанк",
figi: "FIGI-SBER",
isin: "RU0009029540",
logoName: null,
quantity: "1.25",
ticker: "SBER",
type: "STOCK",
}
/* eslint-enable unicorn/no-null */);
const second = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...position,
absoluteReturn: null,
account: { id: "b", name: "ИИС" },
averagePurchasePrice: null,
currentValue: null,
quantity: "-2",
}
/* eslint-enable unicorn/no-null */);
function sections(positions: Portfolio["positions"]) {
return toAccountPagePositionSections({
lastSuccessfulAt: "2026-09-09T12:00:00Z",
isStale: false,
totalsByCurrency: [],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [],
isStale: false,
lastSuccessfulAt: "2026-09-09T12:00:00Z",
positions,
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [],
});
}
@@ -72,8 +78,12 @@ describe("Positions grouped by security", () => {
it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => {
expect(
sections([
{ ...position, figi: null },
{ ...second, figi: null },
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...position, figi: null }
/* eslint-enable unicorn/no-null */),
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...second, figi: null }
/* eslint-enable unicorn/no-null */),
])[0].groups,
).toHaveLength(1);
expect(
@@ -81,8 +91,12 @@ describe("Positions grouped by security", () => {
).toHaveLength(2);
expect(
sections([
{ ...position, figi: null, isin: null },
{ ...second, figi: null, isin: null },
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...position, figi: null, isin: null }
/* eslint-enable unicorn/no-null */),
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...second, figi: null, isin: null }
/* eslint-enable unicorn/no-null */),
])[0].groups,
).toHaveLength(2);
});
@@ -104,20 +118,20 @@ describe("Positions grouped by security", () => {
position,
{
...second,
currentValue: { currency: "USD", value: "1.01" },
averagePurchasePrice: { currency: "USD", value: "0.99" },
currentValue: { currency: "USD", value: "1.01" },
},
];
const before = structuredClone(source);
const [group] = sections(source)[0].groups;
expect(
group.positions.map((item) => [
group.positions.map(item => [
item.quantity,
item.currentValue,
item.averagePurchasePrice,
]),
).toEqual(
source.map((item) => [
source.map(item => [
item.quantity,
item.currentValue,
item.averagePurchasePrice,
@@ -1,14 +1,18 @@
import "@mantine/core/styles.css";
import "@wealthledger/ui/styles.css";
import "@/shared/i18n";
import { MantineProvider } from "@mantine/core";
// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup.
import { cleanup, render, screen } from "@testing-library/react";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { afterEach, expect, it } from "vitest";
import type {
AccountPagePosition,
AccountPagePositionSection,
} from "./AccountPage.vm";
import { PositionSection } from "./components/PositionSection.view";
afterEach(cleanup);
@@ -22,39 +26,42 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
const width = window.innerWidth;
expect([375, 1280]).toContain(width);
for (const kind of kinds) {
const positions: AccountPagePosition[] = [1, 2, 3].map((index) => ({
figi: null,
displayName: `Инструмент ${index}`,
type: kind === "stocks" ? "STOCK" : kind === "bonds" ? "BOND" : "FUND",
quantity: "10",
currentValue: { currency: "RUB", value: "1000" },
averagePurchasePrice: null,
absoluteReturn: null,
isin: null,
ticker: null,
logoName: null,
shareText: null,
returnText: null,
logoSrc: null,
}));
const otherType = kind === "bonds" ? "BOND" : "FUND";
const positions: AccountPagePosition[] = [1, 2, 3].map(index => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
absoluteReturn: null,
averagePurchasePrice: null,
currentValue: { currency: "RUB", value: "1000" },
displayName: `Инструмент ${index}`,
figi: null,
isin: null,
logoName: null,
logoSrc: null,
quantity: "10",
returnText: null,
shareText: null,
ticker: null,
type: kind === "stocks" ? "STOCK" : otherType,
}
/* eslint-enable unicorn/no-null */)));
const { unmount } = render(
<MantineProvider
theme={theme}
cssVariablesResolver={cssVariablesResolver}
forceColorScheme="light"
theme={theme}
>
<PositionSection
section={{
kind,
groups: positions.map((position) => ({
groups: positions.map(position => ({
key: position.displayName,
positions: [position],
})),
kind,
}}
/>
</MantineProvider>,
);
const bounds = positions.map((position) =>
const bounds = positions.map(position =>
screen.getByText(position.displayName).getBoundingClientRect(),
);
const context = `${kind} at ${width}px`;
@@ -64,10 +71,15 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
if (index === 0) continue;
const previous = bounds[index - 1];
if (kind === "other" && width === 1280) {
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(current.top, context).toBeCloseTo(previous.top, 0);
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(current.left, context).toBeGreaterThan(previous.right);
} else {
}
else {
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(current.left, context).toBeCloseTo(previous.left, 0);
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(current.top, context).toBeGreaterThan(previous.bottom);
}
}
@@ -1,14 +1,16 @@
import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountsPage } from "./AccountsPage";
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
import { AccountsPageError } from "./components/AccountsPageError.view";
@@ -17,15 +19,15 @@ import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
const account = {
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2026-01-01",
type: "BROKERAGE",
} as const;
function configureTestAuth() {
configureAuthClient({
accessToken: () => "test-token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
}
@@ -44,7 +46,7 @@ function renderPage(queryClient: QueryClient) {
describe("AccountsPage", () => {
it("loads accounts after retrying an initial HTTP failure", async () => {
const modes: (string | null)[] = [];
const modes: (null | string)[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
@@ -56,6 +58,7 @@ describe("AccountsPage", () => {
renderPage(createTestQueryClient());
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
expect(await screen.findByText(account.name)).toBeVisible();
// eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response.
expect(modes).toEqual([null, "observe"]);
});
it.each([
@@ -67,8 +70,10 @@ describe("AccountsPage", () => {
<State />
</MantineProvider>,
);
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
if (role) expect(screen.getByRole(role)).toBeInTheDocument();
else
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(
screen.getByRole("heading", { name: "Нет инвестиционных счетов" }),
).toBeInTheDocument();
@@ -83,7 +88,7 @@ describe("AccountsPage", () => {
</MemoryRouter>
</MantineProvider>,
);
screen.getByRole("button", { name: "Повторить" }).click();
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(retry).toHaveBeenCalledOnce();
expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
@@ -97,9 +102,11 @@ describe("AccountsPage", () => {
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(resource([account]));
return new Promise<Response>((resolve) => {
resolveRefresh = resolve;
});
{
const deferred = Promise.withResolvers<Response>();
resolveRefresh = deferred.resolve;
return deferred.promise;
}
}),
);
const queryClient = createTestQueryClient();
@@ -135,12 +142,12 @@ describe("AccountsPage", () => {
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(resource([account]));
if (requestCount === 2)
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
return HttpResponse.json(resource([account]));
return requestCount === 2
? HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
)
: HttpResponse.json(resource([account]));
}),
);
const queryClient = createTestQueryClient();
@@ -157,7 +164,7 @@ describe("AccountsPage", () => {
expect(warning).toBeInTheDocument();
expect(screen.getByText(account.name)).toBeInTheDocument();
screen.getByRole("button", { name: "Повторить" }).click();
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(requestCount).toBe(3));
await waitFor(() => expect(warning).not.toBeInTheDocument());
expect(screen.getByText(account.name)).toBeVisible();
@@ -169,11 +176,12 @@ describe("AccountsPage", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(resource([]));
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
return requestCount === 1
? HttpResponse.json(resource([]))
: HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
const queryClient = createTestQueryClient();
@@ -202,9 +210,11 @@ it("keeps unknown membership loading instead of declaring no accounts", async ()
const client = createTestQueryClient();
renderPage(client);
await waitFor(() =>
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
data: null,
}),
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
data: null,
}
/* eslint-enable unicorn/no-null */)),
);
expect(
screen.queryByText("Нет инвестиционных счетов"),
@@ -1,4 +1,5 @@
import type { FC } from "react";
import { useAccountsPageViewModel } from "./AccountsPage.vm";
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
import { AccountsPageError } from "./components/AccountsPageError.view";
@@ -13,20 +14,20 @@ const AccountsPage: FC = () => {
return (
<AccountsPageEmpty
lastSuccessfulAt={viewModel.lastSuccessfulAt}
refreshing={viewModel.refreshing}
sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError}
refreshing={viewModel.refreshing}
retry={viewModel.retry}
sourceError={viewModel.sourceError}
/>
);
return (
<AccountsPageLoaded
accounts={viewModel.accounts}
lastSuccessfulAt={viewModel.lastSuccessfulAt}
refreshing={viewModel.refreshing}
sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError}
refreshing={viewModel.refreshing}
retry={viewModel.retry}
sourceError={viewModel.sourceError}
/>
);
};
@@ -9,35 +9,35 @@ function useAccountsPageViewModel() {
const accounts = useBrokerageAccounts();
const sync = useScreenSync({ screen: "accounts" }, [accounts]);
if (
!accounts.data ||
(accounts.meta?.availability === "partial" && !accounts.data.length)
!accounts.data
|| (accounts.meta?.availability === "partial" && accounts.data.length === 0)
) {
if (!accounts.isError && accounts.meta?.sync.state !== "failed")
return { state: "loading" as const };
if (accounts.isError || accounts.meta?.sync.state === "failed")
return {
state: "error" as const,
code: apiErrorCode(accounts.error),
retry: sync.retry,
state: "error" as const,
};
}
if (!accounts.data?.length)
return {
state: "empty" as const,
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
refreshing: accounts.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
refreshing: accounts.isFetching || sync.refreshing,
retry: sync.retry,
sourceError: sync.sourceError,
state: "empty" as const,
};
return {
state: "loaded" as const,
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
refreshing: accounts.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
refreshing: accounts.isFetching || sync.refreshing,
retry: sync.retry,
sourceError: sync.sourceError,
state: "loaded" as const,
};
}
@@ -1,7 +1,9 @@
import type { FC } from "react";
import { Stack } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
AccountsRefreshState,
type AccountsRefreshStateProps,
@@ -13,8 +15,8 @@ const AccountsPageEmpty: FC<AccountsRefreshStateProps> = (props) => {
<Stack>
<AccountsRefreshState {...props} />
<StateView
title={t("accounts.emptyTitle")}
text={t("accounts.emptyText")}
title={t("accounts.emptyTitle")}
/>
</Stack>
);
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { Button, Stack } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -17,41 +18,41 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
if (code === "TBANK_NOT_CONNECTED")
return (
<StateView
error
title={t("accounts.notConnectedTitle")}
text={t("accounts.notConnectedText")}
action={settingsAction}
error
text={t("accounts.notConnectedText")}
title={t("accounts.notConnectedTitle")}
/>
);
if (code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("accounts.invalidTokenTitle")}
text={t("accounts.invalidTokenText")}
action={settingsAction}
error
text={t("accounts.invalidTokenText")}
title={t("accounts.invalidTokenTitle")}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={
action={(
<Stack align="flex-start">
<Button onClick={retry}>{t("accounts.retry")}</Button>
{settingsAction}
</Stack>
}
)}
error
text={t("accounts.unavailableText")}
title={t("accounts.unavailableTitle")}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
error
text={t("states.errorText")}
title={t("states.errorTitle")}
/>
);
};
@@ -1,11 +1,14 @@
import { Button, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { Button, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
type BrokerageAccountCard,
BrokerageAccountList,
} from "@/entities/brokerage-account";
import {
AccountsRefreshState,
type AccountsRefreshStateProps,
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { Card, Group, SimpleGrid, Skeleton, Stack } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
const AccountsPageLoading: FC = () => {
@@ -9,8 +10,8 @@ const AccountsPageLoading: FC = () => {
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="30%" />
<Skeleton height={20} width="45%" />
<SimpleGrid cols={1} spacing="lg" aria-hidden="true">
{[0, 1].map((index) => (
<SimpleGrid aria-hidden="true" cols={1} spacing="lg">
{[0, 1].map(index => (
<Card key={index} padding="xl" radius="lg" withBorder>
<Stack gap="xl">
<Stack gap="sm">
@@ -1,47 +1,49 @@
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib";
type AccountsRefreshStateProps = {
lastSuccessfulAt?: string | null;
sourceError?: boolean;
refreshing?: boolean;
lastSuccessfulAt?: null | string;
refreshError?: boolean;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
};
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
const { refreshing = false, refreshError = false, retry } = props;
const { refreshError = false, refreshing = false, retry } = props;
const { t } = useTranslation();
return (
<>
{props.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{!!(props.lastSuccessfulAt) && ((
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
) : null}
))}
<Button
onClick={retry}
disabled={refreshing}
onClick={retry}
variant="light"
w="fit-content"
>
{t(refreshError ? "accounts.retry" : "investment.refresh")}
</Button>
{refreshing ? (
<Group gap="xs" role="status" aria-label={t("accounts.refreshing")}>
{!!(refreshing) && ((
<Group aria-label={t("accounts.refreshing")} gap="xs" role="status">
<Loader size="sm" />
<Text c="dimmed" size="sm">
{t("accounts.refreshing")}
</Text>
</Group>
) : null}
{refreshError ? (
<Alert color="orange" role="status" aria-live="polite">
))}
{!!(refreshError) && ((
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="sm">
<Text>
{t(
@@ -52,7 +54,7 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
</Text>
</Stack>
</Alert>
) : null}
))}
</>
);
};
+178 -157
View File
@@ -1,13 +1,15 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { BondPage } from "./BondPage";
const LocationProbe = () => {
@@ -21,74 +23,78 @@ const LocationProbe = () => {
};
const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13";
const money = (value: string) => ({ value, currency: "RUB" });
const bond = {
instrumentUid: uid,
name: "Тестовая облигация",
ticker: "TEST",
nominal: money("500"),
initialNominal: money("1000"),
aci: money("12"),
maturityDate: "2030-05-15",
callDate: null,
couponQuantityPerYear: 4,
floatingCoupon: true,
amortization: true,
perpetual: false,
subordinated: false,
isin: "TESTISIN",
currency: "RUB",
sector: "financial",
lot: 10,
logoName: null,
historyFrom: null,
};
const money = (value: string) => ({ currency: "RUB", value });
const bond = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
aci: money("12"),
amortization: true,
callDate: null,
couponQuantityPerYear: 4,
currency: "RUB",
floatingCoupon: true,
historyFrom: null,
initialNominal: money("1000"),
instrumentUid: uid,
isin: "TESTISIN",
logoName: null,
lot: 10,
maturityDate: "2030-05-15",
name: "Тестовая облигация",
nominal: money("500"),
perpetual: false,
sector: "financial",
subordinated: false,
ticker: "TEST",
}
/* eslint-enable unicorn/no-null */);
const position = {
quantity: "10",
currentValue: money("3000"),
averagePurchasePrice: money("200"),
absoluteReturn: money("1000"),
returnPercent: "50",
accounts: [
{
absoluteReturn: money("1000"),
account: { id: "main", name: "Основной" },
averagePurchasePrice: money("200"),
currentValue: money("3000"),
instrumentUid: uid,
quantity: "10",
currentValue: money("3000"),
averagePurchasePrice: money("200"),
absoluteReturn: money("1000"),
returnPercent: "50",
account: { id: "main", name: "Основной" },
},
],
averagePurchasePrice: money("200"),
currentValue: money("3000"),
isStale: false,
observedAt: "2026-09-10T10:00:00Z",
quantity: "10",
returnPercent: "50",
};
const quote = {
pricePercent: "98.125",
cleanPrice: money("490.625"),
aci: money("12"),
dirtyPrice: money("502.625"),
priceAt: "2026-09-10T10:00:00Z",
tradingStatus: null,
changePercentagePoints: "-0.875",
previousCloseDate: "2026-09-09",
unavailable: [],
observedAt: "2026-09-10T10:00:00Z",
};
const quote = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
aci: money("12"),
changePercentagePoints: "-0.875",
cleanPrice: money("490.625"),
dirtyPrice: money("502.625"),
observedAt: "2026-09-10T10:00:00Z",
previousCloseDate: "2026-09-09",
priceAt: "2026-09-10T10:00:00Z",
pricePercent: "98.125",
tradingStatus: null,
unavailable: [],
}
/* eslint-enable unicorn/no-null */);
function setup(
options: {
noPosition?: boolean;
failCoupons?: boolean;
ratingState?: "active" | "withdrawn" | "missing" | "unavailable";
failBond?: boolean;
path?: string;
slug?: string;
ambiguousTicker?: boolean;
failBond?: boolean;
failCoupons?: boolean;
noPosition?: boolean;
path?: string;
ratingState?: "active" | "missing" | "unavailable" | "withdrawn";
slug?: string;
} = {},
) {
const requests: URL[] = [];
const resolved = { ...bond, slug: options.slug ?? uid };
let failQuote = false;
let isFailQuote = false;
server.use(
http.get("*/api/v1/bonds/:uid", () =>
options.failBond
@@ -101,71 +107,78 @@ function setup(
? HttpResponse.json({ code: "BOND_TICKER_AMBIGUOUS" }, { status: 409 })
: HttpResponse.json(resource(resolved));
}),
http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => {
http.get("*/api/v1/bonds/:uid/metrics", ({ request }) => {
requests.push(new URL(request.url));
if (params.section === "metrics")
return HttpResponse.json(
resource({
return HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
coupon: money("32.88"),
couponDate: "2026-10-27",
couponPeriodDays: 90,
couponType: "COUPON_TYPE_FLOATING",
couponYieldPercent: "27.18",
couponYieldPriceAt: quote.priceAt,
creditRating:
options.ratingState === "missing" ||
options.ratingState === "unavailable"
options.ratingState === "missing"
|| options.ratingState === "unavailable"
? null
: {
agency: "NCR",
value:
options.ratingState === "withdrawn" ? null : "AAA.ru",
date: "2026-04-17",
observedAt: quote.observedAt,
sourceUrl: "https://ratings.ru/ratings/issues/123/",
status:
options.ratingState === "withdrawn"
? "withdrawn"
: "active",
date: "2026-04-17",
sourceUrl: "https://ratings.ru/ratings/issues/123/",
observedAt: quote.observedAt,
value:
options.ratingState === "withdrawn" ? null : "AAA.ru",
},
coupon: money("32.88"),
couponDate: "2026-10-27",
couponType: "COUPON_TYPE_FLOATING",
couponPeriodDays: 90,
couponYieldPercent: "27.18",
couponYieldPriceAt: quote.priceAt,
effectiveYield: {
value: "15.6262",
pricePercent: "53.384",
date: "2030-05-15",
dateType: "MATDATE",
pricePercent: "53.384",
tradeAt: quote.priceAt,
value: "15.6262",
},
observedAt: quote.observedAt,
unavailable:
options.ratingState === "unavailable" ? ["rating"] : [],
observedAt: quote.observedAt,
}),
);
}
/* eslint-enable unicorn/no-null */)),
);
}),
http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => {
requests.push(new URL(request.url));
if (params.section === "quote")
return failQuote
return isFailQuote
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource(quote));
if (params.section === "position")
return HttpResponse.json(
resource(
options.noPosition
? { ...position, quantity: "0", accounts: [] }
? { ...position, accounts: [], quantity: "0" }
: position,
),
);
if (params.section === "candles")
return HttpResponse.json(
resource({
candles: [
{
time: "2026-09-09T00:00:00Z",
close: "99.25",
isComplete: true,
},
],
currency: null,
historyFrom: null,
observedAt: quote.observedAt,
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
candles: [
{
close: "99.25",
isComplete: true,
time: "2026-09-09T00:00:00Z",
},
],
currency: null,
historyFrom: null,
observedAt: quote.observedAt,
}
/* eslint-enable unicorn/no-null */)),
);
if (options.failCoupons)
return HttpResponse.json(
@@ -173,71 +186,75 @@ function setup(
{ status: 503 },
);
return HttpResponse.json(
resource({
coupons: [
{
perBond: money("32.88"),
recordDate: "2026-10-13",
number: "1",
couponType: "COUPON_TYPE_FLOATING",
periodDays: 90,
paymentDate: "2026-10-27",
timing: "upcoming",
estimatedAmount: options.noPosition ? null : money("328.8"),
},
{
perBond: money("20"),
recordDate: "2025-10-13",
paymentDate: null,
number: "2",
couponType: null,
periodDays: null,
timing: "past",
estimatedAmount: null,
},
],
from: "2021-09-10",
to: "2027-09-10",
observedAt: quote.observedAt,
positionAt: null,
estimateUnavailable: false,
positionIsStale: false,
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
coupons: [
{
couponType: "COUPON_TYPE_FLOATING",
estimatedAmount: options.noPosition ? null : money("328.8"),
number: "1",
paymentDate: "2026-10-27",
perBond: money("32.88"),
periodDays: 90,
recordDate: "2026-10-13",
timing: "upcoming",
},
{
couponType: null,
estimatedAmount: null,
number: "2",
paymentDate: null,
perBond: money("20"),
periodDays: null,
recordDate: "2025-10-13",
timing: "past",
},
],
estimateUnavailable: false,
from: "2021-09-10",
observedAt: quote.observedAt,
positionAt: null,
positionIsStale: false,
to: "2027-09-10",
}
/* eslint-enable unicorn/no-null */)),
);
}),
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
const url = new URL(request.url);
requests.push(url);
const next = url.searchParams.has("cursor");
const isNext = url.searchParams.has("cursor");
return HttpResponse.json(
resource({
accountId: "all",
observedAt: quote.observedAt,
hasNext: !next,
nextCursor: next ? null : "next",
items: [
{
id: next ? "sold" : "dividend",
occurredAt: next
? "2022-01-02T10:00:00Z"
: "2025-01-02T10:00:00Z",
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON",
category: next ? "trade" : "income",
description: "",
account: { id: "main", name: "Основной" },
payment: money("200"),
quantity: next ? "2" : null,
price: next ? money("100") : null,
},
],
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accountId: "all",
hasNext: !isNext,
items: [
{
account: { id: "main", name: "Основной" },
category: isNext ? "trade" : "income",
description: "",
id: isNext ? "sold" : "dividend",
occurredAt: isNext
? "2022-01-02T10:00:00Z"
: "2025-01-02T10:00:00Z",
payment: money("200"),
price: isNext ? money("100") : null,
quantity: isNext ? "2" : null,
type: isNext ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON",
},
],
nextCursor: isNext ? null : "next",
observedAt: quote.observedAt,
}
/* eslint-enable unicorn/no-null */)),
);
}),
);
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => false,
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
const client = createTestQueryClient();
render(
@@ -246,17 +263,17 @@ function setup(
<MemoryRouter initialEntries={[options.path ?? `/bonds/${uid}`]}>
<LocationProbe />
<Routes>
<Route path="/bonds/:bondId" element={<BondPage />} />
<Route element={<BondPage />} path="/bonds/:bondId" />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return {
requests,
failQuote: () => {
failQuote = true;
isFailQuote = true;
},
requests,
};
}
describe("bond page", () => {
@@ -280,11 +297,11 @@ describe("bond page", () => {
expect(await screen.findByText("328,8 ₽")).toBeVisible();
await waitFor(() =>
expect(
requests.some((url) => url.searchParams.get("instrumentUid") === uid),
requests.some(url => url.searchParams.get("instrumentUid") === uid),
).toBe(true),
);
expect(
requests.some((url) => url.pathname === `/api/v1/bonds/${uid}/quote`),
requests.some(url => url.pathname === `/api/v1/bonds/${uid}/quote`),
).toBe(true);
fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ }));
await waitFor(() => expect(screen.getByText("99,25 %")).toBeVisible());
@@ -317,11 +334,13 @@ describe("bond page", () => {
);
});
it("keeps market content and sold-position history when coupons fail", async () => {
setup({ noPosition: true, failCoupons: true });
setup({ failCoupons: true, noPosition: true });
await screen.findByText("98,125 %");
const message = await screen.findByText(/Не удалось загрузить этот раздел/);
expect(
(await screen.findByText(/Не удалось загрузить этот раздел/)).closest(
'[role="status"]',
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest(
"[role=\"status\"]",
),
).toHaveAttribute("aria-live", "polite");
expect(
@@ -346,32 +365,34 @@ describe("bond page", () => {
await screen.findByText("98,125 %");
controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
const message = await screen.findByText(/последние полученные/);
expect(
(await screen.findByText(/последние полученные/)).closest(
'[role="status"]',
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest(
"[role=\"status\"]",
),
).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("98,125 %")).toBeVisible();
expect(scopes).toEqual([
{
screen: "bond",
instrumentUid: uid,
accountId: "all",
candlePeriod: "year",
categories: ["trade"],
instrumentUid: uid,
limit: 20,
screen: "bond",
},
]);
});
it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
const { requests } = setup({ path: "/bonds/SAME", ambiguousTicker: true });
const { requests } = setup({ ambiguousTicker: true, path: "/bonds/SAME" });
expect(
await screen.findByText(/Тикер соответствует нескольким выпускам/),
).toBeVisible();
expect(
screen.getByRole("link", { name: "К поиску облигаций" }),
).toHaveAttribute("href", "/bonds");
expect(requests.some((url) => url.pathname.endsWith("/position"))).toBe(
expect(requests.some(url => url.pathname.endsWith("/position"))).toBe(
false,
);
});
@@ -384,16 +405,16 @@ describe("bond page", () => {
await waitFor(() =>
expect(
requests.some(
(url) =>
url.searchParams.get("categories") === "income" &&
!url.searchParams.has("cursor"),
url =>
url.searchParams.get("categories") === "income"
&& !url.searchParams.has("cursor"),
),
).toBe(true),
);
fireEvent.click(screen.getByRole("radio", { name: "Год" }));
await waitFor(() =>
expect(
requests.some((url) => url.searchParams.get("period") === "year"),
requests.some(url => url.searchParams.get("period") === "year"),
).toBe(true),
);
});
+9 -8
View File
@@ -1,15 +1,17 @@
import type { FC } from "react";
import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link, Navigate } from "react-router";
import { BondPageView } from "./BondPage.view";
import { useBondPageVm } from "./BondPage.vm";
const BondPage: FC = () => {
const vm = useBondPageVm();
const { t } = useTranslation();
if (vm.canonicalTo) return <Navigate to={vm.canonicalTo} replace />;
if (vm.canonicalTo) return <Navigate replace to={vm.canonicalTo} />;
if (vm.bondState.loading)
return (
<LoadingSkeleton label={t("bond.loading")}>
@@ -20,18 +22,17 @@ const BondPage: FC = () => {
</Stack>
</LoadingSkeleton>
);
const unavailableMessage = vm.code === "BOND_TICKER_AMBIGUOUS" ? "bond.ambiguousTicker" : "bond.unavailableText";
if (!vm.bond)
return (
<Stack role="alert">
<Title order={1}>{t("bond.unavailable")}</Title>
<Text>
{t(
vm.code === "TBANK_NOT_CONNECTED" ||
vm.code === "INVALID_TBANK_TOKEN"
vm.code === "TBANK_NOT_CONNECTED"
|| vm.code === "INVALID_TBANK_TOKEN"
? "bond.connect"
: vm.code === "BOND_TICKER_AMBIGUOUS"
? "bond.ambiguousTicker"
: "bond.unavailableText",
: unavailableMessage,
)}
</Text>
<Anchor component={Link} to="/bonds">
@@ -40,7 +41,7 @@ const BondPage: FC = () => {
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button variant="default" onClick={vm.bondState.retry}>
<Button onClick={vm.bondState.retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
+322 -290
View File
@@ -1,3 +1,5 @@
import type { FC } from "react";
import {
Accordion,
Alert,
@@ -13,8 +15,8 @@ import {
Title,
} from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
InstrumentChart,
InstrumentEvents,
@@ -27,133 +29,143 @@ import {
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
import classes from "./BondPage.module.css";
import type { BondPageVm } from "./BondPage.vm";
import classes from "./BondPage.module.css";
type BondPageViewProps = { vm: BondPageVm };
const BondPageView: FC<BondPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const bond = vm.bond;
if (!bond) return null;
if (!bond) return;
const maturity = bond.maturityDate ? formatDate(bond.maturityDate) : t("bond.unknown");
return (
<Stack className={classes.page} gap="lg">
<Paper component="header" className={classes.section} withBorder>
<Group justify="space-between" align="start">
<Paper className={classes.section} component="header" withBorder>
<Group align="start" justify="space-between">
<div className={classes.identity}>
<Avatar src={vm.logoSrc} alt="" size={56} radius="md">
<Avatar alt="" radius="md" size={56} src={vm.logoSrc}>
{bond.name[0]}
</Avatar>
<div>
<Title order={1} size="h2">
{bond.name}
</Title>
<Text size="sm" c="dimmed">
{bond.ticker} · {bond.isin}
<Text c="dimmed" size="sm">
{bond.ticker}
{" "}
·
{" "}
{bond.isin}
</Text>
</div>
</div>
<Button
variant="subtle"
onClick={vm.refresh}
disabled={vm.refreshing}
onClick={vm.refresh}
variant="subtle"
>
{t("investment.refresh")}
</Button>
</Group>
<Group mt="md" gap="xs">
{bond.floatingCoupon ? (
<Group gap="xs" mt="md">
{!!(bond.floatingCoupon) && ((
<Badge variant="light">{t("bond.floatingCoupon")}</Badge>
) : null}
{bond.amortization ? (
))}
{!!(bond.amortization) && ((
<Badge variant="light">{t("bond.amortization")}</Badge>
) : null}
{bond.perpetual ? (
))}
{!!(bond.perpetual) && ((
<Badge variant="light">{t("bond.perpetual")}</Badge>
) : null}
{bond.subordinated ? (
<Badge variant="light" color="orange">
))}
{!!(bond.subordinated) && ((
<Badge color="orange" variant="light">
{t("bond.subordinated")}
</Badge>
) : null}
))}
</Group>
{vm.refreshError || vm.bondState.error || vm.quoteState.error ? (
<Alert role="status" aria-live="polite" color="orange" mt="md">
{!!(vm.refreshError || vm.bondState.error || vm.quoteState.error) && ((
<Alert aria-live="polite" color="orange" mt="md" role="status">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
) : null}
{vm.quoteState.loading ? (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton mt="lg" h={55} w="50%" />
</LoadingSkeleton>
) : (
<Stack gap="xs" mt="lg" aria-live="polite">
<Text size="sm" c="dimmed">
{t("bond.percentOfNominal")}
</Text>
<Text className={classes.price}>
{vm.quote?.pricePercent != null
? `${formatDecimal(vm.quote.pricePercent)} %`
: t("bond.unknown")}
</Text>
{vm.quote?.changePercentagePoints != null ? (
<Text fw={600}>
{formatDecimal(vm.quote.changePercentagePoints)}{" "}
{t("bond.percentagePoints")}
</Text>
) : null}
<Text size="xs" c="dimmed">
{vm.quote?.previousCloseDate
? t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})
: t("stock.noComparison")}
</Text>
<Group gap="xl" my="sm">
<Metric
label={t("bond.cleanPrice")}
value={formatMoney(vm.quote?.cleanPrice ?? null)}
/>
<Metric
label={t("bond.aci")}
value={formatMoney(vm.quote?.aci ?? null)}
/>
<Metric
label={t("bond.dirtyPrice")}
value={formatMoney(vm.quote?.dirtyPrice ?? null)}
/>
</Group>
<Text size="xs" c="dimmed">
{t("bond.indicativePrice")}
</Text>
<Group>
<Badge color="gray" variant="light">
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{vm.quote?.priceAt ? (
<Text size="xs" c="dimmed">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
))}
{vm.quoteState.loading
? (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton h={55} mt="lg" w="50%" />
</LoadingSkeleton>
)
: (
<Stack aria-live="polite" gap="xs" mt="lg">
<Text c="dimmed" size="sm">
{t("bond.percentOfNominal")}
</Text>
) : null}
</Group>
{vm.quote?.unavailable.length ? (
<Text size="xs" c="dimmed">
{t("stock.partialQuote")}
</Text>
) : null}
</Stack>
)}
{vm.quoteState.refreshing || vm.bondState.refreshing ? (
<Text role="status" size="xs" c="dimmed">
<Text className={classes.price}>
{vm.quote?.pricePercent == undefined
? t("bond.unknown")
: `${formatDecimal(vm.quote.pricePercent)} %`}
</Text>
{vm.quote?.changePercentagePoints != undefined && ((
<Text fw={600}>
{formatDecimal(vm.quote.changePercentagePoints)}
{" "}
{t("bond.percentagePoints")}
</Text>
))}
<Text c="dimmed" size="xs">
{vm.quote?.previousCloseDate
? t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})
: t("stock.noComparison")}
</Text>
<Group gap="xl" my="sm">
<Metric
label={t("bond.cleanPrice")}
value={formatMoney(vm.quote?.cleanPrice)}
/>
<Metric
label={t("bond.aci")}
value={formatMoney(vm.quote?.aci)}
/>
<Metric
label={t("bond.dirtyPrice")}
value={formatMoney(vm.quote?.dirtyPrice)}
/>
</Group>
<Text c="dimmed" size="xs">
{t("bond.indicativePrice")}
</Text>
<Group>
<Badge color="gray" variant="light">
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{!!(vm.quote?.priceAt) && ((
<Text c="dimmed" size="xs">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
</Text>
))}
</Group>
{!!(vm.quote?.unavailable.length) && ((
<Text c="dimmed" size="xs">
{t("stock.partialQuote")}
</Text>
))}
</Stack>
)}
{!!(vm.quoteState.refreshing || vm.bondState.refreshing) && ((
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
) : null}
))}
<div className={classes.embeddedSection}>
<Title order={2} size="h3" mb="md">
<Title mb="md" order={2} size="h3">
{t("bond.terms")}
</Title>
<div className={classes.companyMetrics}>
@@ -170,9 +182,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
value={
bond.perpetual
? t("bond.perpetual")
: bond.maturityDate
? formatDate(bond.maturityDate)
: t("bond.unknown")
: maturity
}
/>
<Metric
@@ -184,207 +194,226 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
<Metric
label={t("bond.couponFrequency")}
value={
bond.couponQuantityPerYear != null
? String(bond.couponQuantityPerYear)
: t("bond.unknown")
bond.couponQuantityPerYear == undefined
? t("bond.unknown")
: String(bond.couponQuantityPerYear)
}
/>
<Metric
label={t("bond.lot")}
value={bond.lot != null ? String(bond.lot) : t("bond.unknown")}
value={bond.lot == undefined ? t("bond.unknown") : String(bond.lot)}
/>
</div>
</div>
</Paper>
<InstrumentSection title={t("bond.metrics")} state={vm.metricsState}>
{vm.metrics ? (
<Stack gap="md">
<div className={classes.companyMetrics}>
<Metric
label={t("bond.creditRating")}
value={
vm.metrics.creditRating
? vm.metrics.creditRating.status === "withdrawn"
? t("bond.ratingWithdrawn")
: (vm.metrics.creditRating.value ?? t("bond.unknown"))
: vm.metrics.unavailable.includes("rating")
? t("bond.ratingUnavailable")
: t("bond.ratingMissing")
}
/>
<Metric
label={t("bond.couponAmount")}
value={formatMoney(vm.metrics.coupon)}
/>
<Metric
label={t("bond.couponType")}
value={t(`bond.couponTypes.${vm.metrics.couponType}`, {
defaultValue: t("bond.unknown"),
})}
/>
<Metric
label={t("bond.couponDate")}
value={
vm.metrics.couponDate
? formatDate(vm.metrics.couponDate)
: t("bond.unknown")
}
/>
<Metric
label={t("bond.effectiveYield")}
value={
vm.metrics.effectiveYield
? `${formatDecimal(vm.metrics.effectiveYield.value)} %`
: t("bond.unknown")
}
/>
<Metric
label={t("bond.couponYield")}
value={
vm.metrics.couponYieldPercent !== null
? `${formatDecimal(vm.metrics.couponYieldPercent)} %`
: t("bond.unknown")
}
/>
</div>
{vm.metrics.creditRating ? (
<Group gap="xs">
<Anchor
href={vm.metrics.creditRating.sourceUrl}
target="_blank"
rel="noopener noreferrer"
size="sm"
>
{t("bond.ratingSource", {
date: formatDate(vm.metrics.creditRating.date),
})}
</Anchor>
<Text size="xs" c="dimmed">
{t("bond.ratingChecked", {
date: formatSnapshotTime(
vm.metrics.creditRating.observedAt,
),
})}
</Text>
</Group>
) : null}
<Text size="xs" c="dimmed">
{t("bond.ratingCoverage")}
</Text>
{vm.metrics.effectiveYield ? (
<Text size="xs" c="dimmed">
{t("bond.yieldSource", {
target: t(
`bond.yieldTargets.${vm.metrics.effectiveYield.dateType}`,
{ defaultValue: t("bond.yieldTargets.unknown") },
),
date: formatDate(vm.metrics.effectiveYield.date),
price: formatDecimal(vm.metrics.effectiveYield.pricePercent),
at: formatSnapshotTime(vm.metrics.effectiveYield.tradeAt),
})}
</Text>
) : (
<Text size="xs" c="dimmed">
{t("bond.yieldUnavailable")}
</Text>
)}
<Text size="xs" c="dimmed">
{t("bond.couponYieldNote")}
</Text>
{vm.metrics.couponYieldPriceAt ? (
<Text size="xs" c="dimmed">
{t("bond.couponYieldPriceAt", {
date: formatSnapshotTime(vm.metrics.couponYieldPriceAt),
})}
</Text>
) : null}
{vm.metrics.unavailable.length ? (
<Text size="xs" c="dimmed">
{t("bond.metricsPartial")}
</Text>
) : null}
</Stack>
) : null}
</InstrumentSection>
<InstrumentChart vm={vm} priceUnit="percent" />
<BondMetrics vm={vm} />
<InstrumentChart priceUnit="percent" vm={vm} />
<InstrumentPosition
vm={vm}
returnNote={t("bond.returnNote")}
quantityLabel={t("bond.quantity")}
/>
<InstrumentSection
title={t("bond.upcomingCoupons")}
state={vm.couponsState}
>
{vm.coupons ? (
<Stack gap="md">
{vm.coupons.estimateUnavailable || vm.coupons.positionIsStale ? (
<Alert color="orange" role="status" aria-live="polite">
{t("stock.estimateUnavailable")}
</Alert>
) : null}
{vm.upcoming.length ? (
vm.upcoming.map((coupon) => (
<Coupon
key={`${coupon.number}:${coupon.paymentDate}`}
coupon={coupon}
/>
))
) : (
<Text c="dimmed">{t("bond.noCoupons")}</Text>
)}
<Text size="xs" c="dimmed">
{t("bond.couponWindow", {
from: formatDate(vm.coupons.from),
to: formatDate(vm.coupons.to),
})}
</Text>
<Text size="xs" c="dimmed">
{t("bond.couponNote")}
</Text>
{vm.coupons.positionAt ? (
<Text size="xs" c="dimmed">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.coupons.positionAt),
})}
</Text>
) : null}
<Accordion variant="contained" radius="md">
<Accordion.Item value="history">
<Accordion.Control>{t("bond.couponHistory")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{vm.pastCoupons.length ? (
vm.pastCoupons.map((coupon) => (
<Coupon
key={`${coupon.number}:${coupon.paymentDate}`}
coupon={coupon}
/>
))
) : (
<Text c="dimmed">{t("bond.noCoupons")}</Text>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
) : null}
</InstrumentSection>
<InstrumentEvents
returnNote={t("bond.returnNote")}
vm={vm}
incomeLabel={t("bond.coupons")}
incomeFilter="coupons"
/>
<BondCoupons vm={vm} />
<InstrumentEvents
historyNote={t("bond.historyNote")}
incomeFilter="coupons"
incomeLabel={t("bond.coupons")}
vm={vm}
/>
</Stack>
);
};
const BondMetrics: FC<BondPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const creditRating = vm.metrics?.creditRating;
let rating = t(vm.metrics?.unavailable.includes("rating") ? "bond.ratingUnavailable" : "bond.ratingMissing");
if (creditRating) rating = creditRating.status === "withdrawn" ? t("bond.ratingWithdrawn") : (creditRating.value ?? t("bond.unknown"));
return (
<InstrumentSection state={vm.metricsState} title={t("bond.metrics")}>
{!!(vm.metrics) && ((
<Stack gap="md">
<div className={classes.companyMetrics}>
<Metric
label={t("bond.creditRating")}
value={
rating
}
/>
<Metric
label={t("bond.couponAmount")}
value={formatMoney(vm.metrics.coupon)}
/>
<Metric
label={t("bond.couponType")}
value={t(`bond.couponTypes.${vm.metrics.couponType}`, {
defaultValue: t("bond.unknown"),
})}
/>
<Metric
label={t("bond.couponDate")}
value={
vm.metrics.couponDate
? formatDate(vm.metrics.couponDate)
: t("bond.unknown")
}
/>
<Metric
label={t("bond.effectiveYield")}
value={
vm.metrics.effectiveYield
? `${formatDecimal(vm.metrics.effectiveYield.value)} %`
: t("bond.unknown")
}
/>
<Metric
label={t("bond.couponYield")}
value={
vm.metrics.couponYieldPercent === null
? t("bond.unknown")
: `${formatDecimal(vm.metrics.couponYieldPercent)} %`
}
/>
</div>
{!!(vm.metrics.creditRating) && ((
<Group gap="xs">
<Anchor
href={vm.metrics.creditRating.sourceUrl}
rel="noopener noreferrer"
size="sm"
target="_blank"
>
{t("bond.ratingSource", {
date: formatDate(vm.metrics.creditRating.date),
})}
</Anchor>
<Text c="dimmed" size="xs">
{t("bond.ratingChecked", {
date: formatSnapshotTime(
vm.metrics.creditRating.observedAt,
),
})}
</Text>
</Group>
))}
<Text c="dimmed" size="xs">
{t("bond.ratingCoverage")}
</Text>
{vm.metrics.effectiveYield
? (
<Text c="dimmed" size="xs">
{t("bond.yieldSource", {
at: formatSnapshotTime(vm.metrics.effectiveYield.tradeAt),
date: formatDate(vm.metrics.effectiveYield.date),
price: formatDecimal(vm.metrics.effectiveYield.pricePercent),
target: t(
`bond.yieldTargets.${vm.metrics.effectiveYield.dateType}`,
{ defaultValue: t("bond.yieldTargets.unknown") },
),
})}
</Text>
)
: (
<Text c="dimmed" size="xs">
{t("bond.yieldUnavailable")}
</Text>
)}
<Text c="dimmed" size="xs">
{t("bond.couponYieldNote")}
</Text>
{!!(vm.metrics.couponYieldPriceAt) && ((
<Text c="dimmed" size="xs">
{t("bond.couponYieldPriceAt", {
date: formatSnapshotTime(vm.metrics.couponYieldPriceAt),
})}
</Text>
))}
{vm.metrics.unavailable.length > 0 && ((
<Text c="dimmed" size="xs">
{t("bond.metricsPartial")}
</Text>
))}
</Stack>
))}
</InstrumentSection>
);
};
const BondCoupons: FC<BondPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<InstrumentSection
state={vm.couponsState}
title={t("bond.upcomingCoupons")}
>
{!!(vm.coupons) && ((
<Stack gap="md">
{!!(vm.coupons.estimateUnavailable || vm.coupons.positionIsStale) && ((
<Alert aria-live="polite" color="orange" role="status">
{t("stock.estimateUnavailable")}
</Alert>
))}
{vm.upcoming.length > 0
? (
vm.upcoming.map(coupon => (
<Coupon
coupon={coupon}
key={`${coupon.number}:${coupon.paymentDate}`}
/>
))
)
: (
<Text c="dimmed">{t("bond.noCoupons")}</Text>
)}
<Text c="dimmed" size="xs">
{t("bond.couponWindow", {
from: formatDate(vm.coupons.from),
to: formatDate(vm.coupons.to),
})}
</Text>
<Text c="dimmed" size="xs">
{t("bond.couponNote")}
</Text>
{!!(vm.coupons.positionAt) && ((
<Text c="dimmed" size="xs">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.coupons.positionAt),
})}
</Text>
))}
<Accordion radius="md" variant="contained">
<Accordion.Item value="history">
<Accordion.Control>{t("bond.couponHistory")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{vm.pastCoupons.length > 0
? (
vm.pastCoupons.map(coupon => (
<Coupon
coupon={coupon}
key={`${coupon.number}:${coupon.paymentDate}`}
/>
))
)
: (
<Text c="dimmed">{t("bond.noCoupons")}</Text>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
))}
</InstrumentSection>
);
};
type MetricProps = { label: string; value: string };
const Metric: FC<MetricProps> = (props) => (
const Metric: FC<MetricProps> = props => (
<div>
<Text size="xs" c="dimmed">
<Text c="dimmed" size="xs">
{props.label}
</Text>
<Text fw={600} size="lg">
@@ -399,44 +428,47 @@ const Coupon: FC<CouponProps> = (props) => {
const { coupon } = props;
const { t } = useTranslation();
return (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" align="start">
<Paper p="md" radius="md" withBorder>
<Group align="start" justify="space-between">
<div>
<Text fw={600}>
{coupon.paymentDate
? formatDate(coupon.paymentDate)
: t("stock.dateUnknown")}
</Text>
<Text size="xs" c="dimmed">
<Text c="dimmed" size="xs">
{t("bond.couponNumber", {
number: coupon.number ?? t("bond.unknown"),
})}
</Text>
</div>
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
{coupon.estimatedAmount ? (
{!!(coupon.estimatedAmount) && ((
<Metric
label={t("stock.yourEstimate")}
value={formatMoney(coupon.estimatedAmount)}
/>
) : null}
))}
</Group>
<Group mt="sm" gap="md">
<Badge variant="light" color="gray">
<Group gap="md" mt="sm">
<Badge color="gray" variant="light">
{t(`bond.couponTypes.${coupon.couponType}`, {
defaultValue: t("bond.unknown"),
})}
</Badge>
{coupon.periodDays != null ? (
<Text size="xs" c="dimmed">
{coupon.periodDays != undefined && ((
<Text c="dimmed" size="xs">
{t("bond.couponDays", { count: coupon.periodDays })}
</Text>
) : null}
{coupon.recordDate ? (
<Text size="xs" c="dimmed">
{t("stock.record")}: {formatDate(coupon.recordDate)}
))}
{!!(coupon.recordDate) && ((
<Text c="dimmed" size="xs">
{t("stock.record")}
:
{" "}
{formatDate(coupon.recordDate)}
</Text>
) : null}
))}
</Group>
</Paper>
);
+70 -67
View File
@@ -1,4 +1,7 @@
import { useParams, useSearchParams } from "react-router";
import type { OperationCategory } from "@/entities/broker-operation";
import {
type BondPeriod,
useBond,
@@ -8,7 +11,6 @@ import {
useBondPosition,
useBondQuote,
} from "@/entities/bond";
import type { OperationCategory } from "@/entities/broker-operation";
import {
section,
useInstrumentOperations,
@@ -22,111 +24,112 @@ const filters = ["all", "trades", "coupons", "other"] as const;
type BondEventFilter = (typeof filters)[number];
const categories: Record<BondEventFilter, OperationCategory[]> = {
all: [],
trades: ["trade"],
coupons: ["income"],
other: ["tax", "fee", "transfer", "other"],
trades: ["trade"],
};
type BondPageVm = ReturnType<typeof useBondPageVm>;
function useBondPageVm() {
const { bondId = "" } = useParams();
const bond = useBond(bondId);
const uid = bond.data?.instrumentUid ?? "";
const [params, setParams] = useSearchParams();
const period =
periods.find((value) => value === params.get("period")) ?? "month";
const filter =
filters.find((value) => value === params.get("events")) ?? "all";
const [parameters, setParameters] = useSearchParams();
const period
= periods.find(value => value === parameters.get("period")) ?? "month";
const filter
= filters.find(value => value === parameters.get("events")) ?? "all";
const quote = useBondQuote(uid);
const metrics = useBondMetrics(uid);
const candles = useBondCandles(uid, period);
const position = useBondPosition(uid);
const coupons = useBondCoupons(uid);
const history = useInstrumentOperations(uid, filter, categories[filter]);
const history = useInstrumentOperations(uid, filter, /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter]);
const { operations } = history;
const sync = useScreenSync(
{
screen: "bond",
instrumentUid: uid,
candlePeriod: period,
accountId: "all",
categories: categories[filter],
candlePeriod: period,
categories: /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter],
instrumentUid: uid,
limit: 20,
screen: "bond",
},
[bond, quote, candles, position, metrics, coupons, operations],
);
const setParam = (key: string, value: string) => {
const next = new URLSearchParams(params);
const setParameter = (key: string, value: string) => {
const next = new URLSearchParams(parameters);
next.set(key, value);
setParams(next, { replace: true });
setParameters(next, { replace: true });
};
const chartData =
candles.data?.candles.map((candle) => ({
const chartData
= candles.data?.candles.map(candle => ({
...candle,
date: formatDate(candle.time),
price: Number(candle.close),
})) ?? [];
const search = parameters.size > 0 ? `?${parameters.toString()}` : "";
return {
refresh: sync.refresh,
refreshing: sync.refreshing,
refreshError: sync.transportError || sync.sourceError,
canonicalTo:
bond.data?.slug && bond.data.slug !== bondId
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
averageLine: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
bond: bond.data,
bondState: section(bond, uid ? sync.retry : () => bond.refetch()),
candles: candles.data,
candlesState: section(candles, sync.retry),
canonicalTo:
bond.data?.slug && bond.data.slug !== bondId
? `/bonds/${encodeURIComponent(bond.data.slug)}${search}`
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
chartData,
code: apiErrorCode(bond.error),
coupons: coupons.data,
couponsState: section(coupons, sync.retry),
eventRows:
operations.data?.items.map(item => ({
...item,
logoName: item.logoName ?? bond.data?.logoName ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
ticker: item.ticker ?? bond.data?.ticker ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
})) ?? [],
filter,
hasIncompleteCandle:
candles.data?.candles.some(candle => !candle.isComplete) ?? false,
hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(bond.data?.logoName),
metrics: metrics.data,
metricsState: section(metrics, sync.retry),
quote: quote.data,
quoteState: section(quote, sync.retry),
candles: candles.data,
chartData,
priceRows: chartData.toReversed(),
candlesState: section(candles, sync.retry),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine: null,
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
positionState: section(position, sync.retry),
coupons: coupons.data,
couponsState: section(coupons, sync.retry),
upcoming:
coupons.data?.coupons
.filter((event) => event.timing === "upcoming")
.sort((a, b) =>
(a.paymentDate ?? a.recordDate ?? "").localeCompare(
b.paymentDate ?? b.recordDate ?? "",
),
) ?? [],
pastCoupons:
coupons.data?.coupons
.filter((event) => event.timing !== "upcoming")
.sort((a, b) =>
(b.paymentDate ?? b.recordDate ?? "").localeCompare(
a.paymentDate ?? a.recordDate ?? "",
),
) ?? [],
onNext: history.onNext,
onPrevious: history.onPrevious,
operations: operations.data,
operationsState: {
...section(operations, sync.retry),
retry: () => history.retry(sync.retry),
},
eventRows:
operations.data?.items.map((item) => ({
...item,
ticker: item.ticker ?? bond.data?.ticker ?? null,
logoName: item.logoName ?? bond.data?.logoName ?? null,
})) ?? [],
filter,
setFilter: (value: string) => setParam("events", value),
hasPrevious: history.hasPrevious,
onPrevious: history.onPrevious,
onNext: history.onNext,
pastCoupons:
coupons.data?.coupons
.filter(event => event.timing !== "upcoming")
.toSorted((a, b) =>
(b.paymentDate ?? b.recordDate ?? "").localeCompare(
a.paymentDate ?? a.recordDate ?? "",
),
) ?? [],
period,
position: position.data,
positionState: section(position, sync.retry),
priceRows: chartData.toReversed(),
quote: quote.data,
quoteState: section(quote, sync.retry),
refresh: sync.refresh,
refreshError: sync.transportError || sync.sourceError,
refreshing: sync.refreshing,
setFilter: (value: string) => setParameter("events", value),
setPeriod: (value: string) => setParameter("period", value),
upcoming:
coupons.data?.coupons
.filter(event => event.timing === "upcoming")
.toSorted((a, b) =>
(a.paymentDate ?? a.recordDate ?? "").localeCompare(
b.paymentDate ?? b.recordDate ?? "",
),
) ?? [],
};
}
type BondPageVm = ReturnType<typeof useBondPageVm>;
export { type BondPageVm, useBondPageVm };
+14 -12
View File
@@ -1,13 +1,15 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { BondsPage } from "./BondsPage";
describe("bond search", () => {
@@ -19,20 +21,20 @@ describe("bond search", () => {
return HttpResponse.json(
resource([
{
name: "ОФЗ",
ticker: "SU26238RMFS4",
instrumentUid: "unique-bond",
slug: "SU26238RMFS4",
logoName: "sber.png",
classCode: "TQOB",
isin: "RU0009029540",
realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON",
apiTradeAvailable: true,
pricePercent: "98.125",
classCode: "TQOB",
instrumentUid: "unique-bond",
isin: "RU0009029540",
logoName: "sber.png",
maturityDate: "2030-05-15",
name: "ОФЗ",
perpetual: false,
priceAt: "2026-09-10T10:00:00Z",
pricePercent: "98.125",
realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON",
slug: "SU26238RMFS4",
ticker: "SU26238RMFS4",
},
]),
);
@@ -40,8 +42,8 @@ describe("bond search", () => {
);
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => false,
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
@@ -1,4 +1,5 @@
import type { FC } from "react";
import { BondsPageView } from "./BondsPage.view";
import { useBondsPageVm } from "./BondsPage.vm";
+141 -118
View File
@@ -1,3 +1,5 @@
import type { FC } from "react";
import {
Alert,
Anchor,
@@ -13,22 +15,145 @@ import {
Title,
} from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
import classes from "./BondsPage.module.css";
import type { BondsPageVm } from "./BondsPage.vm";
import classes from "./BondsPage.module.css";
type BondsPageViewProps = { vm: BondsPageVm };
const BondsPageView: FC<BondsPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
function renderResults() {
if (vm.loading) {
return (
<LoadingSkeleton label={t("bond.searching")}>
<Stack>
<Skeleton h={78} />
<Skeleton h={78} />
<Skeleton h={78} />
</Stack>
</LoadingSkeleton>
);
}
if (!vm.ready) return;
if (vm.items.length > 0) {
return (
<Stack gap="sm">
<Text c="dimmed" size="xs">
{t("bond.searchVariants")}
</Text>
{vm.items.map(bond => (
<Paper
className={classes.card}
component={Link}
key={bond.instrumentUid}
to={bond.to}
withBorder
>
<Avatar alt="" radius="md" size={48} src={bond.logoSrc}>
{bond.name[0]}
</Avatar>
<div className={classes.identity}>
<Text fw={600} size="lg">
{bond.name}
</Text>
<Text c="dimmed" size="sm">
{bond.ticker}
{" "}
·
{" "}
{bond.perpetual
? t("bond.perpetual")
: t("bond.matures", {
date: bond.maturityDate
? formatDate(bond.maturityDate)
: t("bond.unknown"),
})}
</Text>
<Group gap="xs" mt="sm">
<Badge color="gray" variant="light">
{t(`stock.exchanges.${bond.realExchange}`, {
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{!!(bond.classCode) && ((
<Text c="dimmed" size="xs">
{t("stock.classCode", { code: bond.classCode })}
</Text>
))}
</Group>
{!!(bond.isin) && ((
<Text c="dimmed" mt={4} size="xs">
{t("stock.isin", { value: bond.isin })}
</Text>
))}
<Text c="dimmed" mt={4} size="xs">
{t("bond.nominal")}
:
{" "}
{formatMoney(bond.nominal)}
{" "}
·
{" "}
{t("bond.couponFrequency")}
:
{" "}
{bond.couponQuantityPerYear ?? t("bond.unknown")}
</Text>
{!!(bond.floatingCoupon) && ((
<Badge mt="xs" variant="light">
{t("bond.floatingCoupon")}
</Badge>
))}
{(bond.apiTradeAvailable === false) && ((
<Text c="dimmed" mt={4} size="xs">
{t("stock.apiUnavailable")}
</Text>
))}
</div>
<div className={classes.quote}>
<Text c="dimmed" size="xs">
{t("bond.percentOfNominal")}
</Text>
<Text fw={650} size="xl">
{bond.pricePercent === null
? t("bond.unknown")
: `${formatDecimal(bond.pricePercent)} %`}
</Text>
<Text c="dimmed" size="xs">
{bond.pricePercent !== null && bond.priceAt
? t("stock.priceAt", {
date: formatSnapshotTime(bond.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
{(bond.apiTradeAvailable === true) && ((
<Text c="green.8" mt="sm" size="xs">
{t("stock.apiAvailable")}
</Text>
))}
</div>
</Paper>
))}
</Stack>
);
}
return (
<Text c="dimmed">
{t(vm.partial ? "investment.partial" : "bond.noSearchResults")}
</Text>
);
}
return (
<Stack gap="lg">
<div>
@@ -38,33 +163,33 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
</Text>
</div>
<Button
disabled={vm.refreshing || !vm.canSearch}
onClick={vm.retry}
disabled={vm.refreshing || vm.query.trim().length < 2}
w="fit-content"
>
{t("investment.refresh")}
</Button>
{vm.partial ? (
{!!(vm.partial) && ((
<Alert role="status">{t("investment.partial")}</Alert>
) : null}
))}
<TextInput
label={t("bond.searchLabel")}
maxLength={80}
onChange={event => vm.setQuery(event.currentTarget.value)}
placeholder={t("bond.searchPlaceholder")}
size="md"
maxLength={80}
value={vm.query}
onChange={(event) => vm.setQuery(event.currentTarget.value)}
/>
{vm.query.trim().length < 2 ? (
{!(vm.canSearch) && ((
<Text c="dimmed">{t("stock.searchHint")}</Text>
) : null}
{vm.error ? (
<Alert role="status" aria-live="polite" color="orange">
))}
{!!(vm.error) && ((
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="xs">
<Text>
{t(
vm.code === "TBANK_NOT_CONNECTED" ||
vm.code === "INVALID_TBANK_TOKEN"
vm.code === "TBANK_NOT_CONNECTED"
|| vm.code === "INVALID_TBANK_TOKEN"
? "bond.connect"
: "stock.sectionError",
)}
@@ -72,115 +197,13 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button variant="default" onClick={vm.retry}>
<Button onClick={vm.retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
</Alert>
) : null}
{vm.loading ? (
<LoadingSkeleton label={t("bond.searching")}>
<Stack>
<Skeleton h={78} />
<Skeleton h={78} />
<Skeleton h={78} />
</Stack>
</LoadingSkeleton>
) : vm.ready ? (
vm.items.length ? (
<Stack gap="sm">
<Text size="xs" c="dimmed">
{t("bond.searchVariants")}
</Text>
{vm.items.map((bond) => (
<Paper
component={Link}
to={bond.to}
className={classes.card}
withBorder
key={bond.instrumentUid}
>
<Avatar src={bond.logoSrc} alt="" radius="md" size={48}>
{bond.name[0]}
</Avatar>
<div className={classes.identity}>
<Text fw={600} size="lg">
{bond.name}
</Text>
<Text size="sm" c="dimmed">
{bond.ticker} ·{" "}
{bond.perpetual
? t("bond.perpetual")
: t("bond.matures", {
date: bond.maturityDate
? formatDate(bond.maturityDate)
: t("bond.unknown"),
})}
</Text>
<Group gap="xs" mt="sm">
<Badge variant="light" color="gray">
{t(`stock.exchanges.${bond.realExchange}`, {
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{bond.classCode ? (
<Text size="xs" c="dimmed">
{t("stock.classCode", { code: bond.classCode })}
</Text>
) : null}
</Group>
{bond.isin ? (
<Text size="xs" c="dimmed" mt={4}>
{t("stock.isin", { value: bond.isin })}
</Text>
) : null}
<Text size="xs" c="dimmed" mt={4}>
{t("bond.nominal")}: {formatMoney(bond.nominal)} ·{" "}
{t("bond.couponFrequency")}:{" "}
{bond.couponQuantityPerYear ?? t("bond.unknown")}
</Text>
{bond.floatingCoupon ? (
<Badge variant="light" mt="xs">
{t("bond.floatingCoupon")}
</Badge>
) : null}
{bond.apiTradeAvailable === false ? (
<Text size="xs" c="dimmed" mt={4}>
{t("stock.apiUnavailable")}
</Text>
) : null}
</div>
<div className={classes.quote}>
<Text size="xs" c="dimmed">
{t("bond.percentOfNominal")}
</Text>
<Text fw={650} size="xl">
{bond.pricePercent !== null
? `${formatDecimal(bond.pricePercent)} %`
: t("bond.unknown")}
</Text>
<Text size="xs" c="dimmed">
{bond.pricePercent !== null && bond.priceAt
? t("stock.priceAt", {
date: formatSnapshotTime(bond.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
{bond.apiTradeAvailable === true ? (
<Text size="xs" c="green.8" mt="sm">
{t("stock.apiAvailable")}
</Text>
) : null}
</div>
</Paper>
))}
</Stack>
) : (
<Text c="dimmed">
{t(vm.partial ? "investment.partial" : "bond.noSearchResults")}
</Text>
)
) : null}
))}
{renderResults()}
</Stack>
);
};
+27 -24
View File
@@ -1,44 +1,47 @@
import { useDebouncedValue } from "@mantine/hooks";
import { useSearchParams } from "react-router";
import { useBondSearch } from "@/entities/bond";
import { MIN_BOND_SEARCH_LENGTH, useBondSearch } from "@/entities/bond";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib";
const SEARCH_DEBOUNCE_MS = 300;
type BondsPageVm = ReturnType<typeof useBondsPageVm>;
function useBondsPageVm() {
const [params, setParams] = useSearchParams();
const query = params.get("q") ?? "";
const [parameters, setParameters] = useSearchParams();
const query = parameters.get("q") ?? "";
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
const result = useBondSearch(debounced);
const sync = useScreenSync({ screen: "bonds", query: debounced }, [result]);
const waiting = query.trim() !== debounced;
const sync = useScreenSync({ query: debounced, screen: "bonds" }, [result]);
const canSearch = query.trim().length >= MIN_BOND_SEARCH_LENGTH;
const isWaiting = query.trim() !== debounced;
return {
query,
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
loading:
query.trim().length >= 2 &&
(waiting ||
(!result.data &&
!result.isError &&
result.meta?.sync.state !== "failed")),
error: result.isError || sync.sourceError || sync.transportError,
refreshing: sync.refreshing,
partial: result.meta?.availability === "partial",
canSearch,
code: apiErrorCode(result.error),
error: result.isError || sync.sourceError || sync.transportError,
items:
result.data?.map(bond => ({
...bond,
logoSrc: instrumentLogoUrl(bond.logoName),
to: `/bonds/${encodeURIComponent(bond.slug ?? bond.instrumentUid)}`,
})) ?? [],
loading:
canSearch
&& (isWaiting
|| (!result.data
&& !result.isError
&& result.meta?.sync.state !== "failed")),
partial: result.meta?.availability === "partial",
query,
ready: debounced.length >= MIN_BOND_SEARCH_LENGTH && !isWaiting && Boolean(result.data),
refreshing: sync.refreshing,
retry: () => {
void sync.retry();
},
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items:
result.data?.map((bond) => ({
...bond,
to: `/bonds/${encodeURIComponent(bond.slug ?? bond.instrumentUid)}`,
logoSrc: instrumentLogoUrl(bond.logoName),
})) ?? [],
setQuery: (q: string) => setParameters(q ? { q } : {}, { replace: true }),
};
}
type BondsPageVm = ReturnType<typeof useBondsPageVm>;
export { type BondsPageVm, useBondsPageVm };
+70 -63
View File
@@ -7,55 +7,61 @@ import {
waitFor,
within,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { CardPage } from "./CardPage";
const money = (value: string) => ({ currency: "RUB", value });
const card = {
id: "a",
name: "Повседневная",
bankName: null,
lastFour: null,
balance: money("10000"),
openingBalance: money("10000"),
openingDate: "2026-01-01",
archivedAt: null,
version: 1,
};
const operation = {
id: "transfer",
kind: "transfer",
cardId: "a",
cardName: "Повседневная",
targetCardId: "b",
targetCardName: "Запасная",
categoryId: null,
categoryName: null,
originalExpenseId: null,
amount: money("50.01"),
availableRefund: null,
date: "2026-08-03",
description: null,
version: 1,
createdAt: "2026-08-03T00:00:00Z",
};
const card = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
archivedAt: null,
balance: money("10000"),
bankName: null,
id: "a",
lastFour: null,
name: "Повседневная",
openingBalance: money("10000"),
openingDate: "2026-01-01",
version: 1,
}
/* eslint-enable unicorn/no-null */);
const operation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
amount: money("50.01"),
availableRefund: null,
cardId: "a",
cardName: "Повседневная",
categoryId: null,
categoryName: null,
createdAt: "2026-08-03T00:00:00Z",
date: "2026-08-03",
description: null,
id: "transfer",
kind: "transfer",
originalExpenseId: null,
targetCardId: "b",
targetCardName: "Запасная",
version: 1,
}
/* eslint-enable unicorn/no-null */);
function setup(
options: {
month?: string;
missing?: boolean;
summaryError?: boolean;
cardsError?: boolean;
missing?: boolean;
month?: string;
summaryError?: boolean;
} = {},
) {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
refresh: () => Promise.resolve(false),
});
const requests: string[] = [];
const summaryRequests: string[] = [];
@@ -73,6 +79,7 @@ function setup(
http.get("*/api/v1/bank-cards/a/operations", ({ request }) => {
requests.push(new URL(request.url).search);
return HttpResponse.json({
hasNext: Number(new URL(request.url).searchParams.get("page")) < 3,
items: [
{
...operation,
@@ -81,7 +88,6 @@ function setup(
],
page: Number(new URL(request.url).searchParams.get("page")),
total: 30,
hasNext: Number(new URL(request.url).searchParams.get("page")) < 3,
});
}),
http.get("*/api/v1/bank-cards/summary", ({ request }) => {
@@ -89,12 +95,12 @@ function setup(
return options.summaryError
? HttpResponse.error()
: HttpResponse.json({
month: "2026-08",
cardId: "a",
expenses: money("0"),
refunds: money("50"),
net: money("-50"),
categories: [],
expenses: money("0"),
month: "2026-08",
net: money("-50"),
refunds: money("50"),
});
}),
);
@@ -106,13 +112,13 @@ function setup(
initialEntries={[`/cards/a?month=${options.month ?? "2026-08"}`]}
>
<Routes>
<Route path="/cards/:cardId" element={<CardPage />} />
<Route element={<CardPage />} path="/cards/:cardId" />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return { requests, summaryRequests, client };
return { client, requests, summaryRequests };
}
describe("CardPage", () => {
it.each([
@@ -145,10 +151,10 @@ describe("CardPage", () => {
expect(screen.getByText("−50,01 ₽")).toBeInTheDocument();
expect(await screen.findByText("-50,00 ₽")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
expect(requests.every((value) => value.includes("limit=10"))).toBe(true);
expect(requests.every(value => value.includes("limit=10"))).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() =>
expect(requests.some((v) => v.includes("page=2"))).toBe(true),
expect(requests.some(v => v.includes("page=2"))).toBe(true),
);
const lastPage = await screen.findByRole("button", { name: "Страница 3" });
await waitFor(() => expect(lastPage).toBeEnabled());
@@ -170,10 +176,10 @@ describe("CardPage", () => {
await waitFor(() =>
expect(
requests.some(
(v) =>
v.includes("from=2026-08-10") &&
v.includes("to=2026-08-20") &&
v.includes("page=1"),
v =>
v.includes("from=2026-08-10")
&& v.includes("to=2026-08-20")
&& v.includes("page=1"),
),
).toBe(true),
);
@@ -187,7 +193,7 @@ describe("CardPage", () => {
expect(screen.getByText(/10\s000 ₽/)).toBeInTheDocument();
});
it("keeps the journal in place while the next page loads", async () => {
const scroll = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
const scroll = vi.spyOn(globalThis, "scrollBy").mockImplementation(() => {});
setup();
await screen.findByText("Перевод на «Запасная»");
const navigation = screen.getByRole("navigation", {
@@ -196,18 +202,16 @@ describe("CardPage", () => {
const measure = vi
.spyOn(navigation, "getBoundingClientRect")
.mockReturnValue({ top: 600 } as DOMRect);
let release = () => {};
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
server.use(
http.get("*/api/v1/bank-cards/a/operations", async () => {
await pending;
return HttpResponse.json({
items: [{ ...operation, id: "next", description: "Новая страница" }],
hasNext: true,
items: [{ ...operation, description: "Новая страница", id: "next" }],
page: 2,
total: 30,
hasNext: true,
});
}),
);
@@ -224,11 +228,12 @@ describe("CardPage", () => {
measure.mockReturnValue({ top: 200 } as DOMRect);
release();
await screen.findByText(/Новая страница/);
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const list = screen.getByText(/Новая страница/).closest("[aria-busy]");
expect(list?.parentElement?.style.minHeight).toBe("");
expect(scroll).toHaveBeenCalledExactlyOnceWith({
top: -400,
behavior: "instant",
top: -400,
});
measure.mockRestore();
scroll.mockRestore();
@@ -250,8 +255,8 @@ describe("CardPage", () => {
fireEvent.click(await screen.findByRole("option", { name: "20" }));
await waitFor(() => {
expect(requests.at(-1)).toContain("limit=20");
expect(requests.at(-1)).toContain("page=1");
});
expect(requests.at(-1)).toContain("page=1"); ;
await waitFor(() =>
expect(screen.getByRole("button", { name: "Страница 1" })).toBeEnabled(),
);
@@ -313,15 +318,17 @@ describe("CardPage", () => {
),
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
const body = (await request.json()) as {
version: number;
archived: boolean;
version: number;
};
bodies.push(body);
current = {
...current,
version: current.version + 1,
archivedAt: body.archived ? "2026-09-01" : null,
} as typeof card;
current = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...current,
archivedAt: body.archived ? "2026-09-01" : null,
version: current.version + 1,
}
/* eslint-enable unicorn/no-null */) as typeof card;
return HttpResponse.json(current);
}),
);
@@ -338,8 +345,8 @@ describe("CardPage", () => {
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeEnabled();
expect(bodies).toEqual([
{ version: 1, archived: true },
{ version: 2, archived: false },
{ archived: true, version: 1 },
{ archived: false, version: 2 },
]);
});
it("recovers the prerequisite card list while preserving the visible balance and journal", async () => {
+4 -2
View File
@@ -1,5 +1,7 @@
import type { FC } from "react";
import { BankCardForm } from "@/entities/bank-card";
import { DeleteOperation } from "../DeleteOperation/DeleteOperation";
import { OperationForm } from "../OperationForm/OperationForm";
import { CardPageView } from "./CardPage.view";
@@ -20,14 +22,14 @@ const CardPage: FC = () => {
<OperationForm
card={vm.card.data}
cards={vm.cards.data}
operation={vm.operation === "new" ? undefined : vm.operation}
onClose={() => vm.setOperation(null)}
operation={(vm.operation === "new") ? undefined : (vm.operation)}
/>
)}
{vm.deleting && (
<DeleteOperation
operation={vm.deleting}
onClose={() => vm.setDeleting(null)}
operation={vm.deleting}
/>
)}
</>
+175 -159
View File
@@ -12,9 +12,10 @@ import {
} from "@mantine/core";
import { AddIcon, DeleteIcon, EditIcon } from "@wealthledger/icons";
import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui";
import { type FC, useLayoutEffect, useRef } from "react";
import { type FC, type ReactNode, useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
BankCardFace,
BankFeedback,
@@ -22,26 +23,28 @@ import {
BankPeriodPicker,
BankSummaryView,
} from "@/entities/bank-card";
import classes from "./CardPage.module.css";
import type { CardPageVm } from "./CardPage.vm";
import classes from "./CardPage.module.css";
type CardPageViewProps = { vm: CardPageVm };
const CardPageView: FC<CardPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const card = vm.card.data;
const paginationRef = useRef<HTMLDivElement>(null);
const pendingPage = useRef<{
scope: string;
const pendingPageRef = useRef<null | {
page: number;
scope: string;
top: number;
} | null>(null);
}>(null);
const scope = `${card?.id}-${vm.period.from}-${vm.period.to}-${vm.pageSize}`;
useLayoutEffect(() => {
const pending = pendingPage.current;
const pending = pendingPageRef.current;
if (!pending) return;
if (pending.scope !== scope || vm.history.isError) {
pendingPage.current = null;
pendingPageRef.current = /* eslint-disable-line unicorn/no-null -- Reset the pending-pagination ref to its initial null sentinel. */ null;
return;
}
if (vm.history.isPlaceholderData || vm.history.data?.page !== pending.page)
@@ -49,9 +52,10 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
const pagination = paginationRef.current;
if (pagination) {
const delta = pagination.getBoundingClientRect().top - pending.top;
if (delta) window.scrollBy({ top: delta, behavior: "instant" });
if (delta) window.scrollBy({ behavior: "instant", top: delta });
}
pendingPage.current = null;
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
pendingPageRef.current = null;
}, [
scope,
vm.history.data,
@@ -72,10 +76,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
);
return (
<StateView
error
title={t(vm.notFound ? "bank.notFound" : "bank.error")}
text={t("bank.back")}
action={
action={(
<Group>
<Button component={Link} to="/cards">
{t("bank.back")}
@@ -86,65 +87,158 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</Button>
)}
</Group>
}
)}
error
text={t("bank.back")}
title={t(vm.notFound ? "bank.notFound" : "bank.error")}
/>
);
}
// eslint-disable-next-line sonarjs/function-return-type -- ReactNode deliberately includes lists, elements and empty output.
function renderHistory(): ReactNode {
if (!card) return;
if (vm.rows) {
if (vm.rows.length > 0) {
return vm.rows.map(row => (
<section
aria-label={`${row.title} ${row.item.date}`}
className={classes.operation}
key={row.item.id}
>
<EntryCard
actions={(
<Group gap={4} wrap="nowrap">
<ActionIcon
aria-label={t("bank.edit")}
disabled={
vm.history.isPlaceholderData
|| !row.editable
|| Boolean(card.archivedAt)
}
onClick={() => vm.setOperation(row.item)}
size="sm"
title={t("bank.edit")}
variant="subtle"
>
<EditIcon size={18} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
aria-label={t("bank.delete")}
color="red"
disabled={
vm.history.isPlaceholderData
|| !row.editable
|| Boolean(card.archivedAt)
}
onClick={() => vm.setDeleting(row.item)}
size="sm"
title={t("bank.delete")}
variant="subtle"
>
<DeleteIcon size={18} strokeWidth={1.5} />
</ActionIcon>
</Group>
)}
color={row.color}
details={
(row.otherCard)
? ((
<Anchor
component={Link}
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
>
{t("bank.card")}
</Anchor>
))
: undefined
}
icon={(
<BankOperationIcon
categoryName={row.item.categoryName}
kind={row.item.kind}
/>
)}
initial={row.title[0]}
metadata={row.metadata}
title={row.title}
tone={row.tone}
value={row.value}
valueLabel={t(`bank.kinds.${row.item.kind}`)}
/>
</section>
));
}
return <Text>{t("bank.noOperations")}</Text>;
}
if (vm.history.isLoading) {
return (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<Stack>
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
);
}
}
return (
<Stack gap="lg" className={classes.page}>
<Stack className={classes.page} gap="lg">
<Stack>
<Title order={1} className={classes.wrap}>
<Title className={classes.wrap} order={1}>
{card.name}
</Title>
<div className={classes.card}>
<BankCardFace card={card} />
<Group gap={4} wrap="nowrap" className={classes.cardActions}>
<Group className={classes.cardActions} gap={4} wrap="nowrap">
<ActionIcon
variant="subtle"
size="md"
radius="md"
aria-label={t("bank.editCard")}
title={t("bank.editCard")}
onClick={() => vm.setEditingCard(true)}
radius="md"
size="md"
title={t("bank.editCard")}
variant="subtle"
>
<EditIcon size={18} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="md"
radius="md"
color="red"
aria-label={t(card.archivedAt ? "bank.restore" : "bank.archive")}
title={t(card.archivedAt ? "bank.restore" : "bank.archive")}
color="red"
loading={vm.archivePending}
onClick={() => void vm.toggleArchive()}
radius="md"
size="md"
title={t(card.archivedAt ? "bank.restore" : "bank.archive")}
variant="subtle"
>
{card.archivedAt ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
</svg>
) : (
<DeleteIcon size={18} strokeWidth={1.5} />
)}
{card.archivedAt
? (
<svg
aria-hidden="true"
fill="none"
height="18"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width="18"
>
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
</svg>
)
: (
<DeleteIcon size={18} strokeWidth={1.5} />
)}
</ActionIcon>
</Group>
</div>
{vm.archiveError ? (
{!!(vm.archiveError) && ((
<BankFeedback
error={vm.archiveError}
retry={() => void vm.card.refetch()}
/>
) : null}
))}
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
</Stack>
{vm.card.isError && (
@@ -158,136 +252,58 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
{vm.card.isFetching && <Text role="status">{t("bank.refreshing")}</Text>}
{vm.cards.isError && (
<BankFeedback
loadingError
error={vm.cards.error}
cached={Boolean(vm.cards.data)}
error={vm.cards.error}
loadingError
retry={() => void vm.cards.refetch()}
/>
)}
<BankPeriodPicker period={vm.period} onChange={vm.setPeriod} />
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
<BankSummaryView
data={vm.summary.data}
loading={vm.summary.isLoading}
fetching={vm.summary.isFetching}
error={vm.summary.isError}
fetching={vm.summary.isFetching}
loading={vm.summary.isLoading}
retry={() => void vm.summary.refetch()}
/>
<Group justify="space-between" align="end">
<Group align="end" justify="space-between">
<Group gap="xs">
<Title order={2}>{t("bank.operations")}</Title>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t("bank.addOperation")}
title={t("bank.addOperation")}
disabled={Boolean(card.archivedAt) || !vm.cards.data}
onClick={() => vm.setOperation("new")}
size="lg"
title={t("bank.addOperation")}
variant="subtle"
>
<AddIcon size={20} strokeWidth={1.5} />
</ActionIcon>
</Group>
<Select
label={t("bank.pageSize")}
value={vm.pageSize}
onChange={vm.setPageSize}
allowDeselect={false}
w={180}
data={[
"5",
"10",
"20",
"50",
"100",
{ value: "all", label: t("bank.allOperations") },
{ label: t("bank.allOperations"), value: "all" },
]}
label={t("bank.pageSize")}
onChange={vm.setPageSize}
value={vm.pageSize}
w={180}
/>
</Group>
<Stack
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`}
gap="lg"
className={classes.operations}
aria-busy={vm.history.isFetching}
className={classes.operations}
data-pending={vm.history.isPlaceholderData || undefined}
gap="lg"
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`}
>
{vm.rows ? (
vm.rows.length ? (
vm.rows.map((row) => (
<section
key={row.item.id}
className={classes.operation}
aria-label={`${row.title} ${row.item.date}`}
>
<EntryCard
title={row.title}
metadata={row.metadata}
color={row.color}
initial={row.title[0]}
icon={
<BankOperationIcon
kind={row.item.kind}
categoryName={row.item.categoryName}
/>
}
value={row.value}
valueLabel={t(`bank.kinds.${row.item.kind}`)}
tone={row.tone}
details={
row.otherCard ? (
<Anchor
component={Link}
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
>
{t("bank.card")}
</Anchor>
) : null
}
actions={
<Group gap={4} wrap="nowrap">
<ActionIcon
variant="subtle"
disabled={
vm.history.isPlaceholderData ||
!row.editable ||
Boolean(card.archivedAt)
}
size="sm"
aria-label={t("bank.edit")}
title={t("bank.edit")}
onClick={() => vm.setOperation(row.item)}
>
<EditIcon size={18} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
disabled={
vm.history.isPlaceholderData ||
!row.editable ||
Boolean(card.archivedAt)
}
size="sm"
aria-label={t("bank.delete")}
title={t("bank.delete")}
onClick={() => vm.setDeleting(row.item)}
>
<DeleteIcon size={18} strokeWidth={1.5} />
</ActionIcon>
</Group>
}
/>
</section>
))
) : (
<Text>{t("bank.noOperations")}</Text>
)
) : vm.history.isLoading ? (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<Stack>
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
) : null}
{renderHistory()}
</Stack>
{vm.history.isError && (
<Group role="alert">
@@ -299,34 +315,34 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
)}
{vm.history.data && vm.pageSize !== "all" && (
<Group
component="nav"
ref={paginationRef}
aria-label={t("bank.pagination")}
component="nav"
justify="center"
py="sm"
ref={paginationRef}
>
<Pagination
total={vm.totalPages}
value={vm.page}
onChange={(page) => {
if (paginationRef.current)
pendingPage.current = {
scope,
page,
top: paginationRef.current.getBoundingClientRect().top,
};
vm.setPage(page);
}}
disabled={vm.history.isFetching}
siblings={0}
getItemProps={(page) => ({
"aria-label": t("bank.page", { page }),
})}
getControlProps={(control) => ({
getControlProps={control => ({
"aria-label": t(
control === "previous" ? "bank.previous" : "bank.next",
),
})}
getItemProps={page => ({
"aria-label": t("bank.page", { page }),
})}
onChange={(page) => {
if (paginationRef.current)
pendingPageRef.current = {
page,
scope,
top: paginationRef.current.getBoundingClientRect().top,
};
vm.setPage(page);
}}
siblings={0}
total={vm.totalPages}
value={vm.page}
/>
</Group>
)}
+60 -62
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams, useSearchParams } from "react-router";
import {
type BankOperation,
bankPeriod,
@@ -13,18 +14,19 @@ import {
import { apiErrorCode } from "@/shared/api";
import { formatDate, formatMoney } from "@/shared/lib";
const PAGE_SIZES = ["5", "10", "20", "50", "100", "all"];
const PAGE_SIZES = new Set(["5", "10", "20", "50", "100", "all"]);
type CardPageVm = ReturnType<typeof useCardPageVm>;
function useCardPageVm() {
const { cardId } = useParams();
const [params, setParams] = useSearchParams();
const [parameters, setParameters] = useSearchParams();
const { t } = useTranslation();
const period = bankPeriod(params);
const requestedSize = params.get("limit") ?? "10";
const pageSize = PAGE_SIZES.includes(requestedSize) ? requestedSize : "10";
const rawPage = Number(params.get("page") ?? 1);
const page =
pageSize !== "all" && Number.isSafeInteger(rawPage) && rawPage > 0
const period = bankPeriod(parameters);
const requestedSize = parameters.get("limit") ?? "10";
const pageSize = PAGE_SIZES.has(requestedSize) ? requestedSize : "10";
const rawPage = Number(parameters.get("page") ?? 1);
const page
= pageSize !== "all" && Number.isSafeInteger(rawPage) && rawPage > 0
? rawPage
: 1;
const card = useBankCard(cardId);
@@ -33,94 +35,90 @@ function useCardPageVm() {
const cards = useBankCards(true);
const history = useBankOperations(cardId, {
...period,
page,
limit: pageSize === "all" ? "all" : Number(pageSize),
page,
});
const summary = useBankSummary(period, cardId);
const [editingCard, setEditingCard] = useState(false);
const [operation, setOperation] = useState<BankOperation | "new" | null>(
const [operation, setOperation] = useState<"new" | BankOperation | null>(
null,
);
const [deleting, setDeleting] = useState<BankOperation | null>(null);
const rows = history.data?.items.map((item) => {
const outgoing =
item.kind === "expense" ||
(item.kind === "transfer" && item.cardId === cardId);
const isOutgoing
= item.kind === "expense"
|| (item.kind === "transfer" && item.cardId === cardId);
const otherCardId = isOutgoing ? item.targetCardId : item.cardId;
let title = t(`bank.kinds.${item.kind}`);
if (item.kind === "transfer") title = t(isOutgoing ? "bank.transferTo" : "bank.transferFrom", { name: isOutgoing ? item.targetCardName : item.cardName });
return {
color: isOutgoing ? "red" : "green",
editable:
Boolean(cards.data)
&& !cards.data?.some(
c =>
(c.id === item.cardId || c.id === item.targetCardId)
&& c.archivedAt,
),
item,
color: outgoing ? "red" : "green",
title:
item.kind === "transfer"
? t(outgoing ? "bank.transferTo" : "bank.transferFrom", {
name: outgoing ? item.targetCardName : item.cardName,
})
: t(`bank.kinds.${item.kind}`),
metadata: [formatDate(item.date), item.categoryName, item.description]
.filter(Boolean)
.join(" · "),
value: `${outgoing ? "−" : "+"}${formatMoney(item.amount)}`,
tone: outgoing ? ("negative" as const) : ("positive" as const),
otherCard:
item.kind === "transfer"
? outgoing
? item.targetCardId
: item.cardId
: null,
editable:
Boolean(cards.data) &&
!cards.data?.some(
(c) =>
(c.id === item.cardId || c.id === item.targetCardId) &&
c.archivedAt,
),
? otherCardId
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
title,
tone: isOutgoing ? ("negative" as const) : ("positive" as const),
value: `${isOutgoing ? "−" : "+"}${formatMoney(item.amount)}`,
};
});
return {
card,
cards,
archiveError,
archivePending: updateCard.isPending,
card,
cards,
deleting,
editingCard,
history,
notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND",
operation,
page,
pageSize,
period,
rows,
setDeleting,
setEditingCard,
setOperation,
setPage: (value: number) =>
setParameters({ ...period, limit: pageSize, page: String(value) }),
setPageSize: (value: null | string) => {
if (value && PAGE_SIZES.has(value))
setParameters({ ...period, limit: value });
},
setPeriod: (value: [null | string, null | string]) => {
if (value[0] && value[1])
setParameters({ from: value[0], limit: pageSize, to: value[1] });
},
summary,
toggleArchive: async () => {
if (!card.data) return;
setArchiveError(undefined);
try {
await updateCard.mutateAsync({
body: { archived: !card.data.archivedAt, version: card.data.version },
id: card.data.id,
body: { version: card.data.version, archived: !card.data.archivedAt },
});
} catch (failure) {
setArchiveError(failure);
}
catch (error) {
setArchiveError(error);
}
},
history,
summary,
period,
page,
pageSize,
totalPages:
pageSize === "all"
? 1
: Math.max(1, Math.ceil((history.data?.total ?? 0) / Number(pageSize))),
rows,
editingCard,
setEditingCard,
operation,
setOperation,
deleting,
setDeleting,
notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND",
setPeriod: (value: [string | null, string | null]) => {
if (value[0] && value[1])
setParams({ from: value[0], to: value[1], limit: pageSize });
},
setPageSize: (value: string | null) => {
if (value && PAGE_SIZES.includes(value))
setParams({ ...period, limit: value });
},
setPage: (value: number) =>
setParams({ ...period, page: String(value), limit: pageSize }),
};
}
type CardPageVm = ReturnType<typeof useCardPageVm>;
export { type CardPageVm, useCardPageVm };
@@ -1,37 +1,42 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import type { BankOperation } from "@/entities/bank-card";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { DeleteOperation } from "./DeleteOperation";
const transfer: BankOperation = {
id: "transfer",
kind: "transfer",
cardId: "a",
cardName: "Основная",
targetCardId: "b",
targetCardName: "Запасная",
categoryId: null,
categoryName: null,
originalExpenseId: null,
amount: { currency: "RUB", value: "100" },
availableRefund: null,
date: "2026-08-01",
description: null,
version: 1,
createdAt: "2026-08-01T00:00:00Z",
};
const transfer: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
amount: { currency: "RUB", value: "100" },
availableRefund: null,
cardId: "a",
cardName: "Основная",
categoryId: null,
categoryName: null,
createdAt: "2026-08-01T00:00:00Z",
date: "2026-08-01",
description: null,
id: "transfer",
kind: "transfer",
originalExpenseId: null,
targetCardId: "b",
targetCardName: "Запасная",
version: 1,
}
/* eslint-enable unicorn/no-null */);
describe("DeleteOperation", () => {
it("names both sides and requires a deliberate fresh version after conflict", async () => {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
refresh: () => Promise.resolve(false),
});
const versions: string[] = [];
server.use(
@@ -53,7 +58,7 @@ describe("DeleteOperation", () => {
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<DeleteOperation operation={transfer} onClose={close} />
<DeleteOperation onClose={close} operation={transfer} />
</MantineProvider>
</QueryClientProvider>,
);
@@ -1,6 +1,7 @@
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import {
BankFeedback,
type BankOperation,
@@ -10,7 +11,7 @@ import {
} from "@/entities/bank-card";
import { formatDate, formatMoney } from "@/shared/lib";
type DeleteOperationProps = { operation: BankOperation; onClose: () => void };
type DeleteOperationProps = { onClose: () => void; operation: BankOperation };
const DeleteOperation: FC<DeleteOperationProps> = (props) => {
useBankFormFocus();
const [opened, setOpened] = useState(true);
@@ -19,20 +20,20 @@ const DeleteOperation: FC<DeleteOperationProps> = (props) => {
const [latest, setLatest] = useState<BankOperation>();
const [error, setError] = useState<unknown>();
const query = useBankOperation(record.id);
const { deleteOperation } = useBankMutations();
const pending =
!opened ||
deleteOperation.isPending ||
(query.isFetching && Boolean(error));
const { deleteOperation: operationDeletion } = useBankMutations();
const isPending
= !opened
|| operationDeletion.isPending
|| (query.isFetching && Boolean(error));
return (
<Modal
opened={opened}
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
closeOnClickOutside={!isPending}
closeOnEscape={!isPending}
onClose={() => !isPending && setOpened(false)}
onExitTransitionEnd={props.onClose}
onClose={() => !pending && setOpened(false)}
opened={opened}
title={t("bank.deleteTitle")}
closeOnEscape={!pending}
closeOnClickOutside={!pending}
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
>
<Stack>
<Text>
@@ -43,27 +44,27 @@ const DeleteOperation: FC<DeleteOperationProps> = (props) => {
target: record.targetCardName,
})
: t("bank.deleteHint", {
kind: t(`bank.kinds.${record.kind}`),
amount: formatMoney(record.amount),
card: record.cardName,
kind: t(`bank.kinds.${record.kind}`),
})}
</Text>
{error ? (
{!!(error) && ((
<>
<BankFeedback error={error} />
<Button
variant="default"
disabled={pending}
disabled={isPending}
onClick={async () => {
const result = await query.refetch();
if (result.data && !result.error) setLatest(result.data);
else setError(result.error);
}}
variant="default"
>
{t("bank.reloadRecord")}
</Button>
</>
) : null}
))}
{latest && (
<>
<Text>
@@ -72,13 +73,13 @@ const DeleteOperation: FC<DeleteOperationProps> = (props) => {
})}
</Text>
<Button
variant="default"
disabled={pending}
disabled={isPending}
onClick={() => {
setRecord(latest);
setLatest(undefined);
setError(undefined);
}}
variant="default"
>
{t("bank.adoptVersion", { version: latest.version })}
</Button>
@@ -87,26 +88,27 @@ const DeleteOperation: FC<DeleteOperationProps> = (props) => {
<Group>
<Button
color="red"
loading={pending}
loading={isPending}
onClick={async () => {
setError(undefined);
try {
await deleteOperation.mutateAsync({
await operationDeletion.mutateAsync({
id: record.id,
version: record.version,
});
setOpened(false);
} catch (failure) {
setError(failure);
}
catch (error_) {
setError(error_);
}
}}
>
{t("bank.delete")}
</Button>
<Button
variant="default"
disabled={pending}
disabled={isPending}
onClick={() => setOpened(false)}
variant="default"
>
{t("bank.cancel")}
</Button>
@@ -1,74 +1,36 @@
import { Alert } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Alert } from "@mantine/core";
import { type FC, useState } from "react";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import type { OperationValues } from "./OperationForm.types";
import { OperationFormView } from "./OperationForm.view";
type ExampleProps = { pending?: boolean; conflict?: boolean };
type ExampleProps = { conflict?: boolean; pending?: boolean };
const OperationExample: FC<ExampleProps> = (props) => {
const [opened, setOpened] = useState(true);
const [action, setAction] = useState("");
const methods = useForm<OperationValues>({
defaultValues: {
kind: "refund",
cardId: "demo",
targetCardId: "",
categoryId: "",
originalExpenseId: "purchase",
amount: "500",
cardId: "demo",
categoryId: "",
date: "2026-08-01",
description: "Синтетический возврат",
kind: "refund",
originalExpenseId: "purchase",
targetCardId: "",
},
});
const kind = useWatch({ control: methods.control, name: "kind" });
return (
<FormProvider {...methods}>
<OperationFormView
opened={opened}
pending={Boolean(props.pending)}
editing
categoriesOpen={false}
onClose={() => !props.pending && setOpened(false)}
onCancel={() => setOpened(false)}
onExitTransitionEnd={() => setAction("Форма закрыта")}
onSubmit={(event) => {
event.preventDefault();
setAction("Локальный submit");
}}
fields={{
editing: true,
refundCardId: "demo",
kind,
cards: [{ value: "demo", label: "Учебная карта" }],
targets: [],
categories: [],
defaultCategory: "",
kinds: [{ value: "refund", label: "Возврат" }],
dateInputValue: () => "2026-08-01",
validDate: (value) => value === "2026-08-01",
maxDate: () => "2026-08-27",
}}
refund={{
editing: true,
expenses: [
{
value: "purchase",
label: "01.07.2026 · Учебная покупка · 500 ₽",
},
],
onSelect: () => undefined,
limit: { currency: "RUB", value: "500" },
}}
loadingExpenses={false}
editing
emptyExpenses={false}
sourcePage={1}
previousDisabled
nextDisabled
onPrevious={() => undefined}
onNext={() => undefined}
onAddCategory={() => undefined}
saveDisabled={false}
feedback={
props.conflict && (
<Alert color="orange" role="alert">
@@ -76,27 +38,66 @@ const OperationExample: FC<ExampleProps> = (props) => {
</Alert>
)
}
onReload={
props.conflict ? () => setAction("Локальный reload") : undefined
}
fields={{
cards: [{ label: "Учебная карта", value: "demo" }],
categories: [],
dateInputValue: () => "2026-08-01",
defaultCategory: "",
editing: true,
kind,
kinds: [{ label: "Возврат", value: "refund" }],
maxDate: () => "2026-08-27",
refundCardId: "demo",
targets: [],
validDate: value => value === "2026-08-01",
}}
latest={
props.conflict
? {
version: 2,
(props.conflict)
? ({
details: "Учебная версия",
onAdopt: () => setAction("Локальное принятие версии"),
}
version: 2,
})
: undefined
}
loadingExpenses={false}
nextDisabled
onAddCategory={() => {}}
onCancel={() => setOpened(false)}
onClose={() => !props.pending && setOpened(false)}
onExitTransitionEnd={() => setAction("Форма закрыта")}
onNext={() => {}}
onPrevious={() => {}}
onReload={
(props.conflict) ? () => setAction("Локальный reload") : undefined
}
onSubmit={(event) => {
event.preventDefault();
setAction("Локальный submit");
}}
opened={opened}
pending={Boolean(props.pending)}
previousDisabled
refund={{
editing: true,
expenses: [
{
label: "01.07.2026 · Учебная покупка · 500 ₽",
value: "purchase",
},
],
limit: { currency: "RUB", value: "500" },
onSelect: () => {},
}}
saveDisabled={false}
sourcePage={1}
/>
<output aria-live="polite">{action}</output>
</FormProvider>
);
};
const meta = {
title: "Product/OperationForm",
component: OperationExample,
tags: ["autodocs"],
parameters: {
docs: {
description: {
@@ -105,6 +106,8 @@ const meta = {
},
},
},
tags: ["autodocs"],
title: "Product/OperationForm",
} satisfies Meta<typeof OperationExample>;
export default meta;
type Story = StoryObj<typeof meta>;
@@ -7,37 +7,46 @@ import {
waitFor,
within,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import type { BankCard, BankOperation } from "@/entities/bank-card";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { OperationForm } from "./OperationForm";
const money = (value: string) => ({ currency: "RUB", value });
const card: BankCard = {
id: "a",
name: "Повседневная",
bankName: null,
lastFour: null,
paymentSystem: null,
balance: money("0"),
openingBalance: money("0"),
openingDate: "2026-01-01",
archivedAt: null,
version: 1,
};
const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
archivedAt: null,
balance: money("0"),
bankName: null,
id: "a",
lastFour: null,
name: "Повседневная",
openingBalance: money("0"),
openingDate: "2026-01-01",
paymentSystem: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
async function select(label: RegExp, value: RegExp | string) {
fireEvent.click(screen.getByRole("combobox", { name: label }));
fireEvent.click(await screen.findByRole("option", { name: value }));
}
function setup(operation?: BankOperation) {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
refresh: () => Promise.resolve(false),
});
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
{ id: "default", name: "Остальное", isDefault: true, version: 2 },
{ id: "default", isDefault: true, name: "Остальное", version: 2 },
]),
),
);
@@ -46,35 +55,28 @@ function setup(operation?: BankOperation) {
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<OperationForm
operation={operation}
card={card}
cards={[
card,
{ ...card, id: "b", name: "Запасная", openingDate: "2026-08-01" },
{
...card,
archivedAt: "2026-01-02",
id: "archived",
name: "Архивная",
archivedAt: "2026-01-02",
},
]}
onClose={close}
operation={operation}
/>
</MantineProvider>
</QueryClientProvider>,
);
return close;
}
async function select(label: string, value: string | RegExp) {
fireEvent.click(screen.getByRole("combobox", { name: new RegExp(label) }));
fireEvent.click(await screen.findByRole("option", { name: value }));
}
describe("OperationForm", () => {
it("blocks closing and editing while save is pending, then closes after success", async () => {
let finish: () => void = () => {};
const response = new Promise<void>((resolve) => {
finish = resolve;
});
const { promise: response, resolve: finish } = Promise.withResolvers<void>();
const received = vi.fn();
server.use(
http.post("*/api/v1/bank-cards/operations", async () => {
@@ -101,7 +103,8 @@ describe("OperationForm", () => {
fireEvent.keyDown(document, { key: "Escape" });
expect(close).not.toHaveBeenCalled();
expect(screen.getByRole("dialog")).toBeInTheDocument();
} finally {
}
finally {
finish();
}
await waitFor(() => expect(close).toHaveBeenCalledOnce());
@@ -132,16 +135,16 @@ describe("OperationForm", () => {
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toMatchObject({
kind: "expense",
amount: money("92233720368547758.07"),
categoryId: "default",
date: "2026-08-07",
amount: money("92233720368547758.07"),
kind: "expense",
});
});
it("checks both transfer opening dates, excludes archive and reuses intent after a lost response", async () => {
setup();
await select("Вид операции", "Перевод");
await select("Карта-получатель", "Запасная");
await select(/Вид операции/, "Перевод");
await select(/Карта-получатель/, "Запасная");
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "0" },
});
@@ -176,15 +179,15 @@ describe("OperationForm", () => {
await waitFor(() => expect(bodies).toHaveLength(2));
expect(bodies[0]).toEqual(bodies[1]);
expect(bodies[0]).toMatchObject({
kind: "transfer",
cardId: "a",
targetCardId: "b",
amount: money("150.01"),
cardId: "a",
kind: "transfer",
targetCardId: "b",
});
});
it("topup omits categories and preserves entered description", async () => {
setup();
await select("Вид операции", "Пополнение");
await select(/Вид операции/, "Пополнение");
expect(
screen.queryByRole("combobox", { name: /Категория/ }),
).not.toBeInTheDocument();
@@ -204,40 +207,42 @@ describe("OperationForm", () => {
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(body).toBeDefined());
expect(body).toMatchObject({
kind: "topup",
amount: money("0.10"),
description: "Внесение",
kind: "topup",
});
expect(body).not.toHaveProperty("categoryId");
});
it("finds a previous-month purchase across pages, checks remaining refund and retains the complete-refund intent", async () => {
const purchase: BankOperation = {
id: "purchase",
kind: "expense",
cardId: "a",
cardName: card.name,
targetCardId: null,
targetCardName: null,
categoryId: "default",
categoryName: "Остальное",
originalExpenseId: null,
amount: money("500"),
availableRefund: money("500"),
date: "2026-07-01",
description: "Прошлая покупка",
version: 1,
createdAt: "2026-07-01T00:00:00Z",
};
const purchase: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
amount: money("500"),
availableRefund: money("500"),
cardId: "a",
cardName: card.name,
categoryId: "default",
categoryName: "Остальное",
createdAt: "2026-07-01T00:00:00Z",
date: "2026-07-01",
description: "Прошлая покупка",
id: "purchase",
kind: "expense",
originalExpenseId: null,
targetCardId: null,
targetCardName: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
server.use(
http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => {
return HttpResponse.json({
hasNext: new URL(request.url).searchParams.get("page") !== "2",
items:
new URL(request.url).searchParams.get("page") === "2"
? [purchase]
: [],
page: 1,
total: 1,
hasNext: new URL(request.url).searchParams.get("page") !== "2",
});
}),
http.get("*/api/v1/bank-cards/operations/purchase", () =>
@@ -245,7 +250,7 @@ describe("OperationForm", () => {
),
);
const close = setup();
await select("Вид операции", "Возврат");
await select(/Вид операции/, "Возврат");
await waitFor(() =>
expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(),
);
@@ -253,7 +258,7 @@ describe("OperationForm", () => {
await waitFor(() =>
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled(),
);
await select("Исходная покупка", /Прошлая покупка/);
await select(/Исходная покупка/, /Прошлая покупка/);
await screen.findByText("Доступно к возврату: 500 ₽");
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "500.01" },
@@ -284,43 +289,45 @@ describe("OperationForm", () => {
await waitFor(() => expect(close).toHaveBeenCalled());
expect(bodies[0]).toEqual(bodies[1]);
expect(bodies[0]).toMatchObject({
date: "2026-08-01",
kind: "refund",
originalExpenseId: "purchase",
date: "2026-08-01",
});
});
it("edits a full refund with its own amount excluded from the limit and immutable purchase", async () => {
const refund: BankOperation = {
id: "refund",
kind: "refund",
cardId: "a",
cardName: card.name,
targetCardId: null,
targetCardName: null,
categoryId: "default",
categoryName: "Остальное",
originalExpenseId: "purchase",
amount: money("500"),
availableRefund: money("500"),
date: "2026-08-01",
description: "",
version: 2,
createdAt: "2026-08-01T00:00:00Z",
};
const refund: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
amount: money("500"),
availableRefund: money("500"),
cardId: "a",
cardName: card.name,
categoryId: "default",
categoryName: "Остальное",
createdAt: "2026-08-01T00:00:00Z",
date: "2026-08-01",
description: "",
id: "refund",
kind: "refund",
originalExpenseId: "purchase",
targetCardId: null,
targetCardName: null,
version: 2,
}
/* eslint-enable unicorn/no-null */);
const purchase = {
...refund,
availableRefund: money("0"),
date: "2026-07-01",
id: "purchase",
kind: "expense",
date: "2026-07-01",
availableRefund: money("0"),
};
server.use(
http.get("*/api/v1/bank-cards/a/expenses", () =>
HttpResponse.json({
hasNext: false,
items: [purchase],
page: 1,
total: 1,
hasNext: false,
}),
),
http.get("*/api/v1/bank-cards/operations/purchase", () =>
@@ -357,39 +364,41 @@ describe("OperationForm", () => {
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toMatchObject({
version: 2,
amount: money("500"),
originalExpenseId: "purchase",
description: "Полностью возвращено",
originalExpenseId: "purchase",
version: 2,
});
});
it("keeps edit draft and stale version until explicit adoption after conflict", async () => {
const operation: BankOperation = {
id: "expense",
kind: "expense",
cardId: "a",
cardName: card.name,
targetCardId: null,
targetCardName: null,
categoryId: "default",
categoryName: "Остальное",
originalExpenseId: null,
amount: money("100"),
availableRefund: money("100"),
date: "2026-08-01",
description: "Первое",
version: 1,
createdAt: "2026-08-01T00:00:00Z",
};
const operation: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
amount: money("100"),
availableRefund: money("100"),
cardId: "a",
cardName: card.name,
categoryId: "default",
categoryName: "Остальное",
createdAt: "2026-08-01T00:00:00Z",
date: "2026-08-01",
description: "Первое",
id: "expense",
kind: "expense",
originalExpenseId: null,
targetCardId: null,
targetCardName: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
let readVersion = 1;
const bodies: Record<string, unknown>[] = [];
server.use(
http.get("*/api/v1/bank-cards/operations/expense", () =>
HttpResponse.json({
...operation,
version: readVersion,
description: readVersion === 1 ? "Первое" : "Другое исправление",
version: readVersion,
}),
),
http.patch(
@@ -434,9 +443,9 @@ describe("OperationForm", () => {
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(bodies[2]).toMatchObject({
version: 2,
description: "Мой черновик",
amount: money("150"),
description: "Мой черновик",
version: 2,
});
});
it("creates a category inside expense without losing operation fields", async () => {
@@ -452,20 +461,20 @@ describe("OperationForm", () => {
fireEvent.change(within(dialog).getByLabelText(/Название категории/), {
target: { value: " кафе " },
});
let created = false;
let isCreated = false;
server.use(
http.post("*/api/v1/bank-cards/categories", () => {
created = true;
isCreated = true;
return HttpResponse.json(
{ id: "new", name: "кафе", version: 1, isDefault: false },
{ id: "new", isDefault: false, name: "кафе", version: 1 },
{ status: 201 },
);
}),
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
{ id: "default", name: "Остальное", isDefault: true, version: 2 },
...(created
? [{ id: "new", name: "кафе", isDefault: false, version: 1 }]
{ id: "default", isDefault: true, name: "Остальное", version: 2 },
...(isCreated
? [{ id: "new", isDefault: false, name: "кафе", version: 1 }]
: []),
]),
),
@@ -495,9 +504,9 @@ describe("OperationForm", () => {
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(body).toBeDefined());
expect(body).toMatchObject({
amount: money("1.20"),
categoryId: "new",
description: "Черновик расхода",
amount: money("1.20"),
});
});
});
@@ -1,7 +1,11 @@
import type { FC } from "react";
import { FormProvider } from "react-hook-form";
import { BankFeedback, CategoryManager } from "@/entities/bank-card";
import type { OperationFormProps } from "./OperationForm.types";
import { OperationFormView } from "./OperationForm.view";
import { useOperationFormVm } from "./OperationForm.vm";
@@ -12,34 +16,35 @@ const OperationForm: FC<OperationFormProps> = (props) => {
<FormProvider {...vm.methods}>
<OperationFormView
{...vm.view}
refundFeedback={
<>
{vm.expensesError && (
<BankFeedback
loadingError
error={vm.expensesError}
retry={vm.retryExpenses}
/>
)}{" "}
{vm.sourceError && (
<BankFeedback
loadingError
error={vm.sourceError}
retry={vm.retrySource}
/>
)}
</>
}
categoriesFeedback={
vm.categoriesError && (
<BankFeedback
loadingError
error={vm.categoriesError}
loadingError
retry={vm.retryCategories}
/>
)
}
feedback={vm.error ? <BankFeedback error={vm.error} /> : null}
feedback={(vm.error) ? (<BankFeedback error={vm.error} />) : undefined}
refundFeedback={(
<>
{vm.expensesError && (
<BankFeedback
error={vm.expensesError}
loadingError
retry={vm.retryExpenses}
/>
)}
{" "}
{vm.sourceError && (
<BankFeedback
error={vm.sourceError}
loadingError
retry={vm.retrySource}
/>
)}
</>
)}
/>
</FormProvider>
{vm.categoriesOpen && (
@@ -1,21 +1,21 @@
import type { BankCard, BankOperation } from "@/entities/bank-card";
type OperationValues = {
kind: BankOperation["kind"];
cardId: string;
targetCardId: string;
categoryId: string;
originalExpenseId: string;
amount: string;
date: string;
description: string;
};
type OperationFormProps = {
card: BankCard;
cards: BankCard[];
operation?: BankOperation;
onClose: () => void;
operation?: BankOperation;
};
type OperationValues = {
amount: string;
cardId: string;
categoryId: string;
date: string;
description: string;
kind: BankOperation["kind"];
originalExpenseId: string;
targetCardId: string;
};
export type { OperationFormProps, OperationValues };
@@ -1,3 +1,5 @@
import type { FC, ReactNode, SubmitEventHandler } from "react";
import {
Button,
Group,
@@ -9,35 +11,38 @@ import {
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import type { FC, FormEventHandler, ReactNode } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { BankOperation } from "@/entities/bank-card";
import { formatMoney } from "@/shared/lib";
import classes from "./OperationForm.module.css";
import type { OperationValues } from "./OperationForm.types";
type Choice = { value: string; label: string };
import classes from "./OperationForm.module.css";
type Choice = { label: string; value: string };
type OperationFieldsProps = {
editing: boolean;
refundCardId: string;
kind: OperationValues["kind"];
cards: Choice[];
targets: Choice[];
categories: Choice[];
defaultCategory: string;
kinds: Choice[];
dateInputValue: (value: string) => string;
validDate: (value: string) => boolean;
defaultCategory: string;
editing: boolean;
kind: OperationValues["kind"];
kinds: Choice[];
maxDate: () => string;
refundCardId: string;
targets: Choice[];
validDate: (value: string) => boolean;
};
const OperationFields: FC<OperationFieldsProps> = (props) => {
const { t } = useTranslation();
const {
control,
formState: { errors, isSubmitting },
register,
setValue,
formState: { errors, isSubmitting },
} = useFormContext<OperationValues>();
return (
<Stack>
@@ -46,9 +51,9 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
name="kind"
render={({ field }) => (
<Select
data={props.kinds}
label={t("bank.kind")}
required
data={props.kinds}
{...field}
disabled={isSubmitting || props.editing}
onChange={(value) => {
@@ -63,11 +68,11 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
name="cardId"
render={({ field }) => (
<Select
data={props.cards}
label={t(
props.kind === "transfer" ? "bank.source" : "bank.operationCard",
)}
required
data={props.cards}
{...field}
disabled={isSubmitting || props.kind === "refund"}
error={errors.cardId?.message}
@@ -80,9 +85,9 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
name="targetCardId"
render={({ field }) => (
<Select
data={props.targets}
label={t("bank.destination")}
required
data={props.targets}
{...field}
disabled={isSubmitting}
error={errors.targetCardId?.message}
@@ -91,9 +96,9 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
/>
)}
<TextInput
inputMode="decimal"
label={t("bank.amount")}
required
inputMode="decimal"
{...register("amount")}
error={errors.amount?.message}
/>
@@ -103,30 +108,28 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
render={({ field }) => (
<DateInput
{...field}
label={t("bank.date")}
required
valueFormat="DD.MM.YYYY"
value={props.validDate(field.value) ? field.value : null}
dateParser={(value) =>
dateParser={value =>
props.validDate(props.dateInputValue(value))
? props.dateInputValue(value)
: null
}
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null}
error={errors.date?.message}
fixOnBlur={false}
onInput={(event) =>
field.onChange(props.dateInputValue(event.currentTarget.value))
}
onChange={(value) => field.onChange(value ?? "")}
label={t("bank.date")}
maxDate={props.maxDate()}
onBlur={(event) => {
field.onChange(props.dateInputValue(event.currentTarget.value));
field.onBlur();
}}
maxDate={props.maxDate()}
onChange={value => field.onChange(value ?? "")}
onInput={event =>
field.onChange(props.dateInputValue(event.currentTarget.value))}
popoverProps={{
withinPortal: false,
middlewares: { shift: { crossAxis: true } },
withinPortal: false,
}}
error={errors.date?.message}
required
value={(props.validDate(field.value)) ? (field.value) : undefined}
valueFormat="DD.MM.YYYY"
/>
)}
/>
@@ -136,13 +139,13 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
name="categoryId"
render={({ field }) => (
<Select
data={props.categories}
label={t("bank.category")}
required
data={props.categories}
{...field}
value={field.value || props.defaultCategory}
disabled={isSubmitting}
error={errors.categoryId?.message}
value={field.value || props.defaultCategory}
/>
)}
/>
@@ -158,8 +161,8 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
type RefundFieldsProps = {
editing: boolean;
expenses: Choice[];
onSelect: (id: string) => void;
limit: BankOperation["availableRefund"] | undefined;
onSelect: (id: string) => void;
};
const RefundFields: FC<RefundFieldsProps> = (props) => {
const { t } = useTranslation();
@@ -174,16 +177,16 @@ const RefundFields: FC<RefundFieldsProps> = (props) => {
name="originalExpenseId"
render={({ field }) => (
<Select
data={props.expenses}
label={t("bank.expense")}
required
data={props.expenses}
{...field}
disabled={isSubmitting || props.editing}
error={errors.originalExpenseId?.message}
onChange={(value) => {
field.onChange(value ?? "");
props.onSelect(value ?? "");
}}
disabled={isSubmitting || props.editing}
error={errors.originalExpenseId?.message}
/>
)}
/>
@@ -198,48 +201,48 @@ const RefundFields: FC<RefundFieldsProps> = (props) => {
};
type OperationFormViewProps = {
opened: boolean;
pending: boolean;
categoriesFeedback?: ReactNode;
categoriesOpen: boolean;
editing: boolean;
emptyExpenses: boolean;
feedback?: ReactNode;
fields: OperationFieldsProps;
latest?: { details: string; onAdopt: () => void; version: number };
loadingExpenses: boolean;
nextDisabled: boolean;
onAddCategory: () => void;
onCancel: () => void;
onClose: () => void;
onExitTransitionEnd: () => void;
onCancel: () => void;
onSubmit: FormEventHandler<HTMLFormElement>;
fields: OperationFieldsProps;
refund: RefundFieldsProps;
loadingExpenses: boolean;
emptyExpenses: boolean;
sourcePage: number;
previousDisabled: boolean;
nextDisabled: boolean;
onPrevious: () => void;
onNext: () => void;
onAddCategory: () => void;
onPrevious: () => void;
onReload?: () => void;
latest?: { version: number; details: string; onAdopt: () => void };
saveDisabled: boolean;
onSubmit: SubmitEventHandler<HTMLFormElement>;
opened: boolean;
pending: boolean;
previousDisabled: boolean;
refund: RefundFieldsProps;
refundFeedback?: ReactNode;
categoriesFeedback?: ReactNode;
feedback?: ReactNode;
saveDisabled: boolean;
sourcePage: number;
};
const OperationFormView: FC<OperationFormViewProps> = (props) => {
const { t } = useTranslation();
return (
<Modal
opened={props.opened}
onExitTransitionEnd={props.onExitTransitionEnd}
onClose={props.onClose}
title={t(props.editing ? "bank.editOperation" : "bank.addOperation")}
closeOnEscape={!props.pending && !props.categoriesOpen}
closeOnClickOutside={!props.pending && !props.categoriesOpen}
closeButtonProps={{
"aria-label": t("bank.close"),
disabled: props.pending,
"disabled": props.pending,
}}
closeOnClickOutside={!props.pending && !props.categoriesOpen}
closeOnEscape={!props.pending && !props.categoriesOpen}
onClose={props.onClose}
onExitTransitionEnd={props.onExitTransitionEnd}
opened={props.opened}
title={t(props.editing ? "bank.editOperation" : "bank.addOperation")}
>
<form onSubmit={props.onSubmit}>
<fieldset disabled={props.pending} className={classes.fieldset}>
<fieldset className={classes.fieldset} disabled={props.pending}>
<Stack>
<OperationFields {...props.fields} />
{props.fields.kind === "refund" && (
@@ -252,17 +255,17 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
{!props.editing && (
<Group>
<Button
variant="default"
disabled={props.previousDisabled}
onClick={props.onPrevious}
variant="default"
>
{t("bank.previous")}
</Button>
<Text>{t("bank.page", { page: props.sourcePage })}</Text>
<Button
variant="default"
disabled={props.nextDisabled}
onClick={props.onNext}
variant="default"
>
{t("bank.next")}
</Button>
@@ -272,14 +275,14 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
</>
)}
{props.fields.kind === "expense" && (
<Button variant="subtle" onClick={props.onAddCategory}>
<Button onClick={props.onAddCategory} variant="subtle">
{t("bank.addCategory")}
</Button>
)}
{props.categoriesFeedback}
{props.feedback}
{props.onReload && (
<Button variant="default" onClick={props.onReload}>
<Button onClick={props.onReload} variant="default">
{t("bank.reloadRecord")}
</Button>
)}
@@ -288,20 +291,20 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
<Text>
{t("bank.versionHint", { details: props.latest.details })}
</Text>
<Button variant="default" onClick={props.latest.onAdopt}>
<Button onClick={props.latest.onAdopt} variant="default">
{t("bank.adoptVersion", { version: props.latest.version })}
</Button>
</>
)}
<Group>
<Button
type="submit"
loading={props.pending}
disabled={props.saveDisabled}
loading={props.pending}
type="submit"
>
{t("bank.save")}
</Button>
<Button variant="default" onClick={props.onCancel}>
<Button onClick={props.onCancel} variant="default">
{t("bank.cancel")}
</Button>
</Group>
@@ -3,11 +3,12 @@ import { useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import {
type BankOperation,
bankDateInputValue,
bankDateSchema,
bankMoneyValue,
type BankOperation,
bankOperationKinds,
bankToday,
moneyCents,
@@ -20,6 +21,7 @@ import {
} from "@/entities/bank-card";
import { validationMessage } from "@/shared/i18n";
import { formatDate, formatMoney } from "@/shared/lib";
import type {
OperationFormProps,
OperationValues,
@@ -28,12 +30,9 @@ import type {
const MAX_DESCRIPTION_LENGTH = 500;
const operationSchema = z.object({
kind: z.enum(bankOperationKinds),
cardId: z.string(),
targetCardId: z.string(),
categoryId: z.string(),
originalExpenseId: z.string(),
amount: positiveMoneySchema,
cardId: z.string(),
categoryId: z.string(),
date: bankDateSchema,
description: z
.string()
@@ -41,8 +40,38 @@ const operationSchema = z.object({
MAX_DESCRIPTION_LENGTH,
validationMessage("bank.validation.description")(undefined),
),
kind: z.enum(bankOperationKinds),
originalExpenseId: z.string(),
targetCardId: z.string(),
});
function validateRefund(values: OperationValues, context: z.RefinementCtx,
expense: BankOperation | undefined, limit: BankOperation["availableRefund"] | undefined) {
if (!expense || expense.cardId !== values.cardId)
context.addIssue({
code: "custom",
path: ["originalExpenseId"],
...validationMessage("bank.validation.required")(undefined),
});
if (expense && values.date < expense.date)
context.addIssue({
code: "custom",
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
const amount = moneyCents(values.amount);
if (
limit
&& amount !== null
&& amount > (moneyCents(limit.value) ?? 0n)
)
context.addIssue({
code: "custom",
path: ["amount"],
...validationMessage("bank.validation.refund")(undefined),
});
}
const useOperationFormVm = (props: OperationFormProps) => {
useBankFormFocus();
const [opened, setOpened] = useState(true);
@@ -65,97 +94,70 @@ const useOperationFormVm = (props: OperationFormProps) => {
const categories = useBankCategories();
const mutation = useBankMutations();
const methods = useForm<OperationValues>({
defaultValues: {
amount: props.operation?.amount.value ?? "",
cardId: props.operation?.cardId ?? props.card.id,
categoryId: props.operation?.categoryId ?? "",
date: props.operation?.date ?? bankToday(),
description: props.operation?.description ?? "",
kind: props.operation?.kind ?? "expense",
originalExpenseId: props.operation?.originalExpenseId ?? "",
targetCardId: props.operation?.targetCardId ?? "",
},
resolver: zodResolver(
operationSchema.superRefine((values, ctx) => {
operationSchema.superRefine((values, context) => {
// Repeated intent reaches server idempotency even after its source changed.
if (
submittedValues &&
JSON.stringify(values) === JSON.stringify(submittedValues)
submittedValues
&& JSON.stringify(values) === JSON.stringify(submittedValues)
)
return;
const source = props.cards.find((c) => c.id === values.cardId);
const target = props.cards.find((c) => c.id === values.targetCardId);
const source = props.cards.find(c => c.id === values.cardId);
const target = props.cards.find(c => c.id === values.targetCardId);
if (!source || source.archivedAt)
ctx.addIssue({
context.addIssue({
code: "custom",
path: ["cardId"],
...validationMessage("bank.validation.required")(undefined),
});
if (
values.kind === "transfer" &&
(!target || target.archivedAt || target.id === values.cardId)
values.kind === "transfer"
&& (!target || target.archivedAt || target.id === values.cardId)
)
ctx.addIssue({
context.addIssue({
code: "custom",
path: ["targetCardId"],
...validationMessage("bank.validation.destination")(undefined),
});
if (
(source && values.date < source.openingDate) ||
(values.kind === "transfer" &&
target &&
values.date < target.openingDate)
(source && values.date < source.openingDate)
|| (target
&& values.kind === "transfer"
&& values.date < target.openingDate)
)
ctx.addIssue({
context.addIssue({
code: "custom",
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
if (values.kind === "refund") {
const expense = sourceQuery.data;
if (!expense || expense.cardId !== values.cardId)
ctx.addIssue({
code: "custom",
path: ["originalExpenseId"],
...validationMessage("bank.validation.required")(undefined),
});
if (expense && values.date < expense.date)
ctx.addIssue({
code: "custom",
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
const limit = props.operation
? refundLimit
: expense?.availableRefund;
const amount = moneyCents(values.amount);
if (
limit &&
amount !== null &&
amount > (moneyCents(limit.value) ?? 0n)
)
ctx.addIssue({
code: "custom",
path: ["amount"],
...validationMessage("bank.validation.refund")(undefined),
});
}
if (values.kind === "refund") validateRefund(values, context, sourceQuery.data,
props.operation ? refundLimit : sourceQuery.data?.availableRefund);
if (
values.kind === "expense" &&
!categories.data?.some(
(c) =>
c.id ===
(values.categoryId ||
categories.data?.find((c) => c.isDefault)?.id),
values.kind === "expense"
&& !categories.data?.some(
c =>
c.id
=== (values.categoryId
|| categories.data?.find(c => c.isDefault)?.id),
)
)
ctx.addIssue({
context.addIssue({
code: "custom",
path: ["categoryId"],
...validationMessage("bank.validation.required")(undefined),
});
}),
),
defaultValues: {
kind: props.operation?.kind ?? "expense",
cardId: props.operation?.cardId ?? props.card.id,
targetCardId: props.operation?.targetCardId ?? "",
categoryId: props.operation?.categoryId ?? "",
originalExpenseId: props.operation?.originalExpenseId ?? "",
amount: props.operation?.amount.value ?? "",
date: props.operation?.date ?? bankToday(),
description: props.operation?.description ?? "",
},
});
const kind = useWatch({ control: methods.control, name: "kind" });
const expenses = useBankExpenses(
@@ -163,30 +165,24 @@ const useOperationFormVm = (props: OperationFormProps) => {
{ page: sourcePage },
);
const source = useWatch({ control: methods.control, name: "cardId" });
const pending = !opened || methods.formState.isSubmitting;
const isPending = !opened || methods.formState.isSubmitting;
const options = props.cards
.filter((c) => !c.archivedAt)
.map((c) => ({ value: c.id, label: c.name }));
.filter(c => !c.archivedAt)
.map(c => ({ label: c.name, value: c.id }));
const onSubmit = methods.handleSubmit(async (values) => {
setSubmittedValues(values);
setError(undefined);
try {
const body = {
kind: values.kind,
cardId: values.cardId,
...(values.kind === "transfer"
? { targetCardId: values.targetCardId }
: {}),
...(values.kind === "expense"
? {
categoryId:
values.categoryId ||
categories.data?.find((c) => c.isDefault)?.id,
}
: {}),
...(values.kind === "refund"
? { originalExpenseId: values.originalExpenseId }
: {}),
kind: values.kind,
...((values.kind === "transfer") && { targetCardId: values.targetCardId }),
...((values.kind === "expense") && {
categoryId:
values.categoryId
|| categories.data?.find(c => c.isDefault)?.id,
}),
...((values.kind === "refund") && { originalExpenseId: values.originalExpenseId }),
amount: {
currency: "RUB" as const,
value: bankMoneyValue(values.amount),
@@ -196,8 +192,8 @@ const useOperationFormVm = (props: OperationFormProps) => {
};
if (props.operation)
await mutation.updateOperation.mutateAsync({
id: props.operation.id,
body: { ...body, version },
id: props.operation.id,
});
else
await mutation.createOperation.mutateAsync({
@@ -205,8 +201,9 @@ const useOperationFormVm = (props: OperationFormProps) => {
...body,
});
setOpened(false);
} catch (failure) {
setError(failure);
}
catch (error_) {
setError(error_);
}
});
const onReload = async () => {
@@ -216,101 +213,103 @@ const useOperationFormVm = (props: OperationFormProps) => {
if (props.operation) {
setLatest(result.data);
setRefundLimit(result.data.availableRefund);
} else setError(undefined);
} else setError(result.error);
}
else setError(undefined);
}
else setError(result.error);
};
const expenseChoice = (item: BankOperation) => ({
value: item.id,
label: t("bank.expenseChoice", {
date: formatDate(item.date),
name: item.description || item.categoryName,
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(expenseChoice) ?? [];
const expenseOptions = expenses.data?.items.map(element => expenseChoice(element)) ?? [];
const selected = sourceQuery.data
? expenseChoice(sourceQuery.data)
: undefined;
return {
methods,
categoriesError: categories.isError ? categories.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
categoriesOpen,
error,
categoriesError: categories.isError ? categories.error : null,
expensesError: expenses.isError ? expenses.error : null,
sourceError: sourceQuery.isError ? sourceQuery.error : null,
retryCategories: () => void categories.refetch(),
retryExpenses: () => void expenses.refetch(),
retrySource: () => void sourceQuery.refetch(),
closeCategories: () => setCategoriesOpen(false),
error,
expensesError: expenses.isError ? expenses.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
methods,
onCategoryCreated: (id: string) => {
methods.setValue("categoryId", id);
setCategoriesOpen(false);
},
retryCategories: () => void categories.refetch(),
retryExpenses: () => void expenses.refetch(),
retrySource: () => void sourceQuery.refetch(),
sourceError: sourceQuery.isError ? sourceQuery.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
view: {
opened,
pending,
categoriesOpen,
editing: Boolean(props.operation),
onClose: () => !pending && !categoriesOpen && setOpened(false),
onExitTransitionEnd: props.onClose,
onCancel: () => setOpened(false),
onSubmit,
emptyExpenses: Boolean(
expenses.data && expenses.data.items.length === 0 && !props.operation,
),
fields: {
refundCardId: props.card.id,
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,
cards: options,
targets: options.filter((c) => c.value !== source),
categories:
categories.data?.map((c) => ({ value: c.id, label: c.name })) ?? [],
defaultCategory: categories.data?.find((c) => c.isDefault)?.id ?? "",
kinds: bankOperationKinds.map((value) => ({
value,
kinds: bankOperationKinds.map(value => ({
label: t(`bank.kinds.${value}`),
value,
})),
dateInputValue: bankDateInputValue,
validDate: (value: string) => bankDateSchema.safeParse(value).success,
maxDate: bankToday,
refundCardId: props.card.id,
targets: options.filter(c => c.value !== source),
validDate: (value: string) => bankDateSchema.safeParse(value).success,
},
refund: {
editing: Boolean(props.operation),
expenses:
selected &&
!expenseOptions.some((item) => item.value === selected.value)
? [selected, ...expenseOptions]
: expenseOptions,
onSelect: setSourceId,
limit: props.operation
? refundLimit
: sourceQuery.data?.availableRefund,
},
loadingExpenses: expenses.isLoading,
emptyExpenses: Boolean(
expenses.data && !expenses.data.items.length && !props.operation,
),
sourcePage,
previousDisabled: sourcePage === 1 || expenses.isFetching,
nextDisabled: !expenses.data?.hasNext || expenses.isFetching,
onPrevious: () => setSourcePage(sourcePage - 1),
onNext: () => setSourcePage(sourcePage + 1),
onAddCategory: () => setCategoriesOpen(true),
onReload: error && (props.operation || sourceId) ? onReload : undefined,
latest: latest
? {
version: latest.version,
details: `${formatDate(latest.date)} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
onAdopt: () => {
setVersion(latest.version);
setLatest(undefined);
setError(undefined);
},
version: latest.version,
}
: undefined,
loadingExpenses: expenses.isLoading,
nextDisabled: !expenses.data?.hasNext || expenses.isFetching,
onAddCategory: () => setCategoriesOpen(true),
onCancel: () => setOpened(false),
onClose: () => !isPending && !categoriesOpen && setOpened(false),
onExitTransitionEnd: props.onClose,
onNext: () => setSourcePage(sourcePage + 1),
onPrevious: () => setSourcePage(sourcePage - 1),
onReload: error && (sourceId || props.operation) ? onReload : undefined,
onSubmit,
opened,
pending: isPending,
previousDisabled: sourcePage === 1 || expenses.isFetching,
refund: {
editing: Boolean(props.operation),
expenses:
selected
&& expenseOptions.every(item => item.value !== selected.value)
? [selected, ...expenseOptions]
: expenseOptions,
limit: props.operation
? refundLimit
: sourceQuery.data?.availableRefund,
onSelect: setSourceId,
},
saveDisabled:
(kind === "expense" && !categories.data) ||
(kind === "refund" && !sourceQuery.data),
(kind === "expense" && !categories.data)
|| (kind === "refund" && !sourceQuery.data),
sourcePage,
},
};
};
+51 -41
View File
@@ -7,37 +7,41 @@ import {
waitFor,
within,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { CardsPage } from "./CardsPage";
const money = (value: string) => ({ currency: "RUB", value });
function renderCards(fail = false, categoryFail = false) {
function renderCards(isFail = false, hasCategoryFailure = false) {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
refresh: () => Promise.resolve(false),
});
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
categoryFail ? HttpResponse.error() : HttpResponse.json([]),
hasCategoryFailure ? HttpResponse.error() : HttpResponse.json([]),
),
http.get("*/api/v1/bank-cards", () =>
fail ? HttpResponse.error() : HttpResponse.json([]),
isFail ? HttpResponse.error() : HttpResponse.json([]),
),
http.get("*/api/v1/bank-cards/summary", () =>
HttpResponse.json({
month: "2026-09",
cardId: null,
expenses: money("0"),
refunds: money("0"),
net: money("0"),
categories: [],
}),
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
cardId: null,
categories: [],
expenses: money("0"),
month: "2026-09",
net: money("0"),
refunds: money("0"),
}
/* eslint-enable unicorn/no-null */)),
),
);
return render(
@@ -64,9 +68,9 @@ describe("CardsPage", () => {
http.post("*/api/v1/bank-cards", async ({ request }) => {
submitted.push((await request.json()) as Record<string, unknown>);
if (submitted.length === 1) {
await new Promise<void>((r) => {
resolve = r;
});
const deferred = Promise.withResolvers<void>();
resolve = deferred.resolve;
await deferred.promise;
return HttpResponse.error();
}
return HttpResponse.json({ id: submitted[0].id }, { status: 201 });
@@ -107,12 +111,14 @@ describe("CardsPage", () => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
);
expect(submitted[0]).toEqual(submitted[1]);
expect(submitted[0]).toMatchObject({
name: "Повседневная",
bankName: null,
lastFour: null,
openingBalance: money("-92233720368547758.08"),
});
expect(submitted[0]).toMatchObject((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
bankName: null,
lastFour: null,
name: "Повседневная",
openingBalance: money("-92233720368547758.08"),
}
/* eslint-enable unicorn/no-null */));
});
it("uses archive only for card list and keeps all-card summary including negative net", async () => {
renderCards();
@@ -121,17 +127,19 @@ describe("CardsPage", () => {
http.get("*/api/v1/bank-cards", ({ request }) => {
requests.push(new URL(request.url).search);
return HttpResponse.json([
{
id: "demo",
name: "Запасная",
bankName: null,
lastFour: null,
balance: money("0"),
archivedAt: "2026-09-01",
version: 1,
openingBalance: money("0"),
openingDate: "2026-01-01",
},
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
archivedAt: "2026-09-01",
balance: money("0"),
bankName: null,
id: "demo",
lastFour: null,
name: "Запасная",
openingBalance: money("0"),
openingDate: "2026-01-01",
version: 1,
}
/* eslint-enable unicorn/no-null */),
]);
}),
);
@@ -154,13 +162,15 @@ describe("CardsPage", () => {
server.use(
http.get("*/api/v1/bank-cards/summary", ({ request }) => {
requests.push(new URL(request.url).search);
return HttpResponse.json({
cardId: null,
expenses: money("0"),
refunds: money("0"),
net: money("0"),
categories: [],
});
return HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
cardId: null,
categories: [],
expenses: money("0"),
net: money("0"),
refunds: money("0"),
}
/* eslint-enable unicorn/no-null */));
}),
);
fireEvent.click(screen.getByLabelText("Период"));
@@ -202,7 +212,7 @@ describe("CardsPage", () => {
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
{ id: "default", name: "Другое", version: 1, isDefault: true },
{ id: "default", isDefault: true, name: "Другое", version: 1 },
]),
),
);
@@ -1,5 +1,7 @@
import type { FC } from "react";
import { BankCardForm, CategoryManager } from "@/entities/bank-card";
import { CardsPageView } from "./CardsPage.view";
import { useCardsPageVm } from "./CardsPage.vm";
+71 -57
View File
@@ -1,3 +1,5 @@
import type { FC } from "react";
import {
ActionIcon,
Button,
@@ -12,79 +14,91 @@ import {
} from "@mantine/core";
import { AddIcon } from "@wealthledger/icons";
import { LoadingSkeleton, StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
BankCardFace,
BankFeedback,
BankPeriodPicker,
BankSummaryView,
} from "@/entities/bank-card";
import classes from "./CardsPage.module.css";
import type { CardsPageVm } from "./CardsPage.vm";
import classes from "./CardsPage.module.css";
type CardsPageViewProps = { vm: CardsPageVm };
const CardsPageView: FC<CardsPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<Stack gap="lg">
<Group gap="sm">
<Title order={1}>{t("bank.title")}</Title>
<ActionIcon
size="lg"
variant="light"
aria-label={t("bank.addCard")}
title={t("bank.addCard")}
onClick={() => vm.setAdding(true)}
>
<AddIcon size={20} strokeWidth={2} />
</ActionIcon>
</Group>
<Switch
label={t("bank.showArchive")}
checked={vm.archive}
onChange={(event) => vm.setArchive(event.currentTarget.checked)}
/>
{vm.items ? (
vm.items.length ? (
function renderCards() {
if (vm.items) {
if (vm.items.length > 0) {
return (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
{vm.items.map((card) => (
{vm.items.map(card => (
<Paper
key={card.id}
component={Link}
to={card.to}
p={0}
className={classes.card}
component={Link}
key={card.id}
p={0}
to={card.to}
>
<BankCardFace card={card} />
</Paper>
))}
</SimpleGrid>
) : (
<StateView
title={t("bank.empty")}
text={t(vm.archive ? "bank.emptyHint" : "bank.noActive")}
action={
<Button onClick={() => vm.setAdding(true)}>
{t("bank.addCard")}
</Button>
}
/>
)
) : vm.cards.isLoading ? (
);
}
return (
<StateView
action={(
<Button onClick={() => vm.setAdding(true)}>
{t("bank.addCard")}
</Button>
)}
text={t(vm.archive ? "bank.emptyHint" : "bank.noActive")}
title={t("bank.empty")}
/>
);
}
if (vm.cards.isLoading) {
return (
<LoadingSkeleton label={t("bank.loading")}>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Skeleton h={250} />
<Skeleton h={250} />
</SimpleGrid>
</LoadingSkeleton>
) : null}
);
}
}
return (
<Stack gap="lg">
<Group gap="sm">
<Title order={1}>{t("bank.title")}</Title>
<ActionIcon
aria-label={t("bank.addCard")}
onClick={() => vm.setAdding(true)}
size="lg"
title={t("bank.addCard")}
variant="light"
>
<AddIcon size={20} strokeWidth={2} />
</ActionIcon>
</Group>
<Switch
checked={vm.archive}
label={t("bank.showArchive")}
onChange={event => vm.setArchive(event.currentTarget.checked)}
/>
{renderCards()}
{vm.cards.isError && (
<Group role="alert">
<Text>{t(vm.items ? "bank.refreshError" : "bank.error")}</Text>
<Button variant="default" onClick={() => void vm.cards.refetch()}>
<Button onClick={() => void vm.cards.refetch()} variant="default">
{t("bank.retry")}
</Button>
</Group>
@@ -93,26 +107,26 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
<Text role="status">{t("bank.refreshing")}</Text>
)}
<Group align="flex-end" justify="space-between">
<BankPeriodPicker period={vm.period} onChange={vm.setPeriod} />
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
{Boolean(vm.categories.data?.length || vm.cards.data?.length) && (
<Button
variant="subtle"
color="gray"
onClick={() => vm.setManagingCategories(true)}
leftSection={
leftSection={(
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinejoin="round"
aria-hidden="true"
fill="none"
height="18"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.7"
viewBox="0 0 24 24"
width="18"
>
<path d="M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z" />
</svg>
}
)}
onClick={() => vm.setManagingCategories(true)}
variant="subtle"
>
{t("bank.categories")}
</Button>
@@ -121,16 +135,16 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
<BankSummaryView
all
data={vm.summary.data}
loading={vm.summary.isLoading}
error={vm.summary.isError}
fetching={vm.summary.isFetching}
loading={vm.summary.isLoading}
retry={() => void vm.summary.refetch()}
/>
{vm.categories.isError && (
<BankFeedback
loadingError
error={vm.categories.error}
cached={Boolean(vm.categories.data)}
error={vm.categories.error}
loadingError
retry={() => void vm.categories.refetch()}
/>
)}
+16 -15
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useSearchParams } from "react-router";
import {
bankPeriod,
useBankCards,
@@ -7,9 +8,10 @@ import {
useBankSummary,
} from "@/entities/bank-card";
type CardsPageVm = ReturnType<typeof useCardsPageVm>;
function useCardsPageVm() {
const [params, setParams] = useSearchParams();
const period = bankPeriod(params);
const [parameters, setParameters] = useSearchParams();
const period = bankPeriod(parameters);
const [archive, setArchive] = useState(false);
const categories = useBankCategories();
const [managingCategories, setManagingCategories] = useState(false);
@@ -17,25 +19,24 @@ function useCardsPageVm() {
const cards = useBankCards(archive);
const summary = useBankSummary(period);
return {
categories,
managingCategories,
setManagingCategories,
period,
archive,
setArchive,
adding,
setAdding,
archive,
cards,
summary,
setPeriod: (value: [string | null, string | null]) => {
if (value[0] && value[1]) setParams({ from: value[0], to: value[1] });
},
items: cards.data?.map((card) => ({
categories,
items: cards.data?.map(card => ({
...card,
to: `/cards/${card.id}?${new URLSearchParams(period)}`,
})),
managingCategories,
period,
setAdding,
setArchive,
setManagingCategories,
setPeriod: (value: [null | string, null | string]) => {
if (value[0] && value[1]) setParameters({ from: value[0], to: value[1] });
},
summary,
};
}
type CardsPageVm = ReturnType<typeof useCardsPageVm>;
export { type CardsPageVm, useCardsPageVm };
+3 -1
View File
@@ -1,6 +1,8 @@
import { useDocumentTitle } from "@mantine/hooks";
import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { AuthPageLayout, LoginForm } from "@/features/auth";
const LoginPage: FC = () => {
+6 -5
View File
@@ -1,7 +1,8 @@
import type { FC } from "react";
import { Button, Stack, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -13,13 +14,13 @@ const NotFoundPage: FC = () => {
<Stack>
<Title order={1}>{t("notFound.title")}</Title>
<StateView
title={t("states.emptyTitle")}
text={t("states.emptyText")}
action={
action={(
<Button component={Link} to="/accounts">
{t("notFound.action")}
</Button>
}
)}
text={t("states.emptyText")}
title={t("states.emptyTitle")}
/>
</Stack>
);
+3 -1
View File
@@ -1,6 +1,8 @@
import { useDocumentTitle } from "@mantine/hooks";
import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { AuthPageLayout, RegisterForm } from "@/features/auth";
const RegisterPage: FC = () => {
+3 -1
View File
@@ -1,7 +1,9 @@
import { Button, Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { Button, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useAuth } from "@/features/auth";
import { TbankConnectionSection } from "@/features/tbank-connection";
+200 -179
View File
@@ -1,16 +1,19 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { StockPage } from "./StockPage";
import type { StockPageVm } from "./StockPage.vm";
import { StockPage } from "./StockPage";
const LocationProbe = () => {
const location = useLocation();
return (
@@ -22,64 +25,66 @@ const LocationProbe = () => {
};
const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13";
const money = (value: string) => ({ value, currency: "RUB" });
const stock = {
instrumentUid: uid,
name: "Тестовая акция",
ticker: "TEST",
shareType: "SHARE_TYPE_COMMON",
currency: "RUB",
sector: "financial",
lot: 10,
logoName: null,
historyFrom: null,
};
const money = (value: string) => ({ currency: "RUB", value });
const stock = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
currency: "RUB",
historyFrom: null,
instrumentUid: uid,
logoName: null,
lot: 10,
name: "Тестовая акция",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: "TEST",
}
/* eslint-enable unicorn/no-null */);
const position = {
quantity: "10",
currentValue: money("3000"),
averagePurchasePrice: money("200"),
absoluteReturn: money("1000"),
returnPercent: "50",
accounts: [
{
absoluteReturn: money("1000"),
account: { id: "main", name: "Основной" },
averagePurchasePrice: money("200"),
currentValue: money("3000"),
instrumentUid: uid,
quantity: "10",
currentValue: money("3000"),
averagePurchasePrice: money("200"),
absoluteReturn: money("1000"),
returnPercent: "50",
account: { id: "main", name: "Основной" },
},
],
averagePurchasePrice: money("200"),
currentValue: money("3000"),
isStale: false,
observedAt: "2026-09-10T10:00:00Z",
quantity: "10",
returnPercent: "50",
};
const quote = {
change: money("10"),
changePercent: "3.448276",
observedAt: "2026-09-10T10:00:00Z",
previousClose: money("290"),
previousCloseDate: "2026-09-09",
price: money("300"),
priceAt: "2026-09-10T10:00:00Z",
tradingStatus: "SECURITY_TRADING_STATUS_NORMAL_TRADING",
change: money("10"),
changePercent: "3.448276",
previousCloseDate: "2026-09-09",
previousClose: money("290"),
unavailable: [],
observedAt: "2026-09-10T10:00:00Z",
};
function setup(
options: {
noPosition?: boolean;
ambiguousTicker?: boolean;
cursorError?: string;
failCompany?: boolean;
failStock?: boolean;
cursorError?: string;
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
noPosition?: boolean;
path?: string;
slug?: string;
ambiguousTicker?: boolean;
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
} = {},
) {
const requests: URL[] = [];
const resolved = { ...stock, slug: options.slug ?? uid };
let failQuote = false;
let isFailQuote = false;
let cursorError = options.cursorError;
let operationPayment = "200";
server.use(
@@ -97,109 +102,117 @@ function setup(
http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => {
requests.push(new URL(request.url));
if (params.section === "quote")
return failQuote
return isFailQuote
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource(quote));
if (params.section === "position")
return HttpResponse.json(
resource(
options.noPosition
? { ...position, quantity: "0", accounts: [] }
? { ...position, accounts: [], quantity: "0" }
: position,
),
);
if (params.section === "candles")
return HttpResponse.json(
resource({
candles: [],
currency: "RUB",
historyFrom: null,
observedAt: quote.observedAt,
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
candles: [],
currency: "RUB",
historyFrom: null,
observedAt: quote.observedAt,
}
/* eslint-enable unicorn/no-null */)),
);
if (params.section === "company")
return options.failCompany
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
resource({
description: "Описание компании",
metrics: options.metrics ?? [
{
key: "peRatioTtm",
value: 5.6,
unit: "ratio",
currency: null,
period: "TTM",
},
],
unavailable: [],
observedAt: quote.observedAt,
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
description: "Описание компании",
metrics: options.metrics ?? [
{
currency: null,
key: "peRatioTtm",
period: "TTM",
unit: "ratio",
value: 5.6,
},
],
observedAt: quote.observedAt,
unavailable: [],
}
/* eslint-enable unicorn/no-null */)),
);
return HttpResponse.json(
resource({
dividends: [
{
perShare: money("32.88"),
recordDate: "2026-10-13",
lastBuyDate: "2026-10-12",
paymentDate: "2026-10-27",
timing: "upcoming",
estimatedAmount: options.noPosition ? null : money("328.8"),
},
{
perShare: money("20"),
recordDate: "2025-10-13",
paymentDate: null,
lastBuyDate: null,
timing: "past",
estimatedAmount: null,
},
],
from: "2021-09-10",
to: "2027-09-10",
observedAt: quote.observedAt,
positionAt: null,
estimateUnavailable: false,
positionIsStale: false,
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
dividends: [
{
estimatedAmount: options.noPosition ? null : money("328.8"),
lastBuyDate: "2026-10-12",
paymentDate: "2026-10-27",
perShare: money("32.88"),
recordDate: "2026-10-13",
timing: "upcoming",
},
{
estimatedAmount: null,
lastBuyDate: null,
paymentDate: null,
perShare: money("20"),
recordDate: "2025-10-13",
timing: "past",
},
],
estimateUnavailable: false,
from: "2021-09-10",
observedAt: quote.observedAt,
positionAt: null,
positionIsStale: false,
to: "2027-09-10",
}
/* eslint-enable unicorn/no-null */)),
);
}),
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
const url = new URL(request.url);
requests.push(url);
const next = url.searchParams.has("cursor");
if (next && cursorError)
const isNext = url.searchParams.has("cursor");
if (isNext && cursorError)
return HttpResponse.json({ code: cursorError }, { status: 400 });
return HttpResponse.json(
resource({
accountId: "all",
observedAt: quote.observedAt,
hasNext: !next,
nextCursor: next ? null : "next",
items: [
{
id: next ? "sold" : "dividend",
occurredAt: next
? "2022-01-02T10:00:00Z"
: "2025-01-02T10:00:00Z",
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
category: next ? "trade" : "income",
description: "",
account: { id: "main", name: "Основной" },
payment: money(operationPayment),
quantity: next ? "2" : null,
price: next ? money("100") : null,
},
],
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accountId: "all",
hasNext: !isNext,
items: [
{
account: { id: "main", name: "Основной" },
category: isNext ? "trade" : "income",
description: "",
id: isNext ? "sold" : "dividend",
occurredAt: isNext
? "2022-01-02T10:00:00Z"
: "2025-01-02T10:00:00Z",
payment: money(operationPayment),
price: isNext ? money("100") : null,
quantity: isNext ? "2" : null,
type: isNext ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
},
],
nextCursor: isNext ? null : "next",
observedAt: quote.observedAt,
}
/* eslint-enable unicorn/no-null */)),
);
}),
);
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => false,
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
const client = createTestQueryClient();
render(
@@ -208,71 +221,73 @@ function setup(
<MemoryRouter initialEntries={[options.path ?? `/stocks/${uid}`]}>
<LocationProbe />
<Routes>
<Route path="/stocks/:stockId" element={<StockPage />} />
<Route element={<StockPage />} path="/stocks/:stockId" />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return {
requests,
failQuote: () => {
isFailQuote = true;
},
recoverOperations: () => {
cursorError = undefined;
operationPayment = "201";
},
failQuote: () => {
failQuote = true;
},
requests,
};
}
describe("stock page", () => {
it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => {
setup({
metrics: [
{
key: "marketCapitalization",
value: 1234567.89,
unit: "money",
currency: null,
period: null,
},
{
key: "revenueTtm",
value: 1234567.89,
unit: "money",
currency: "RUB",
period: "TTM",
},
{
key: "ebitdaTtm",
value: 1234567.89,
unit: "money",
currency: "USD",
period: "TTM",
},
{
key: "netIncomeTtm",
value: 1234567.89,
unit: "money",
currency: "CAD",
period: "TTM",
},
{
key: "peRatioTtm",
value: 5.678,
unit: "ratio",
currency: null,
period: "TTM",
},
{
key: "roe",
value: 12.3456,
unit: "percent",
currency: null,
period: null,
},
],
});
setup((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
metrics: [
{
currency: null,
key: "marketCapitalization",
period: null,
unit: "money",
value: 1_234_567.89,
},
{
currency: "RUB",
key: "revenueTtm",
period: "TTM",
unit: "money",
value: 1_234_567.89,
},
{
currency: "USD",
key: "ebitdaTtm",
period: "TTM",
unit: "money",
value: 1_234_567.89,
},
{
currency: "CAD",
key: "netIncomeTtm",
period: "TTM",
unit: "money",
value: 1_234_567.89,
},
{
currency: null,
key: "peRatioTtm",
period: "TTM",
unit: "ratio",
value: 5.678,
},
{
currency: null,
key: "roe",
period: null,
unit: "percent",
value: 12.3456,
},
],
}
/* eslint-enable unicorn/no-null */));
expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible();
for (const value of [
"1,23 млн ₽",
@@ -288,12 +303,12 @@ describe("stock page", () => {
await screen.findByText("Зачисление дивидендов");
expect(screen.getByTestId("location")).toHaveTextContent("/stocks/SBERP");
expect(
requests.some((url) => url.pathname === `/api/v1/stocks/${uid}/quote`),
requests.some(url => url.pathname === `/api/v1/stocks/${uid}/quote`),
).toBe(true);
expect(
requests.some((url) => url.searchParams.get("instrumentUid") === uid),
requests.some(url => url.searchParams.get("instrumentUid") === uid),
).toBe(true);
expect(requests.some((url) => url.pathname.includes("/SBERP/quote"))).toBe(
expect(requests.some(url => url.pathname.includes("/SBERP/quote"))).toBe(
false,
);
});
@@ -307,15 +322,15 @@ describe("stock page", () => {
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
});
it("offers search for ambiguous tickers without requesting personal data", async () => {
const { requests } = setup({ path: "/stocks/SAME", ambiguousTicker: true });
const { requests } = setup({ ambiguousTicker: true, path: "/stocks/SAME" });
expect(
await screen.findByText(/Этот тикер относится к нескольким выпускам/),
).toBeVisible();
expect(
requests.some(
(url) =>
url.pathname.endsWith("/operations") ||
url.pathname.endsWith("/position"),
url =>
url.pathname.endsWith("/operations")
|| url.pathname.endsWith("/position"),
),
).toBe(false);
});
@@ -346,15 +361,17 @@ describe("stock page", () => {
await waitFor(() => expect(screen.getByText("13.10.2025")).toBeVisible());
});
it("keeps sold-position history and market content when company data fails", async () => {
setup({ noPosition: true, failCompany: true });
setup({ failCompany: true, noPosition: true });
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
const message = await screen.findByText(/Не удалось загрузить этот раздел/);
expect(
(await screen.findByText(/Не удалось загрузить этот раздел/)).closest(
'[role="status"]',
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest(
"[role=\"status\"]",
),
).toHaveAttribute("aria-live", "polite");
});
@@ -362,7 +379,7 @@ describe("stock page", () => {
const { requests } = setup({ failCompany: true });
const retry = await screen.findByRole("button", { name: "Повторить" });
await screen.findByText("Зачисление дивидендов");
const modes: (string | null)[] = [];
const modes: (null | string)[] = [];
server.use(
http.get("*/api/v1/stocks/:uid/company", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
@@ -370,8 +387,8 @@ describe("stock page", () => {
resource({
description: "Восстановленное описание",
metrics: [],
unavailable: [],
observedAt: quote.observedAt,
unavailable: [],
}),
);
}),
@@ -393,18 +410,18 @@ describe("stock page", () => {
expect(screen.getByText(/02.01.2022, 10:00 UTC/)).toBeVisible();
expect(
requests.some(
(url) =>
url.searchParams.get("cursor") === "next" &&
url.searchParams.get("instrumentUid") === uid,
url =>
url.searchParams.get("cursor") === "next"
&& url.searchParams.get("instrumentUid") === uid,
),
).toBe(true);
fireEvent.click(screen.getByRole("radio", { name: "Сделки" }));
await waitFor(() =>
expect(
requests.some(
(url) =>
url.searchParams.get("categories") === "trade" &&
!url.searchParams.has("cursor"),
url =>
url.searchParams.get("categories") === "trade"
&& !url.searchParams.has("cursor"),
),
).toBe(true),
);
@@ -419,12 +436,12 @@ describe("stock page", () => {
fireEvent.click(screen.getByRole("radio", { name: "Год" }));
await waitFor(() =>
expect(
requests.some((url) => url.searchParams.get("period") === "year"),
requests.some(url => url.searchParams.get("period") === "year"),
).toBe(true),
);
});
it.each([
["INVALID_OPERATIONS_CURSOR", null, null],
["INVALID_OPERATIONS_CURSOR", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, null],
["TBANK_UNAVAILABLE", "next", "observe"],
])(
"recovers history after %s with fresh data and the correct cursor",
@@ -447,7 +464,9 @@ describe("stock page", () => {
expect(recovered[0]?.searchParams.get("cursor")).toBe(cursor);
expect(recovered[0]?.searchParams.get("sync")).toBe(sync);
if (cursor)
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled();
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
else expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
},
);
@@ -468,20 +487,22 @@ describe("stock page", () => {
await screen.findByText("300 ₽");
controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
const message = await screen.findByText(/последние полученные/);
expect(
(await screen.findByText(/последние полученные/)).closest(
'[role="status"]',
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest(
"[role=\"status\"]",
),
).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("300 ₽")).toBeVisible();
expect(scopes).toEqual([
{
screen: "stock",
instrumentUid: uid,
accountId: "all",
candlePeriod: "year",
categories: ["trade"],
instrumentUid: uid,
limit: 20,
screen: "stock",
},
]);
});
+9 -8
View File
@@ -1,15 +1,17 @@
import type { FC } from "react";
import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link, Navigate } from "react-router";
import { StockPageView } from "./StockPage.view";
import { useStockPageVm } from "./StockPage.vm";
const StockPage: FC = () => {
const vm = useStockPageVm();
const { t } = useTranslation();
if (vm.canonicalTo) return <Navigate to={vm.canonicalTo} replace />;
if (vm.canonicalTo) return <Navigate replace to={vm.canonicalTo} />;
if (vm.stockState.loading)
return (
<LoadingSkeleton label={t("stock.loading")}>
@@ -20,18 +22,17 @@ const StockPage: FC = () => {
</Stack>
</LoadingSkeleton>
);
const unavailableMessage = vm.code === "STOCK_TICKER_AMBIGUOUS" ? "stock.ambiguousTicker" : "stock.unavailableText";
if (!vm.stock)
return (
<Stack role="alert">
<Title order={1}>{t("stock.unavailable")}</Title>
<Text>
{t(
vm.code === "TBANK_NOT_CONNECTED" ||
vm.code === "INVALID_TBANK_TOKEN"
vm.code === "TBANK_NOT_CONNECTED"
|| vm.code === "INVALID_TBANK_TOKEN"
? "stock.connect"
: vm.code === "STOCK_TICKER_AMBIGUOUS"
? "stock.ambiguousTicker"
: "stock.unavailableText",
: unavailableMessage,
)}
</Text>
<Anchor component={Link} to="/stocks">
@@ -40,7 +41,7 @@ const StockPage: FC = () => {
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button variant="default" onClick={vm.stockState.retry}>
<Button onClick={vm.stockState.retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
+134 -119
View File
@@ -1,3 +1,5 @@
import type { FC } from "react";
import {
Alert,
Avatar,
@@ -11,8 +13,8 @@ import {
Title,
} from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
InstrumentChart as StockChart,
InstrumentEvents as StockEvents,
@@ -25,27 +27,29 @@ import {
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
import type { StockPageVm } from "./StockPage.vm";
import { StockDividends } from "./components/StockEvents.view";
import classes from "./StockPage.module.css";
import type { StockPageVm } from "./StockPage.vm";
type StockPageViewProps = { vm: StockPageVm };
const StockPageView: FC<StockPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const stock = vm.stock;
if (!stock) return null;
if (!stock) return;
return (
<Stack className={classes.page} gap="lg">
<Paper component="header" className={classes.section} withBorder>
<Group justify="space-between" align="start">
<Paper className={classes.section} component="header" withBorder>
<Group align="start" justify="space-between">
<div className={classes.identity}>
<Avatar
key={vm.logoSrc}
src={vm.logoSrc}
alt=""
size={56}
key={vm.logoSrc}
radius="md"
size={56}
src={vm.logoSrc}
>
{stock.name[0]}
</Avatar>
@@ -53,8 +57,11 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
<Title order={1} size="h2">
{stock.name}
</Title>
<Text size="sm" c="dimmed">
{stock.ticker} ·{" "}
<Text c="dimmed" size="sm">
{stock.ticker}
{" "}
·
{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
@@ -62,152 +69,155 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
</div>
</div>
<Button
variant="subtle"
onClick={vm.refresh}
disabled={vm.refreshing}
onClick={vm.refresh}
variant="subtle"
>
{t("investment.refresh")}
</Button>
</Group>
{vm.company?.description ? (
<Text size="sm" mt="md" style={{ whiteSpace: "pre-line" }}>
{!!(vm.company?.description) && ((
<Text mt="md" size="sm" style={{ whiteSpace: "pre-line" }}>
{vm.company.description}
</Text>
) : null}
{vm.refreshError || vm.stockState.error || vm.quoteState.error ? (
<Alert role="status" aria-live="polite" color="orange" mt="md">
))}
{!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && ((
<Alert aria-live="polite" color="orange" mt="md" role="status">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
) : null}
{vm.quoteState.loading ? (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton mt="lg" h={55} w="50%" />
</LoadingSkeleton>
) : (
<div aria-live="polite">
<Text className={classes.price} mt="lg">
{formatMoney(vm.quote?.price ?? null)}
</Text>
{vm.quote?.change ? (
<Text
fw={600}
className={
vm.quote.change.value.startsWith("-")
? classes.negative
: classes.positive
}
>
{vm.quote.change.value.startsWith("-") ? "" : "+"}
{formatMoney(vm.quote.change)}
{vm.quote.changePercent !== null
? ` (${formatDecimal(vm.quote.changePercent)} %)`
: ""}
</Text>
) : null}
{vm.quote?.previousCloseDate ? (
<Text size="xs" c="dimmed">
{t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})}
</Text>
) : (
<Text size="xs" c="dimmed">
{t("stock.noComparison")}
</Text>
)}
<Group gap="sm" mt="sm">
<Badge variant="light" color="gray">
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{vm.quote?.priceAt ? (
<Text size="xs" c="dimmed">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
))}
{vm.quoteState.loading
? (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton h={55} mt="lg" w="50%" />
</LoadingSkeleton>
)
: (
<div aria-live="polite">
<Text className={classes.price} mt="lg">
{formatMoney(vm.quote?.price)}
</Text>
) : null}
</Group>
{vm.quote?.unavailable.length ? (
<Text c="dimmed" size="xs" mt="xs">
{t("stock.partialQuote")}
</Text>
) : null}
{vm.quoteState.refreshing ? (
<Text role="status" size="xs" c="dimmed">
{t("accounts.refreshing")}
</Text>
) : null}
</div>
)}
{!!(vm.quote?.change) && ((
<Text
className={
vm.quote.change.value.startsWith("-")
? classes.negative
: classes.positive
}
fw={600}
>
{vm.quote.change.value.startsWith("-") ? "" : "+"}
{formatMoney(vm.quote.change)}
{vm.quote.changePercent === null
? ""
: ` (${formatDecimal(vm.quote.changePercent)} %)`}
</Text>
))}
{vm.quote?.previousCloseDate
? (
<Text c="dimmed" size="xs">
{t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})}
</Text>
)
: (
<Text c="dimmed" size="xs">
{t("stock.noComparison")}
</Text>
)}
<Group gap="sm" mt="sm">
<Badge color="gray" variant="light">
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{!!(vm.quote?.priceAt) && ((
<Text c="dimmed" size="xs">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
</Text>
))}
</Group>
{!!(vm.quote?.unavailable.length) && ((
<Text c="dimmed" mt="xs" size="xs">
{t("stock.partialQuote")}
</Text>
))}
{!!(vm.quoteState.refreshing) && ((
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
))}
</div>
)}
<StockSection
title={t("stock.company")}
state={vm.companyState}
embedded
state={vm.companyState}
title={t("stock.company")}
>
<Group gap="xl" mb="md">
{stock.sector ? (
{!!(stock.sector) && ((
<Text size="sm">
{t("stock.sector")}:{" "}
{t("stock.sector")}
:
{" "}
{t(`stock.sectors.${stock.sector}`, {
defaultValue: stock.sector,
})}
</Text>
) : null}
{stock.lot !== null ? (
))}
{stock.lot !== null && ((
<Text size="sm">{t("stock.lot", { value: stock.lot })}</Text>
) : null}
))}
</Group>
{vm.company ? (
{!!(vm.company) && ((
<Stack gap="lg">
{vm.company.unavailable.length ? (
<Alert color="orange" role="status" aria-live="polite">
{vm.company.unavailable.length > 0 && ((
<Alert aria-live="polite" color="orange" role="status">
<Group justify="space-between">
<Text size="sm">{t("stock.partialCompany")}</Text>
<Button
onClick={vm.companyState.retry}
size="xs"
variant="default"
onClick={vm.companyState.retry}
>
{t("accounts.retry")}
</Button>
</Group>
</Alert>
) : null}
{vm.company.metrics.length ? (
<div className={classes.companyMetrics}>
{vm.company.metrics.map((metric) => (
<div key={metric.key}>
<Text size="xs" c="dimmed">
{t(`stock.metrics.${metric.key}`)}
{metric.period ? ` · ${metric.period}` : ""}
</Text>
<Text fw={600} size="lg">
{new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation:
))}
{vm.company.metrics.length > 0
? (
<div className={classes.companyMetrics}>
{vm.company.metrics.map(metric => (
<div key={metric.key}>
<Text c="dimmed" size="xs">
{t(`stock.metrics.${metric.key}`)}
{metric.period ? ` · ${metric.period}` : ""}
</Text>
<Text fw={600} size="lg">
{new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation:
metric.unit === "money" ? "compact" : "standard",
}).format(metric.value)}{" "}
{metric.unit === "money"
? metric.currency === "RUB"
? "₽"
: (metric.currency ?? t("stock.currencyUnknown"))
: metric.unit === "percent"
? "%"
: "×"}
</Text>
}).format(metric.value)}
{" "}
{metricUnit(metric, t("stock.currencyUnknown"))}
</Text>
</div>
))}
</div>
))}
</div>
) : (
<Text c="dimmed">{t("stock.noFundamentals")}</Text>
)}
<Text size="xs" c="dimmed">
)
: (
<Text c="dimmed">{t("stock.noFundamentals")}</Text>
)}
<Text c="dimmed" size="xs">
{t("stock.periodNote")}
</Text>
</Stack>
) : null}
))}
</StockSection>
</Paper>
<StockChart vm={vm} />
@@ -218,4 +228,9 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
);
};
function metricUnit(metric: NonNullable<StockPageVm["company"]>["metrics"][number], unknownCurrency: string) {
if (metric.unit === "money") return metric.currency === "RUB" ? "₽" : (metric.currency ?? unknownCurrency);
return metric.unit === "percent" ? "%" : "×";
}
export { StockPageView };
+72 -69
View File
@@ -1,5 +1,7 @@
import { useParams, useSearchParams } from "react-router";
import type { OperationCategory } from "@/entities/broker-operation";
import {
type StockPeriod,
useStock,
@@ -22,118 +24,119 @@ const filters = ["all", "trades", "dividends", "other"] as const;
type StockEventFilter = (typeof filters)[number];
const categories: Record<StockEventFilter, OperationCategory[]> = {
all: [],
trades: ["trade"],
dividends: ["income"],
other: ["tax", "fee", "transfer", "other"],
trades: ["trade"],
};
type StockPageVm = ReturnType<typeof useStockPageVm>;
function useStockPageVm() {
const { stockId = "" } = useParams();
const stock = useStock(stockId);
const uid = stock.data?.instrumentUid ?? "";
const [params, setParams] = useSearchParams();
const period =
periods.find((value) => value === params.get("period")) ?? "month";
const filter =
filters.find((value) => value === params.get("events")) ?? "all";
const [parameters, setParameters] = useSearchParams();
const period
= periods.find(value => value === parameters.get("period")) ?? "month";
const filter
= filters.find(value => value === parameters.get("events")) ?? "all";
const quote = useStockQuote(uid);
const candles = useStockCandles(uid, period);
const position = useStockPosition(uid);
const company = useStockCompany(uid);
const dividends = useStockDividends(uid);
const history = useInstrumentOperations(uid, filter, categories[filter]);
const history = useInstrumentOperations(uid, filter, /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter]);
const { operations } = history;
const sync = useScreenSync(
{
screen: "stock",
instrumentUid: uid,
candlePeriod: period,
accountId: "all",
categories: categories[filter],
candlePeriod: period,
categories: /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter],
instrumentUid: uid,
limit: 20,
screen: "stock",
},
[stock, quote, candles, position, company, dividends, operations],
);
const setParam = (key: string, value: string) => {
const next = new URLSearchParams(params);
const setParameter = (key: string, value: string) => {
const next = new URLSearchParams(parameters);
next.set(key, value);
setParams(next, { replace: true });
setParameters(next, { replace: true });
};
const chartData =
candles.data?.candles.map((candle) => ({
const chartData
= candles.data?.candles.map(candle => ({
...candle,
date: formatDate(candle.time),
price: Number(candle.close),
})) ?? [];
const average = position.data?.averagePurchasePrice;
const averageLine =
average && average.currency === candles.data?.currency
const averageLine
= average && average.currency === candles.data?.currency
? Number(average.value)
: null;
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const companyData = company.data;
const search = parameters.size > 0 ? `?${parameters.toString()}` : "";
return {
refresh: sync.refresh,
refreshing: sync.refreshing,
refreshError: sync.transportError || sync.sourceError,
averageLine:
averageLine !== null && Number.isFinite(averageLine) ? averageLine : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
candles: candles.data,
candlesState: section(candles, sync.retry),
canonicalTo:
stock.data?.slug && stock.data.slug !== stockId
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
stock: stock.data,
stockState: section(stock, uid ? sync.retry : () => stock.refetch()),
code: apiErrorCode(stock.error),
logoSrc: instrumentLogoUrl(stock.data?.logoName),
quote: quote.data,
quoteState: section(quote, sync.retry),
candles: candles.data,
? `/stocks/${encodeURIComponent(stock.data.slug)}${search}`
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
chartData,
priceRows: chartData.toReversed(),
candlesState: section(candles, sync.retry),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine:
averageLine !== null && Number.isFinite(averageLine) ? averageLine : null,
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
positionState: section(position, sync.retry),
code: apiErrorCode(stock.error),
company: companyData,
companyState: section(company, sync.retry),
dividends: dividends.data,
dividendsState: section(dividends, sync.retry),
upcoming:
dividends.data?.dividends
.filter((event) => event.timing === "upcoming")
.sort((a, b) =>
(a.recordDate ?? a.paymentDate ?? "").localeCompare(
b.recordDate ?? b.paymentDate ?? "",
),
) ?? [],
pastDividends:
dividends.data?.dividends
.filter((event) => event.timing !== "upcoming")
.sort((a, b) =>
(b.recordDate ?? b.paymentDate ?? "").localeCompare(
a.recordDate ?? a.paymentDate ?? "",
),
) ?? [],
eventRows:
operations.data?.items.map(item => ({
...item,
logoName: item.logoName ?? stock.data?.logoName ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
ticker: item.ticker ?? stock.data?.ticker ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
})) ?? [],
filter,
hasIncompleteCandle:
candles.data?.candles.some(candle => !candle.isComplete) ?? false,
hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(stock.data?.logoName),
onNext: history.onNext,
onPrevious: history.onPrevious,
operations: operations.data,
operationsState: {
...section(operations, sync.retry),
retry: () => history.retry(sync.retry),
},
eventRows:
operations.data?.items.map((item) => ({
...item,
ticker: item.ticker ?? stock.data?.ticker ?? null,
logoName: item.logoName ?? stock.data?.logoName ?? null,
})) ?? [],
filter,
setFilter: (value: string) => setParam("events", value),
hasPrevious: history.hasPrevious,
onPrevious: history.onPrevious,
onNext: history.onNext,
pastDividends:
dividends.data?.dividends
.filter(event => event.timing !== "upcoming")
.toSorted((a, b) =>
(b.recordDate ?? b.paymentDate ?? "").localeCompare(
a.recordDate ?? a.paymentDate ?? "",
),
) ?? [],
period,
position: position.data,
positionState: section(position, sync.retry),
priceRows: chartData.toReversed(),
quote: quote.data,
quoteState: section(quote, sync.retry),
refresh: sync.refresh,
refreshError: sync.transportError || sync.sourceError,
refreshing: sync.refreshing,
setFilter: (value: string) => setParameter("events", value),
setPeriod: (value: string) => setParameter("period", value),
stock: stock.data,
stockState: section(stock, uid ? sync.retry : () => stock.refetch()),
upcoming:
dividends.data?.dividends
.filter(event => event.timing === "upcoming")
.toSorted((a, b) =>
(a.recordDate ?? a.paymentDate ?? "").localeCompare(
b.recordDate ?? b.paymentDate ?? "",
),
) ?? [],
};
}
type StockPageVm = ReturnType<typeof useStockPageVm>;
export { type StockPageVm, useStockPageVm };
@@ -1,3 +1,5 @@
import type { FC } from "react";
import {
Accordion,
Alert,
@@ -7,162 +9,171 @@ import {
Stack,
Text,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { InstrumentSection as StockSection } from "@/features/instrument-details";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
import classes from "../StockPage.module.css";
import type { StockPageVm } from "../StockPage.vm";
import classes from "../StockPage.module.css";
type StockEventsProps = { vm: StockPageVm };
const StockDividends: FC<StockEventsProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<StockSection title={t("stock.upcoming")} state={vm.dividendsState}>
{vm.dividends ? (
<StockSection state={vm.dividendsState} title={t("stock.upcoming")}>
{!!(vm.dividends) && ((
<Stack gap="md">
{vm.dividends.estimateUnavailable || vm.dividends.positionIsStale ? (
<Alert color="orange" role="status" aria-live="polite">
{!!(vm.dividends.estimateUnavailable || vm.dividends.positionIsStale) && ((
<Alert aria-live="polite" color="orange" role="status">
{t("stock.estimateUnavailable")}
</Alert>
) : null}
{vm.upcoming.length ? (
vm.upcoming.map((event) => (
<div
className={classes.dividend}
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
>
<Group justify="space-between" align="start">
<div>
<Badge color="green" variant="light">
{t("stock.companyEvent")}
</Badge>
<Text size="xl" fw={650} mt="xs">
{formatMoney(event.perShare)}{" "}
<Text component="span" size="sm" c="dimmed">
{t("stock.perShare")}
</Text>
</Text>
))}
{vm.upcoming.length > 0
? (
vm.upcoming.map(event => (
<div
className={classes.dividend}
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
>
<Group align="start" justify="space-between">
<div>
<Badge color="green" variant="light">
{t("stock.companyEvent")}
</Badge>
<Text fw={650} mt="xs" size="xl">
{formatMoney(event.perShare)}
{" "}
<Text c="dimmed" component="span" size="sm">
{t("stock.perShare")}
</Text>
</Text>
</div>
{!!(event.estimatedAmount) && ((
<div>
<Text c="dimmed" size="xs">
{t("stock.yourEstimate")}
</Text>
<Text fw={600}>{formatMoney(event.estimatedAmount)}</Text>
</div>
))}
</Group>
<Group gap="xl" mt="md">
{!!(event.lastBuyDate) && ((
<div>
<Text c="dimmed" size="xs">
{t("stock.lastBuy")}
</Text>
<Text>{formatDate(event.lastBuyDate)}</Text>
</div>
))}
{!!(event.recordDate) && ((
<div>
<Text c="dimmed" size="xs">
{t("stock.record")}
</Text>
<Text>{formatDate(event.recordDate)}</Text>
</div>
))}
{!!(event.paymentDate) && ((
<div>
<Text c="dimmed" size="xs">
{t("stock.payment")}
</Text>
<Text>{formatDate(event.paymentDate)}</Text>
</div>
))}
</Group>
</div>
{event.estimatedAmount ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.yourEstimate")}
</Text>
<Text fw={600}>{formatMoney(event.estimatedAmount)}</Text>
</div>
) : null}
</Group>
<Group gap="xl" mt="md">
{event.lastBuyDate ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.lastBuy")}
</Text>
<Text>{formatDate(event.lastBuyDate)}</Text>
</div>
) : null}
{event.recordDate ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.record")}
</Text>
<Text>{formatDate(event.recordDate)}</Text>
</div>
) : null}
{event.paymentDate ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.payment")}
</Text>
<Text>{formatDate(event.paymentDate)}</Text>
</div>
) : null}
</Group>
</div>
))
) : (
<Text c="dimmed">
{t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })}
</Text>
)}
<Text size="xs" c="dimmed">
))
)
: (
<Text c="dimmed">
{t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })}
</Text>
)}
<Text c="dimmed" size="xs">
{t("stock.dividendNote")}
</Text>
{vm.dividends.positionAt ? (
<Text size="xs" c="dimmed">
{!!(vm.dividends.positionAt) && ((
<Text c="dimmed" size="xs">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.dividends.positionAt),
})}
</Text>
) : null}
<Accordion variant="contained" radius="md">
))}
<Accordion radius="md" variant="contained">
<Accordion.Item value="history">
<Accordion.Control>
<Text fw={600}>{t("stock.dividendHistory")}</Text>
</Accordion.Control>
<Accordion.Panel>
<Text size="xs" c="dimmed" my="sm">
<Text c="dimmed" my="sm" size="xs">
{t("stock.dividendWindow", {
from: formatDate(vm.dividends.from),
to: formatDate(vm.dividends.to),
})}
</Text>
{vm.pastDividends.length ? (
<Stack gap="sm">
{vm.pastDividends.map((event) => (
<Paper
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
withBorder
p="md"
radius="md"
>
<Group justify="space-between" align="start">
<div>
<Text size="xs" c="dimmed">
{t("stock.record")}
</Text>
<Text fw={600}>
{event.recordDate
? formatDate(event.recordDate)
: t("stock.dateUnknown")}
</Text>
{event.paymentDate ? (
<Text size="xs" c="dimmed" mt="xs">
{t("stock.payment")}:{" "}
{formatDate(event.paymentDate)}
</Text>
) : null}
</div>
<div>
<Text fw={650} size="lg">
{formatMoney(event.perShare)}
</Text>
<Text size="xs" c="dimmed">
{t("stock.perShare")}
</Text>
</div>
</Group>
</Paper>
))}
</Stack>
) : (
<Text c="dimmed">{t("stock.noDividends")}</Text>
)}
{vm.pastDividends.length > 0
? (
<Stack gap="sm">
{vm.pastDividends.map(event => (
<Paper
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
p="md"
radius="md"
withBorder
>
<Group align="start" justify="space-between">
<div>
<Text c="dimmed" size="xs">
{t("stock.record")}
</Text>
<Text fw={600}>
{event.recordDate
? formatDate(event.recordDate)
: t("stock.dateUnknown")}
</Text>
{!!(event.paymentDate) && ((
<Text c="dimmed" mt="xs" size="xs">
{t("stock.payment")}
:
{" "}
{formatDate(event.paymentDate)}
</Text>
))}
</div>
<div>
<Text fw={650} size="lg">
{formatMoney(event.perShare)}
</Text>
<Text c="dimmed" size="xs">
{t("stock.perShare")}
</Text>
</div>
</Group>
</Paper>
))}
</Stack>
)
: (
<Text c="dimmed">{t("stock.noDividends")}</Text>
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
) : null}
))}
</StockSection>
);
};
@@ -1,13 +1,15 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { StocksPage } from "./StocksPage";
describe("stock search", () => {
@@ -19,18 +21,18 @@ describe("stock search", () => {
return HttpResponse.json(
resource([
{
name: "Сбербанк",
ticker: "SBER",
instrumentUid: "unique-stock",
slug: "SBER",
logoName: "sber.png",
classCode: "TQBR",
isin: "RU0009029540",
realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON",
apiTradeAvailable: true,
classCode: "TQBR",
instrumentUid: "unique-stock",
isin: "RU0009029540",
logoName: "sber.png",
name: "Сбербанк",
price: { currency: "RUB", value: "306.12" },
priceAt: "2026-09-10T10:00:00Z",
realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON",
slug: "SBER",
ticker: "SBER",
},
]),
);
@@ -38,8 +40,8 @@ describe("stock search", () => {
);
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => false,
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
@@ -1,4 +1,5 @@
import type { FC } from "react";
import { StocksPageView } from "./StocksPage.view";
import { useStocksPageVm } from "./StocksPage.vm";
@@ -1,3 +1,5 @@
import type { FC } from "react";
import {
Alert,
Anchor,
@@ -13,17 +15,113 @@ import {
Title,
} from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
import classes from "./StocksPage.module.css";
import type { StocksPageVm } from "./StocksPage.vm";
import classes from "./StocksPage.module.css";
type StocksPageViewProps = { vm: StocksPageVm };
const StocksPageView: FC<StocksPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
function renderResults() {
if (vm.loading) {
return (
<LoadingSkeleton label={t("stock.searching")}>
<Stack>
<Skeleton h={78} />
<Skeleton h={78} />
<Skeleton h={78} />
</Stack>
</LoadingSkeleton>
);
}
if (!vm.ready) return;
if (vm.items.length > 0) {
return (
<Stack gap="sm">
<Text c="dimmed" size="xs">
{t("stock.searchVariants")}
</Text>
{vm.items.map(stock => (
<Paper
className={classes.card}
component={Link}
key={stock.instrumentUid}
to={stock.to}
withBorder
>
<Avatar alt="" radius="md" size={48} src={stock.logoSrc}>
{stock.name[0]}
</Avatar>
<div className={classes.identity}>
<Text fw={600} size="lg">
{stock.name}
</Text>
<Text c="dimmed" size="sm">
{stock.ticker}
{" "}
·
{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
<Group gap="xs" mt="sm">
<Badge color="gray" variant="light">
{t(`stock.exchanges.${stock.realExchange}`, {
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{!!(stock.classCode) && ((
<Text c="dimmed" size="xs">
{t("stock.classCode", { code: stock.classCode })}
</Text>
))}
</Group>
{!!(stock.isin) && ((
<Text c="dimmed" mt={4} size="xs">
{t("stock.isin", { value: stock.isin })}
</Text>
))}
{(stock.apiTradeAvailable === false) && ((
<Text c="dimmed" mt={4} size="xs">
{t("stock.apiUnavailable")}
</Text>
))}
</div>
<div className={classes.quote}>
<Text fw={650} size="xl">
{stock.price ? formatMoney(stock.price) : "—"}
</Text>
<Text c="dimmed" size="xs">
{stock.price && stock.priceAt
? t("stock.priceAt", {
date: formatSnapshotTime(stock.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
{(stock.apiTradeAvailable === true) && ((
<Text c="green.8" mt="sm" size="xs">
{t("stock.apiAvailable")}
</Text>
))}
</div>
</Paper>
))}
</Stack>
);
}
return (
<Text c="dimmed">
{t(vm.partial ? "investment.partial" : "stock.noSearchResults")}
</Text>
);
}
return (
<Stack gap="lg">
<div>
@@ -33,33 +131,33 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
</Text>
</div>
<Button
disabled={vm.refreshing || !vm.canSearch}
onClick={vm.retry}
disabled={vm.refreshing || vm.query.trim().length < 2}
w="fit-content"
>
{t("investment.refresh")}
</Button>
{vm.partial ? (
{!!(vm.partial) && ((
<Alert role="status">{t("investment.partial")}</Alert>
) : null}
))}
<TextInput
label={t("stock.searchLabel")}
maxLength={80}
onChange={event => vm.setQuery(event.currentTarget.value)}
placeholder={t("stock.searchPlaceholder")}
size="md"
maxLength={80}
value={vm.query}
onChange={(event) => vm.setQuery(event.currentTarget.value)}
/>
{vm.query.trim().length < 2 ? (
{!(vm.canSearch) && ((
<Text c="dimmed">{t("stock.searchHint")}</Text>
) : null}
{vm.error ? (
<Alert role="status" aria-live="polite" color="orange">
))}
{!!(vm.error) && ((
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="xs">
<Text>
{t(
vm.code === "TBANK_NOT_CONNECTED" ||
vm.code === "INVALID_TBANK_TOKEN"
vm.code === "TBANK_NOT_CONNECTED"
|| vm.code === "INVALID_TBANK_TOKEN"
? "stock.connect"
: "stock.sectionError",
)}
@@ -67,96 +165,13 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button variant="default" onClick={vm.retry}>
<Button onClick={vm.retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
</Alert>
) : null}
{vm.loading ? (
<LoadingSkeleton label={t("stock.searching")}>
<Stack>
<Skeleton h={78} />
<Skeleton h={78} />
<Skeleton h={78} />
</Stack>
</LoadingSkeleton>
) : vm.ready ? (
vm.items.length ? (
<Stack gap="sm">
<Text size="xs" c="dimmed">
{t("stock.searchVariants")}
</Text>
{vm.items.map((stock) => (
<Paper
component={Link}
to={stock.to}
className={classes.card}
withBorder
key={stock.instrumentUid}
>
<Avatar src={stock.logoSrc} alt="" radius="md" size={48}>
{stock.name[0]}
</Avatar>
<div className={classes.identity}>
<Text fw={600} size="lg">
{stock.name}
</Text>
<Text size="sm" c="dimmed">
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
<Group gap="xs" mt="sm">
<Badge variant="light" color="gray">
{t(`stock.exchanges.${stock.realExchange}`, {
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{stock.classCode ? (
<Text size="xs" c="dimmed">
{t("stock.classCode", { code: stock.classCode })}
</Text>
) : null}
</Group>
{stock.isin ? (
<Text size="xs" c="dimmed" mt={4}>
{t("stock.isin", { value: stock.isin })}
</Text>
) : null}
{stock.apiTradeAvailable === false ? (
<Text size="xs" c="dimmed" mt={4}>
{t("stock.apiUnavailable")}
</Text>
) : null}
</div>
<div className={classes.quote}>
<Text fw={650} size="xl">
{stock.price ? formatMoney(stock.price) : "—"}
</Text>
<Text size="xs" c="dimmed">
{stock.price && stock.priceAt
? t("stock.priceAt", {
date: formatSnapshotTime(stock.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
{stock.apiTradeAvailable === true ? (
<Text size="xs" c="green.8" mt="sm">
{t("stock.apiAvailable")}
</Text>
) : null}
</div>
</Paper>
))}
</Stack>
) : (
<Text c="dimmed">
{t(vm.partial ? "investment.partial" : "stock.noSearchResults")}
</Text>
)
) : null}
))}
{renderResults()}
</Stack>
);
};
+27 -24
View File
@@ -1,44 +1,47 @@
import { useDebouncedValue } from "@mantine/hooks";
import { useSearchParams } from "react-router";
import { useStockSearch } from "@/entities/stock";
import { MIN_STOCK_SEARCH_LENGTH, useStockSearch } from "@/entities/stock";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib";
const SEARCH_DEBOUNCE_MS = 300;
type StocksPageVm = ReturnType<typeof useStocksPageVm>;
function useStocksPageVm() {
const [params, setParams] = useSearchParams();
const query = params.get("q") ?? "";
const [parameters, setParameters] = useSearchParams();
const query = parameters.get("q") ?? "";
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
const result = useStockSearch(debounced);
const sync = useScreenSync({ screen: "stocks", query: debounced }, [result]);
const waiting = query.trim() !== debounced;
const sync = useScreenSync({ query: debounced, screen: "stocks" }, [result]);
const canSearch = query.trim().length >= MIN_STOCK_SEARCH_LENGTH;
const isWaiting = query.trim() !== debounced;
return {
query,
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
loading:
query.trim().length >= 2 &&
(waiting ||
(!result.data &&
!result.isError &&
result.meta?.sync.state !== "failed")),
error: result.isError || sync.sourceError || sync.transportError,
refreshing: sync.refreshing,
partial: result.meta?.availability === "partial",
canSearch,
code: apiErrorCode(result.error),
error: result.isError || sync.sourceError || sync.transportError,
items:
result.data?.map(stock => ({
...stock,
logoSrc: instrumentLogoUrl(stock.logoName),
to: `/stocks/${encodeURIComponent(stock.slug ?? stock.instrumentUid)}`,
})) ?? [],
loading:
canSearch
&& (isWaiting
|| (!result.data
&& !result.isError
&& result.meta?.sync.state !== "failed")),
partial: result.meta?.availability === "partial",
query,
ready: debounced.length >= MIN_STOCK_SEARCH_LENGTH && !isWaiting && Boolean(result.data),
refreshing: sync.refreshing,
retry: () => {
void sync.retry();
},
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items:
result.data?.map((stock) => ({
...stock,
to: `/stocks/${encodeURIComponent(stock.slug ?? stock.instrumentUid)}`,
logoSrc: instrumentLogoUrl(stock.logoName),
})) ?? [],
setQuery: (q: string) => setParameters(q ? { q } : {}, { replace: true }),
};
}
type StocksPageVm = ReturnType<typeof useStocksPageVm>;
export { type StocksPageVm, useStocksPageVm };