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:
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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")} />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+9
-8
@@ -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")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+76
-65
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-3
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+6
-4
@@ -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")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+39
-30
@@ -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>
|
||||
|
||||
+3
-1
@@ -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 = () => {
|
||||
|
||||
+40
-36
@@ -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} />
|
||||
|
||||
+8
-5
@@ -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;
|
||||
|
||||
+26
-22
@@ -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>
|
||||
))}
|
||||
|
||||
+101
-83
@@ -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 };
|
||||
|
||||
+111
-112
@@ -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 };
|
||||
|
||||
+9
-7
@@ -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")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+60
-50
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-5
@@ -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" }]);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+3
-2
@@ -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;
|
||||
|
||||
+13
-11
@@ -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>
|
||||
))}
|
||||
|
||||
+13
-11
@@ -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: "Ошибка синхронизации сохраняет суммы",
|
||||
};
|
||||
|
||||
+12
-11
@@ -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}
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+34
-28
@@ -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: [],
|
||||
|
||||
+28
-22
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+33
-33
@@ -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,
|
||||
|
||||
+71
-64
@@ -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}
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user