refactor: align frontend ownership boundaries

Close audit gaps across API drift, query policy, UI, and tests.
This commit is contained in:
2026-09-06 18:20:05 +03:00
parent 175727bc93
commit c97f62bd98
118 changed files with 2190 additions and 2254 deletions
+39 -74
View File
@@ -1,25 +1,20 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { AuthProvider } from "@/features/auth";
import {
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoading,
} from "@/pages/accounts";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { theme } from "../theme";
import { AppRoutes } from "./AppRoutes";
describe("AppRoutes", () => {
afterEach(cleanup);
function renderRoutes(path: string) {
return render(
<QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={[path]}>
<MantineProvider theme={theme}>
<AuthProvider>
@@ -50,37 +45,6 @@ describe("AppRoutes", () => {
);
});
it("renders the T-Bank settings action for a missing connection", () => {
render(
<MantineProvider theme={theme}>
<MemoryRouter>
<AccountsPageError code="TBANK_NOT_CONNECTED" />
</MemoryRouter>
</MantineProvider>,
);
expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings");
});
it("renders retry and settings actions when T-Bank is unavailable", () => {
const retry = vi.fn();
render(
<MantineProvider theme={theme}>
<MemoryRouter>
<AccountsPageError code="TBANK_UNAVAILABLE" retry={retry} />
</MemoryRouter>
</MantineProvider>,
);
screen.getByRole("button", { name: "Повторить" }).click();
expect(retry).toHaveBeenCalledOnce();
expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings");
});
it("redirects anonymous users from accounts", async () => {
renderRoutes("/accounts");
@@ -91,6 +55,40 @@ describe("AppRoutes", () => {
);
});
it("logs in through the public form and reaches the protected landing", async () => {
let credentials: unknown;
server.use(
http.post("*/api/v1/auth/login", async ({ request }) => {
credentials = await request.json();
return HttpResponse.json({
accessToken: "login-token",
user: { id: "user-1", email: "user@example.com" },
});
}),
);
renderRoutes("/accounts");
await screen.findByRole("heading", { level: 1, name: "Вход" });
fireEvent.change(screen.getByLabelText("Email"), {
target: { value: " User@Example.com " },
});
fireEvent.change(screen.getByLabelText("Пароль"), {
target: { value: "a secure password" },
});
fireEvent.click(screen.getByRole("button", { name: "Вход" }));
expect(
await screen.findByRole("heading", {
level: 2,
name: "Нет инвестиционных счетов",
}),
).toBeInTheDocument();
expect(credentials).toEqual({
email: "user@example.com",
password: "a secure password",
});
});
it("lazy-loads the protected shell for an authenticated accounts route", async () => {
server.use(
http.post("*/api/v1/auth/refresh", () =>
@@ -114,37 +112,4 @@ describe("AppRoutes", () => {
screen.getByRole("navigation", { name: "WealthLedger" }),
).toBeInTheDocument();
});
it.each([
["loading", AccountsPageLoading, "status"],
["error", AccountsPageError, "alert"],
["empty", AccountsPageEmpty, undefined],
] as const)("renders only the Accounts %s state", (_name, State, role) => {
render(
<MantineProvider theme={theme}>
<State />
</MantineProvider>,
);
if (role) {
expect(screen.getByRole(role)).toBeInTheDocument();
} else {
expect(
screen.getByRole("heading", {
level: 2,
name: "Нет инвестиционных счетов",
}),
).toBeInTheDocument();
}
expect(screen.queryAllByRole("status")).toHaveLength(
role === "status" ? 1 : 0,
);
expect(screen.queryAllByRole("alert")).toHaveLength(
role === "alert" ? 1 : 0,
);
expect(screen.queryAllByText("Нет инвестиционных счетов")).toHaveLength(
role ? 0 : 1,
);
});
});
+89 -181
View File
@@ -1,9 +1,8 @@
import { Container, Group, Skeleton, Stack } from "@mantine/core";
import type { FC, PropsWithChildren } from "react";
import { lazy, Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Navigate, Route, Routes, useLocation } from "react-router";
import { useAuth } from "@/features/auth";
import type { FC } from "react";
import { lazy } from "react";
import { Navigate, Route, Routes } from "react-router";
import { LazyPage } from "./RouteFallback";
import { Protected, Public } from "./RouteGuards";
const AccountPage = lazy(async () => {
const { AccountPage } = await import("@/pages/account");
@@ -46,187 +45,96 @@ const ProtectedLayout = lazy(async () => {
return { default: ProtectedLayout };
});
type RouteSkeletonVariant = "auth" | "content" | "shell";
type LazyPageProps = PropsWithChildren<{
variant: RouteSkeletonVariant;
}>;
const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
const { variant } = props;
const { t } = useTranslation();
if (variant === "auth")
return (
<Container size={420} py="xl">
<Stack
aria-busy="true"
aria-label={t("states.loadingTitle")}
role="status"
>
<Skeleton height={32} width="45%" />
<Skeleton height={48} radius="sm" />
<Skeleton height={48} radius="sm" />
<Skeleton height={36} radius="sm" />
</Stack>
</Container>
);
if (variant === "shell")
return (
<Stack
aria-busy="true"
aria-label={t("states.loadingTitle")}
gap={0}
role="status"
>
<Group justify="space-between" p="md">
<Skeleton height={28} width={160} />
<Skeleton height={28} width={96} />
</Group>
<Container fluid p="md">
<Skeleton height={20} width="30%" />
<Stack mt="lg">
<Skeleton height={32} width="30%" />
<Skeleton height={160} radius="md" />
</Stack>
</Container>
</Stack>
);
return (
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status">
<Skeleton height={32} width="30%" />
<Skeleton height={160} radius="md" />
</Stack>
);
};
const LazyPage: FC<LazyPageProps> = (props) => {
const { children, variant } = props;
return (
<Suspense fallback={<RouteSkeleton variant={variant} />}>
{children}
</Suspense>
);
};
const Protected: FC<PropsWithChildren> = (props) => {
const { children } = props;
const { user, ready } = useAuth();
const location = useLocation();
if (!ready) return <RouteSkeleton variant="shell" />;
return user ? (
children
) : (
<Navigate
to="/login"
state={{ returnTo: `${location.pathname}${location.search}` }}
replace
const AppRoutes: FC = () => (
<Routes>
<Route path="/" element={<Navigate to="/accounts" replace />} />
<Route
path="/login"
element={
<Public>
<LazyPage variant="auth">
<LoginPage />
</LazyPage>
</Public>
}
/>
);
};
const Public: FC<PropsWithChildren> = (props) => {
const { children } = props;
const { user, ready } = useAuth();
if (!ready) return <RouteSkeleton variant="shell" />;
return user ? <Navigate to="/accounts" replace /> : children;
};
const AppRoutes: FC = () => {
return (
<Routes>
<Route path="/" element={<Navigate to="/accounts" replace />} />
<Route
path="/register"
element={
<Public>
<LazyPage variant="auth">
<RegisterPage />
</LazyPage>
</Public>
}
/>
<Route
element={
<Protected>
<LazyPage variant="shell">
<ProtectedLayout />
</LazyPage>
</Protected>
}
>
<Route
path="/login"
element={
<Public>
<LazyPage variant="auth">
<LoginPage />
</LazyPage>
</Public>
}
/>
<Route
path="/register"
element={
<Public>
<LazyPage variant="auth">
<RegisterPage />
</LazyPage>
</Public>
}
/>
<Route
element={
<Protected>
<LazyPage variant="shell">
<ProtectedLayout />
</LazyPage>
</Protected>
}
>
<Route
path="/accounts"
element={
<LazyPage variant="content">
<AccountsPage />
</LazyPage>
}
/>
<Route
path="/accounts/:accountId"
element={
<LazyPage variant="content">
<AccountPage />
</LazyPage>
}
/>
<Route
path="/accounts/:accountId/events"
element={
<LazyPage variant="content">
<AccountEventsPage />
</LazyPage>
}
/>
<Route
path="/accounts/:accountId/income"
element={
<LazyPage variant="content">
<AccountIncomePage />
</LazyPage>
}
/>
<Route
path="/accounts/:accountId/operations"
element={
<LazyPage variant="content">
<AccountOperationsPage />
</LazyPage>
}
/>
<Route
path="/settings"
element={
<LazyPage variant="content">
<SettingsPage />
</LazyPage>
}
/>
</Route>
<Route
path="*"
path="/accounts"
element={
<LazyPage variant="content">
<NotFoundPage />
<AccountsPage />
</LazyPage>
}
/>
</Routes>
);
};
<Route
path="/accounts/:accountId"
element={
<LazyPage variant="content">
<AccountPage />
</LazyPage>
}
/>
<Route
path="/accounts/:accountId/events"
element={
<LazyPage variant="content">
<AccountEventsPage />
</LazyPage>
}
/>
<Route
path="/accounts/:accountId/income"
element={
<LazyPage variant="content">
<AccountIncomePage />
</LazyPage>
}
/>
<Route
path="/accounts/:accountId/operations"
element={
<LazyPage variant="content">
<AccountOperationsPage />
</LazyPage>
}
/>
<Route
path="/settings"
element={
<LazyPage variant="content">
<SettingsPage />
</LazyPage>
}
/>
</Route>
<Route
path="*"
element={
<LazyPage variant="content">
<NotFoundPage />
</LazyPage>
}
/>
</Routes>
);
export { AppRoutes };
+2 -3
View File
@@ -1,8 +1,8 @@
import { MantineProvider } from "@mantine/core";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import type { FC } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { RouteErrorBoundary } from "./RouteErrorBoundary";
let authenticated = false;
@@ -52,7 +52,6 @@ function renderBoundary(path: string) {
}
describe("RouteErrorBoundary", () => {
afterEach(cleanup);
it("offers login after a public route error without error details", () => {
authenticated = false;
renderBoundary("/login");
+68
View File
@@ -0,0 +1,68 @@
import { Container, Group, Skeleton, Stack } from "@mantine/core";
import type { FC, PropsWithChildren } from "react";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
type RouteSkeletonVariant = "auth" | "content" | "shell";
type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>;
const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
const { variant } = props;
const { t } = useTranslation();
if (variant === "auth")
return (
<Container size={420} py="xl">
<Stack
aria-busy="true"
aria-label={t("states.loadingTitle")}
role="status"
>
<Skeleton height={32} width="45%" />
<Skeleton height={48} radius="sm" />
<Skeleton height={48} radius="sm" />
<Skeleton height={36} radius="sm" />
</Stack>
</Container>
);
if (variant === "shell")
return (
<Stack
aria-busy="true"
aria-label={t("states.loadingTitle")}
gap={0}
role="status"
>
<Group justify="space-between" p="md">
<Skeleton height={28} width={160} />
<Skeleton height={28} width={96} />
</Group>
<Container fluid p="md">
<Skeleton height={20} width="30%" />
<Stack mt="lg">
<Skeleton height={32} width="30%" />
<Skeleton height={160} radius="md" />
</Stack>
</Container>
</Stack>
);
return (
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status">
<Skeleton height={32} width="30%" />
<Skeleton height={160} radius="md" />
</Stack>
);
};
const LazyPage: FC<LazyPageProps> = (props) => {
const { children, variant } = props;
return (
<Suspense fallback={<RouteSkeleton variant={variant} />}>
{children}
</Suspense>
);
};
export { LazyPage, RouteSkeleton };
+29
View File
@@ -0,0 +1,29 @@
import type { FC, PropsWithChildren } from "react";
import { Navigate, useLocation } from "react-router";
import { useAuth } from "@/features/auth";
import { RouteSkeleton } from "./RouteFallback";
const Protected: FC<PropsWithChildren> = (props) => {
const { children } = props;
const { user, ready } = useAuth();
const location = useLocation();
if (!ready) return <RouteSkeleton variant="shell" />;
return user ? (
children
) : (
<Navigate
to="/login"
state={{ returnTo: `${location.pathname}${location.search}` }}
replace
/>
);
};
const Public: FC<PropsWithChildren> = (props) => {
const { children } = props;
const { user, ready } = useAuth();
if (!ready) return <RouteSkeleton variant="shell" />;
return user ? <Navigate to="/accounts" replace /> : children;
};
export { Protected, Public };
+2 -69
View File
@@ -1,79 +1,12 @@
:root {
--ft-color-bg-page: #e8ecf1;
--ft-color-bg-app: #f5f7fa;
--ft-color-bg-surface: #ffffff;
--ft-color-bg-muted: #f1f5f9;
--ft-color-text-primary: #1e293b;
--ft-color-text-secondary: #64748b;
--ft-color-border: #e2e8f0;
--ft-color-accent: #2563eb;
--ft-color-danger: #ef4444;
--ft-radius-card: 12px;
--ft-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--ft-color-bg-page);
color: var(--ft-color-text-primary);
background: var(--mantine-color-gray-2);
color: var(--mantine-color-dark-7);
}
a {
color: inherit;
text-decoration: none;
}
.app-header {
align-items: center;
display: flex;
padding: 0 1rem;
}
.bottom-nav {
display: none;
}
[data-depth] {
margin-bottom: 1rem;
}
[data-depth] a {
color: var(--ft-color-accent);
}
.auth-page {
align-items: center;
display: flex;
justify-content: center;
min-height: 100dvh;
padding: 1.5rem;
}
.auth-card {
box-shadow: 0 12px 32px rgba(30, 41, 59, 0.1);
width: min(100%, 26rem);
}
.auth-card fieldset {
border: 0;
padding: 0;
}
@media (max-width: 47.99375em) {
.bottom-nav {
background: var(--ft-color-bg-surface);
border-top: 1px solid var(--ft-color-border);
bottom: 0;
display: flex;
justify-content: space-around;
left: 0;
padding: 0.75rem;
position: fixed;
right: 0;
z-index: 200;
}
.bottom-nav .active {
color: var(--ft-color-accent);
font-weight: 600;
}
[data-depth="3"] > :nth-child(-n + 2) {
display: none;
}
.mantine-AppShell-main {
padding-bottom: 5rem;
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type AccountBreadcrumbsProps = {
accountId: string;
section?: "events" | "income" | "operations";
};
const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => {
const { accountId, section } = props;
const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation();
const accountName =
accounts?.find((account) => account.id === accountId)?.name ??
t("breadcrumbs.accountFallback");
const accountPath = `/accounts/${accountId}`;
const items = [
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
section ? { label: accountName, to: accountPath } : { label: accountName },
...(section ? [{ label: t(`breadcrumbs.${section}`) }] : []),
];
return <BreadcrumbTrail items={items} />;
};
export { AccountBreadcrumbs };
+3 -1
View File
@@ -8,7 +8,9 @@ import { theme } from "@/app/theme";
import { AuthProvider } from "@/features/auth";
const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } },
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: false },
},
});
const App: FC = () => {
+13
View File
@@ -0,0 +1,13 @@
.trail {
margin-bottom: var(--mantine-spacing-md);
}
.link {
color: var(--mantine-primary-color-filled);
}
@media (max-width: 47.99375em) {
.trail[data-depth="3"] > :nth-child(-n + 2) {
display: none;
}
}
+1 -2
View File
@@ -1,5 +1,5 @@
import { MantineProvider } from "@mantine/core";
import { cleanup, render, screen } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AppBreadcrumbs } from "./AppBreadcrumbs";
@@ -26,7 +26,6 @@ function renderBreadcrumbs(
describe("AppBreadcrumbs", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
+16 -113
View File
@@ -1,141 +1,44 @@
import { Breadcrumbs } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link, useMatch } from "react-router";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
type Crumb = { label: string; to?: string };
type BreadcrumbTrailProps = {
items: Crumb[];
};
type AccountBreadcrumbsProps = {
accountId: string;
events: boolean;
income: boolean;
operations: boolean;
};
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
const { items } = props;
const { t } = useTranslation();
return (
<Breadcrumbs
aria-label={t("breadcrumbs.label")}
component="nav"
data-depth={items.length}
separator={<span aria-hidden="true">→</span>}
>
{items.map((item) =>
item.to ? (
<Link key={item.to} to={item.to}>
{item.label}
</Link>
) : (
<span aria-current="page" key={item.label}>
{item.label}
</span>
),
)}
</Breadcrumbs>
);
};
const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => {
const { accountId, events, income, operations } = props;
const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation();
const accountName =
accounts?.find((account) => account.id === accountId)?.name ??
t("breadcrumbs.accountFallback");
const accountPath = `/accounts/${accountId}`;
return (
<BreadcrumbTrail
items={
events
? [
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
{ label: accountName, to: accountPath },
{ label: t("breadcrumbs.events") },
]
: operations
? [
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
{ label: accountName, to: accountPath },
{ label: t("breadcrumbs.operations") },
]
: income
? [
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
{ label: accountName, to: accountPath },
{ label: t("breadcrumbs.income") },
]
: [
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
{ label: accountName },
]
}
/>
);
};
import { useMatch } from "react-router";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
const AppBreadcrumbs: FC = () => {
const { t } = useTranslation();
const accountOperations = useMatch({
const operations = useMatch({
path: "/accounts/:accountId/operations",
end: true,
});
const accountEvents = useMatch({
path: "/accounts/:accountId/events",
end: true,
});
const accountIncome = useMatch({
path: "/accounts/:accountId/income",
end: true,
});
const events = useMatch({ path: "/accounts/:accountId/events", end: true });
const income = useMatch({ path: "/accounts/:accountId/income", end: true });
const account = useMatch({ path: "/accounts/:accountId", end: true });
const accounts = useMatch({ path: "/accounts", end: true });
const settings = useMatch({ path: "/settings", end: true });
if (accountOperations)
if (operations)
return (
<AccountBreadcrumbs
accountId={accountOperations.params.accountId ?? ""}
events={false}
income={false}
operations
accountId={operations.params.accountId ?? ""}
section="operations"
/>
);
if (accountEvents)
if (events)
return (
<AccountBreadcrumbs
accountId={accountEvents.params.accountId ?? ""}
events
income={false}
operations={false}
accountId={events.params.accountId ?? ""}
section="events"
/>
);
if (accountIncome)
if (income)
return (
<AccountBreadcrumbs
accountId={accountIncome.params.accountId ?? ""}
events={false}
income
operations={false}
accountId={income.params.accountId ?? ""}
section="income"
/>
);
if (account)
return (
<AccountBreadcrumbs
accountId={account.params.accountId ?? ""}
events={false}
income={false}
operations={false}
/>
);
return <AccountBreadcrumbs accountId={account.params.accountId ?? ""} />;
if (accounts)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
if (settings)
+36
View File
@@ -0,0 +1,36 @@
import { Breadcrumbs } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import classes from "./AppBreadcrumbs.module.css";
type Crumb = { label: string; to?: string };
type BreadcrumbTrailProps = { items: Crumb[] };
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
const { items } = props;
const { t } = useTranslation();
return (
<Breadcrumbs
aria-label={t("breadcrumbs.label")}
className={classes.trail}
component="nav"
data-depth={items.length}
separator={<span aria-hidden="true">→</span>}
>
{items.map((item) =>
item.to ? (
<Link className={classes.link} key={item.to} to={item.to}>
{item.label}
</Link>
) : (
<span aria-current="page" key={item.label}>
{item.label}
</span>
),
)}
</Breadcrumbs>
);
};
export { BreadcrumbTrail };
@@ -1,17 +1,13 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import {
brokerageOperationsQueryParams,
useBrokerageOperations,
} from "./useBrokerageOperations";
describe("useBrokerageOperations", () => {
afterEach(() => configureAuthClient(null));
it("normalizes query parameters and loads a page without retries", async () => {
configureAuthClient({
accessToken: () => "token",
@@ -24,20 +20,9 @@ describe("useBrokerageOperations", () => {
cursor: "next",
}),
).toEqual({ categories: ["income", "tax"], cursor: "next" });
const client = new QueryClient();
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
const { result } = renderHook(
() => useBrokerageOperations("account-1", { limit: 5 }),
{ wrapper },
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -1,5 +1,8 @@
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema";
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
type OperationsEndpoint =
paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
@@ -32,25 +35,36 @@ function brokerageOperationsQueryParams(query: BrokerageOperationsQuery) {
};
}
function brokerageOperationsQueryOptions(
accountId: string | undefined,
query: BrokerageOperationsQuery = {},
) {
return apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/operations",
{
params: {
path: { accountId: accountId ?? "" },
query: brokerageOperationsQueryParams(query),
},
},
);
}
function useBrokerageOperations(
accountId: string | undefined,
query: BrokerageOperationsQuery = {},
) {
const params = brokerageOperationsQueryParams(query);
return useAuthenticatedQuery({
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/operations",
{ params: { path: { accountId: accountId ?? "" }, query: params } },
),
...brokerageOperationsQueryOptions(accountId, query),
enabled: Boolean(accountId),
staleTime: 30_000,
retry: false,
});
}
export {
type BrokerageOperation,
brokerageOperationsQueryOptions,
brokerageOperationsQueryParams,
type OperationCategory,
operationCategories,
+1
View File
@@ -1,5 +1,6 @@
export {
type BrokerageOperation,
brokerageOperationsQueryOptions,
type OperationCategory,
operationCategories,
useBrokerageOperations,
@@ -0,0 +1,53 @@
import { formatMoney } from "@/shared/lib";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
type PaymentPresentation = {
amount: string;
label: string;
tone: "positive" | "negative" | "neutral";
};
function entryLabelKey(operation: BrokerageOperation): string {
if (operation.category === "income")
return operation.type === "OPERATION_TYPE_COUPON"
? "ledger.entryLabel.income.coupon"
: "ledger.entryLabel.income.dividend";
return `ledger.category.${operation.category}`;
}
function paymentPresentation(
operation: BrokerageOperation,
): PaymentPresentation {
if (!operation.payment)
return {
amount: "—",
label: "ledger.noMoney",
tone: "neutral",
};
if (isZeroDecimal(operation.payment.value))
return {
amount: formatMoney(operation.payment),
label: "ledger.noMoney",
tone: "neutral",
};
if (operation.payment.value.startsWith("-"))
return {
amount: `−${formatMoney({
...operation.payment,
value: operation.payment.value.slice(1),
})}`,
label: entryLabelKey(operation),
tone: "negative",
};
return {
amount: `+${formatMoney(operation.payment)}`,
label: entryLabelKey(operation),
tone: "positive",
};
}
function isZeroDecimal(value: string): boolean {
return /^-?0(?:\.0+)?$/.test(value);
}
export { paymentPresentation };
@@ -2,32 +2,15 @@ import { Avatar, Paper, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
formatMoney,
formatSnapshotTime,
instrumentLogoUrl,
} from "@/entities/portfolio";
import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { paymentPresentation } from "../model/display";
import classes from "./LedgerEntryRow.module.css";
type LedgerEntryRowProps = {
operation: BrokerageOperation;
};
type PaymentPresentation = {
amount: string;
label: string;
tone: "positive" | "negative" | "neutral";
};
function entryLabelKey(operation: BrokerageOperation): string {
if (operation.category === "income")
return operation.type === "OPERATION_TYPE_COUPON"
? "ledger.entryLabel.income.coupon"
: "ledger.entryLabel.income.dividend";
return `ledger.category.${operation.category}`;
}
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
const { operation } = props;
const { t } = useTranslation();
@@ -72,39 +55,4 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
);
};
function paymentPresentation(
operation: BrokerageOperation,
): PaymentPresentation {
if (!operation.payment)
return {
amount: "—",
label: "ledger.noMoney",
tone: "neutral",
};
if (isZeroDecimal(operation.payment.value))
return {
amount: formatMoney(operation.payment),
label: "ledger.noMoney",
tone: "neutral",
};
if (operation.payment.value.startsWith("-"))
return {
amount: `−${formatMoney({
...operation.payment,
value: operation.payment.value.slice(1),
})}`,
label: entryLabelKey(operation),
tone: "negative",
};
return {
amount: `+${formatMoney(operation.payment)}`,
label: entryLabelKey(operation),
tone: "positive",
};
}
function isZeroDecimal(value: string): boolean {
return /^-?0(?:\.0+)?$/.test(value);
}
export { LedgerEntryRow };
@@ -1,31 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { useBrokerageAccounts } from "./useBrokerageAccounts";
function createWrapper() {
const client = new QueryClient();
const TestWrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
return TestWrapper;
}
describe("useBrokerageAccounts", () => {
afterEach(() => configureAuthClient(null));
it("loads accounts through the authenticated query seam", async () => {
configureAuthClient({
accessToken: () => "token",
@@ -46,7 +27,7 @@ describe("useBrokerageAccounts", () => {
);
const { result } = renderHook(() => useBrokerageAccounts(), {
wrapper: createWrapper(),
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -83,7 +64,7 @@ describe("useBrokerageAccounts", () => {
}),
);
const { result } = renderHook(() => useBrokerageAccounts(), {
wrapper: createWrapper(),
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -1,5 +1,8 @@
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema";
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
const brokerageAccountsQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
@@ -13,7 +16,6 @@ function useBrokerageAccounts() {
return useAuthenticatedQuery({
...brokerageAccountsQueryOptions(),
staleTime: 60_000,
retry: false,
});
}
-1
View File
@@ -7,6 +7,5 @@ export {
accountTypeKey,
type BrokerageAccountCard,
brokerageAccountCards,
errorCode,
} from "./model/display";
export { BrokerageAccountList } from "./ui/BrokerageAccountList";
@@ -16,12 +16,6 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
return `accounts.types.${type}` as const;
}
function errorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String(error.code)
: undefined;
}
function brokerageAccountCards(
accounts: BrokerageAccount[],
): BrokerageAccountCard[] {
@@ -44,9 +38,4 @@ function brokerageAccountCards(
});
}
export {
accountTypeKey,
type BrokerageAccountCard,
brokerageAccountCards,
errorCode,
};
export { accountTypeKey, type BrokerageAccountCard, brokerageAccountCards };
@@ -2,7 +2,7 @@ import { Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import type { BrokerageAccountCard } from "../model/display";
import { accountTypeKey } from "../model/display";
import classes from "./BrokerageAccountList.module.css";
@@ -1,31 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { useBrokerageEvents } from "./useBrokerageEvents";
function createWrapper() {
const client = new QueryClient();
const TestWrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
return TestWrapper;
}
describe("useBrokerageEvents", () => {
afterEach(() => configureAuthClient(null));
it("loads future cash events through the authenticated query seam", async () => {
configureAuthClient({
accessToken: () => "token",
@@ -54,7 +35,7 @@ describe("useBrokerageEvents", () => {
);
const { result } = renderHook(() => useBrokerageEvents("account-1"), {
wrapper: createWrapper(),
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -1,5 +1,8 @@
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema";
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
type EventsEndpoint =
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
@@ -15,7 +18,6 @@ function useBrokerageEvents(accountId: string | undefined) {
),
enabled: Boolean(accountId),
staleTime: 30_000,
retry: false,
});
}
+5 -5
View File
@@ -1,5 +1,8 @@
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema";
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
type IncomeSummary =
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"];
@@ -15,7 +18,6 @@ function useIncomeSummary(accountId: string | undefined) {
),
enabled: Boolean(accountId),
staleTime: 30_000,
retry: false,
});
}
@@ -36,8 +38,6 @@ function useIncomeAnalytics(
),
enabled: Boolean(accountId),
staleTime: 30_000,
retry: false,
refetchOnWindowFocus: false,
});
}
@@ -1,36 +1,21 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { usePortfolio } from "./usePortfolio";
describe("usePortfolio", () => {
afterEach(() => configureAuthClient(null));
it("uses the account endpoint with a 30 second cache and no retry", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const client = new QueryClient();
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
const { result } = renderHook(() => usePortfolio("account-1"), {
wrapper,
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -58,16 +43,9 @@ describe("usePortfolio", () => {
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
const client = new QueryClient();
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
const { result } = renderHook(() => usePortfolio("account-1"), { wrapper });
const { result } = renderHook(() => usePortfolio("account-1"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
void result.current.refetch();
+15 -9
View File
@@ -1,24 +1,30 @@
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema";
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
const portfolioQueryPolicy = {
staleTime: 30_000,
retry: false,
} as const;
type Portfolio =
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
function portfolioQueryOptions(accountId: string | undefined) {
return apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{ params: { path: { accountId: accountId ?? "" } } },
);
}
function usePortfolio(accountId: string | undefined) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{ params: { path: { accountId: accountId ?? "" } } },
),
...portfolioQueryOptions(accountId),
enabled: Boolean(accountId),
...portfolioQueryPolicy,
});
}
export { type Portfolio, usePortfolio };
export { type Portfolio, portfolioQueryOptions, usePortfolio };
+5 -7
View File
@@ -1,8 +1,6 @@
export { type Portfolio, usePortfolio } from "./api/usePortfolio";
export {
formatMoney,
formatShare,
formatSnapshotTime,
instrumentLogoUrl,
portfolioErrorCode,
} from "./model/display";
type Portfolio,
portfolioQueryOptions,
usePortfolio,
} from "./api/usePortfolio";
export { formatShare } from "./model/display";
+1 -21
View File
@@ -1,16 +1,7 @@
import { describe, expect, it } from "vitest";
import { formatMoney, formatShare, instrumentLogoUrl } from "./display";
import { formatShare } from "./display";
describe("portfolio display helpers", () => {
it("formats decimal strings without financial arithmetic", () => {
expect(formatMoney({ value: "-1234567.89", currency: "RUB" })).toBe(
"-1 234 567,89 ₽",
);
expect(formatMoney({ value: "10", currency: "RUB" })).toBe("10 ₽");
expect(formatMoney({ value: "0.5", currency: "USD" })).toBe("0,5 USD");
expect(formatMoney(null)).toBe("—");
});
it("formats a share as a display-only percentage", () => {
expect(
formatShare(
@@ -33,15 +24,4 @@ describe("portfolio display helpers", () => {
),
).toBeNull();
});
it("builds the brand logo url from an upstream file name", () => {
expect(instrumentLogoUrl("sber3.png")).toBe(
"https://invest-brands.cdn-tinkoff.ru/sber3x160.png",
);
expect(instrumentLogoUrl(" RU000A0JPNM1.png ")).toBe(
"https://invest-brands.cdn-tinkoff.ru/RU000A0JPNM1x160.png",
);
expect(instrumentLogoUrl(null)).toBeNull();
expect(instrumentLogoUrl(" ")).toBeNull();
});
});
+1 -45
View File
@@ -2,21 +2,6 @@ import type { Portfolio } from "../api/usePortfolio";
type Money = Portfolio["totalsByCurrency"][number];
function formatDecimal(value: string): string {
const [integer, fraction] = value.split(".");
const sign = integer.startsWith("-") ? "-" : "";
const grouped = (sign ? integer.slice(1) : integer).replace(
/\B(?=(\d{3})+(?!\d))/g,
" ",
);
return `${sign}${grouped}${fraction ? `,${fraction}` : ""}`;
}
function formatMoney(money: Money | null): string {
if (!money) return "—";
return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`;
}
function formatShare(
numerator: Money | null,
denominator: Money | null,
@@ -32,33 +17,4 @@ function formatShare(
return `${rounded} %`;
}
function formatSnapshotTime(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
}).format(new Date(value));
}
function portfolioErrorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String(error.code)
: undefined;
}
const logoSize = 160;
function instrumentLogoUrl(logoName: string | null | undefined): string | null {
const stem = logoName?.trim().replace(/\.png$/i, "");
return stem
? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png`
: null;
}
export {
formatMoney,
formatShare,
formatSnapshotTime,
instrumentLogoUrl,
portfolioErrorCode,
};
export { formatShare };
+1
View File
@@ -1,3 +1,4 @@
export { AuthProvider, useAuth } from "./model/auth";
export { AuthPageLayout } from "./ui/AuthPageLayout";
export { LoginForm } from "./ui/LoginForm";
export { RegisterForm } from "./ui/RegisterForm";
+1 -2
View File
@@ -8,8 +8,7 @@ import {
useRef,
useState,
} from "react";
import { apiClient, configureAuthClient } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema";
import { apiClient, configureAuthClient, type paths } from "@/shared/api";
type User =
paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
function isEmailCharacterInvalid(character: string): boolean {
return (
/\s/.test(character) ||
character.charCodeAt(0) < 32 ||
character.charCodeAt(0) === 127
);
}
const email = z
.string()
.trim()
.toLowerCase()
.max(254)
.refine(
(value) =>
value.split("@").length === 2 &&
value.split("@").every(Boolean) &&
!value.split("").some(isEmailCharacterInvalid),
validationMessage("auth.validation.invalidEmail"),
);
const password = z
.string()
.refine(
(value) =>
new TextEncoder().encode(value).length >= 12 &&
new TextEncoder().encode(value).length <= 72,
validationMessage("auth.validation.invalidPasswordLength"),
);
const loginSchema = z.object({
email,
password,
passwordConfirmation: z.string().optional(),
});
const registerSchema = loginSchema.refine(
(value) => value.password === value.passwordConfirmation,
(value) => ({
path: ["passwordConfirmation"],
...validationMessage("auth.validation.passwordsDoNotMatch")(value),
}),
);
type AuthFormValues = z.input<typeof loginSchema>;
export { type AuthFormValues, loginSchema, registerSchema };
+4
View File
@@ -0,0 +1,4 @@
.fieldset {
border: 0;
padding: 0;
}
+2 -10
View File
@@ -1,18 +1,10 @@
import { MantineProvider } from "@mantine/core";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { AuthForm } from "./AuthForm";
describe("AuthForm", () => {
afterEach(cleanup);
it("does not submit registration when password confirmation differs", async () => {
const onSubmit = vi.fn();
render(
+7 -46
View File
@@ -5,52 +5,13 @@ import { useState } from "react";
import { FormProvider, type Resolver, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { z } from "zod";
import {
type AuthFormValues,
loginSchema,
registerSchema,
} from "../model/validation";
import classes from "./AuthForm.module.css";
import { validationMessage } from "@/shared/i18n";
function isEmailCharacterInvalid(character: string): boolean {
return (
/\s/.test(character) ||
character.charCodeAt(0) < 32 ||
character.charCodeAt(0) === 127
);
}
const email = z
.string()
.trim()
.toLowerCase()
.max(254)
.refine(
(value) =>
value.split("@").length === 2 &&
value.split("@").every(Boolean) &&
!value.split("").some(isEmailCharacterInvalid),
validationMessage("auth.validation.invalidEmail"),
);
const password = z
.string()
.refine(
(value) =>
new TextEncoder().encode(value).length >= 12 &&
new TextEncoder().encode(value).length <= 72,
validationMessage("auth.validation.invalidPasswordLength"),
);
const loginSchema = z.object({
email,
password,
passwordConfirmation: z.string().optional(),
});
const registerSchema = loginSchema.refine(
(value) => value.password === value.passwordConfirmation,
(value) => ({
path: ["passwordConfirmation"],
...validationMessage("auth.validation.passwordsDoNotMatch")(value),
}),
);
type AuthFormValues = z.input<typeof loginSchema>;
type AuthFormProps = {
register?: true;
onSubmit: (email: string, password: string) => Promise<void>;
@@ -79,7 +40,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
}
})}
>
<fieldset disabled={isSubmitting}>
<fieldset className={classes.fieldset} disabled={isSubmitting}>
<Stack>
<TextInput
label={t("auth.email")}
@@ -0,0 +1,12 @@
.page {
align-items: center;
display: flex;
justify-content: center;
min-height: 100dvh;
padding: var(--mantine-spacing-xl);
}
.card {
box-shadow: 0 12px 32px rgba(30, 41, 59, 0.1);
width: min(100%, 26rem);
}
+21
View File
@@ -0,0 +1,21 @@
import { Paper, Stack, Title } from "@mantine/core";
import type { FC, PropsWithChildren } from "react";
import classes from "./AuthPageLayout.module.css";
type AuthPageLayoutProps = PropsWithChildren<{ title: string }>;
const AuthPageLayout: FC<AuthPageLayoutProps> = (props) => {
const { children, title } = props;
return (
<main className={classes.page}>
<Paper className={classes.card} p="xl" radius="lg" withBorder>
<Stack gap="lg">
<Title order={1}>{title}</Title>
{children}
</Stack>
</Paper>
</main>
);
};
export { AuthPageLayout };
@@ -1,6 +1,5 @@
import type { QueryClient } from "@tanstack/react-query";
import { apiQueryClient } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema";
import { apiQueryClient, type paths } from "@/shared/api";
type TbankConnectionStatus =
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
@@ -1,24 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { afterEach, describe, expect, it, vi } from "vitest";
import { apiQueryClient } from "@/shared/api";
import { describe, expect, it, vi } from "vitest";
import { apiQueryClient, configureAuthClient } from "@/shared/api";
import { server } from "@/test/server";
import { TbankConnectionSection } from "./TbankConnectionSection";
vi.mock("@/features/auth", () => ({
useAuth: () => ({ accessToken: "test-token" }),
}));
afterEach(cleanup);
const brokerageAccountsQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts",
@@ -39,6 +27,11 @@ const unrelatedQueryKey = apiQueryClient.queryOptions(
).queryKey;
function renderSection(client: QueryClient) {
configureAuthClient({
accessToken: () => "test-token",
refresh: async () => true,
clear: vi.fn(),
});
return render(
<MantineProvider>
<QueryClientProvider client={client}>
@@ -1,36 +1,10 @@
import {
Alert,
Button,
Modal,
PasswordInput,
Stack,
Text,
} from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { apiQueryClient } from "@/shared/api";
import {
commitTbankConnectionBoundary,
type TbankConnectionStatus,
} from "../model/connection-boundary";
import {
TbankConnectionError,
TbankConnectionLoading,
} from "./TbankConnectionSection.view";
import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
type TbankConnectionSectionLoadedProps = {
status: TbankConnectionStatus;
};
function errorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String(error.code)
: undefined;
}
import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
const TbankConnectionSection: FC = () => {
const viewModel = useTbankConnectionViewModel();
@@ -40,119 +14,4 @@ const TbankConnectionSection: FC = () => {
return <TbankConnectionSectionLoaded status={viewModel.status} />;
};
const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
props,
) => {
const { status } = props;
const { t } = useTranslation();
const queryClient = useQueryClient();
const [deleteOpened, setDeleteOpened] = useState(false);
const [requestError, setRequestError] = useState<string | null>(null);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<{ token: string }>();
const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", {
onSuccess: (data) => {
commitTbankConnectionBoundary(queryClient, data);
reset();
},
onError: (error) => setRequestError(errorCode(error) ?? "REQUEST_ERROR"),
});
const remove = apiQueryClient.useMutation(
"delete",
"/api/v1/tbank-connection",
{
onSuccess: () => {
commitTbankConnectionBoundary(queryClient, {
connected: false,
connectedAt: null,
});
setDeleteOpened(false);
},
onError: () => setRequestError("REQUEST_ERROR"),
},
);
const pending = update.isPending || remove.isPending;
const connected = status.connected;
return (
<Stack mt="xl">
<Text fw={600}>{t("tbank.title")}</Text>
<Text size="sm">
{connected
? t("tbank.connected", {
date: new Date(String(status.connectedAt)).toLocaleDateString(
"ru-RU",
),
})
: t("tbank.disconnected")}
</Text>
<Text size="sm" c="dimmed">
{t("tbank.hint")}
</Text>
<form
onSubmit={handleSubmit(({ token }) => {
setRequestError(null);
update.mutate({ body: { token } });
})}
>
<fieldset disabled={pending}>
<Stack>
<PasswordInput
label={t("tbank.token")}
{...register("token", { required: t("tbank.tokenRequired") })}
error={errors.token?.message}
/>
{requestError && (
<Alert color="red">
{t(
requestError === "INVALID_TBANK_TOKEN"
? "tbank.invalidToken"
: requestError === "TBANK_UNAVAILABLE"
? "tbank.unavailable"
: "tbank.requestError",
)}
</Alert>
)}
<Button type="submit" loading={update.isPending}>
{t(connected ? "tbank.replace" : "tbank.connect")}
</Button>
{connected && (
<Button
variant="outline"
color="red"
onClick={() => setDeleteOpened(true)}
>
{t("tbank.delete")}
</Button>
)}
</Stack>
</fieldset>
</form>
<Modal
opened={deleteOpened}
onClose={() => !pending && setDeleteOpened(false)}
title={t("tbank.deleteTitle")}
closeOnClickOutside={!pending}
closeOnEscape={!pending}
>
<Stack>
<Text>{t("tbank.deleteText")}</Text>
<Button
color="red"
loading={remove.isPending}
disabled={pending}
onClick={() => remove.mutate({})}
>
{t("tbank.deleteConfirm")}
</Button>
</Stack>
</Modal>
</Stack>
);
};
export { TbankConnectionSection };
@@ -1,12 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/features/auth";
import { useAuthenticatedQuery } from "@/shared/api";
import { tbankConnectionQueryOptions } from "../model/connection-boundary";
function useTbankConnectionViewModel() {
const { accessToken } = useAuth();
const status = useQuery({
const status = useAuthenticatedQuery({
...tbankConnectionQueryOptions(),
enabled: Boolean(accessToken),
});
if (status.isPending) return { state: "loading" as const };
if (status.isError)
@@ -0,0 +1,139 @@
import {
Alert,
Button,
Modal,
PasswordInput,
Stack,
Text,
} from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { apiErrorCode, apiQueryClient } from "@/shared/api";
import {
commitTbankConnectionBoundary,
type TbankConnectionStatus,
} from "../model/connection-boundary";
type TbankConnectionSectionLoadedProps = {
status: TbankConnectionStatus;
};
const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
props,
) => {
const { status } = props;
const { t } = useTranslation();
const queryClient = useQueryClient();
const [deleteOpened, setDeleteOpened] = useState(false);
const [requestError, setRequestError] = useState<string | null>(null);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<{ token: string }>();
const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", {
onSuccess: (data) => {
commitTbankConnectionBoundary(queryClient, data);
reset();
},
onError: (error) => setRequestError(apiErrorCode(error) ?? "REQUEST_ERROR"),
});
const remove = apiQueryClient.useMutation(
"delete",
"/api/v1/tbank-connection",
{
onSuccess: () => {
commitTbankConnectionBoundary(queryClient, {
connected: false,
connectedAt: null,
});
setDeleteOpened(false);
},
onError: () => setRequestError("REQUEST_ERROR"),
},
);
const pending = update.isPending || remove.isPending;
const connected = status.connected;
return (
<Stack mt="xl">
<Text fw={600}>{t("tbank.title")}</Text>
<Text size="sm">
{connected
? t("tbank.connected", {
date: new Date(status.connectedAt ?? "").toLocaleDateString(
"ru-RU",
),
})
: t("tbank.disconnected")}
</Text>
<Text size="sm" c="dimmed">
{t("tbank.hint")}
</Text>
<form
onSubmit={handleSubmit(({ token }) => {
setRequestError(null);
update.mutate({ body: { token } });
})}
>
<fieldset disabled={pending}>
<Stack>
<PasswordInput
label={t("tbank.token")}
{...register("token", { required: t("tbank.tokenRequired") })}
error={errors.token?.message}
/>
{requestError && (
<Alert color="red">
{t(
requestError === "INVALID_TBANK_TOKEN"
? "tbank.invalidToken"
: requestError === "TBANK_UNAVAILABLE"
? "tbank.unavailable"
: "tbank.requestError",
)}
</Alert>
)}
<Button type="submit" loading={update.isPending}>
{t(connected ? "tbank.replace" : "tbank.connect")}
</Button>
{connected && (
<Button
variant="outline"
color="red"
onClick={() => setDeleteOpened(true)}
>
{t("tbank.delete")}
</Button>
)}
</Stack>
</fieldset>
</form>
<Modal
opened={deleteOpened}
onClose={() => !pending && setDeleteOpened(false)}
title={t("tbank.deleteTitle")}
closeOnClickOutside={!pending}
closeOnEscape={!pending}
>
<Stack>
<Text>{t("tbank.deleteText")}</Text>
<Button
color="red"
loading={remove.isPending}
disabled={pending}
onClick={() => remove.mutate({})}
>
{t("tbank.deleteConfirm")}
</Button>
</Stack>
</Modal>
</Stack>
);
};
export { TbankConnectionSectionLoaded };
@@ -1,33 +1,10 @@
import {
Button,
Card,
Group,
Skeleton,
Stack,
Text,
Title,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { formatMoney } from "@/entities/portfolio";
import { LoadingSkeleton, StateView } from "@/shared/ui";
type AccountEventsPageErrorProps = {
retry: () => void;
};
type AccountEventsPageLoadedProps = {
events: BrokerageEvent[];
};
type AccountEventRowProps = {
event: BrokerageEvent;
};
import { useBrokerageEvents } from "@/entities/brokerage-event";
import { AccountEventsPageEmpty } from "./components/AccountEventsPageEmpty.view";
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
const AccountEventsPage: FC = () => {
const { accountId } = useParams();
@@ -40,98 +17,4 @@ const AccountEventsPage: FC = () => {
return <AccountEventsPageLoaded events={events.data.events} />;
};
const AccountEventsPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="40%" />
<Skeleton height={18} width="70%" />
{[0, 1, 2, 3].map((index) => (
<Skeleton key={index} height={82} radius="md" />
))}
</LoadingSkeleton>
);
};
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { retry } = props;
const { t } = useTranslation();
return (
<StateView
error
title={t("events.errorTitle")}
text={t("events.errorText")}
action={<Button onClick={retry}>{t("events.retry")}</Button>}
/>
);
};
const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title>
<Text c="dimmed">{t("events.hint")}</Text>
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
</Stack>
);
};
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { events } = props;
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title>
<Text c="dimmed">{t("events.hint")}</Text>
{events.map((event) => (
<AccountEventRow
event={event}
key={`${event.type}-${event.date}-${event.ticker}`}
/>
))}
</Stack>
);
};
const AccountEventRow: FC<AccountEventRowProps> = (props) => {
const { event } = props;
const { t } = useTranslation();
return (
<Card withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2}>
<Text fw={700}>{event.displayName}</Text>
<Text c="dimmed" size="sm">
{formatCalendarDate(event.date)} ·{" "}
{t(`events.dateKind.${event.dateKind}`)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text fw={700}>
{event.projectedAmount
? formatMoney(event.projectedAmount)
: t("events.noAmount")}
</Text>
<Text c="dimmed" size="sm">
{t(`events.type.${event.type}`)}
</Text>
</Stack>
</Group>
</Card>
);
};
function formatCalendarDate(date: string) {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeZone: "UTC",
}).format(new Date(`${date}T00:00:00Z`));
}
export { AccountEventsPage };
@@ -0,0 +1,44 @@
import { Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import { formatMoney } from "@/shared/lib";
type AccountEventRowProps = { event: BrokerageEvent };
const AccountEventRow: FC<AccountEventRowProps> = (props) => {
const { event } = props;
const { t } = useTranslation();
return (
<Card withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2}>
<Text fw={700}>{event.displayName}</Text>
<Text c="dimmed" size="sm">
{formatCalendarDate(event.date)} ·{" "}
{t(`events.dateKind.${event.dateKind}`)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text fw={700}>
{event.projectedAmount
? formatMoney(event.projectedAmount)
: t("events.noAmount")}
</Text>
<Text c="dimmed" size="sm">
{t(`events.type.${event.type}`)}
</Text>
</Stack>
</Group>
</Card>
);
};
function formatCalendarDate(date: string) {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeZone: "UTC",
}).format(new Date(`${date}T00:00:00Z`));
}
export { AccountEventRow };
@@ -0,0 +1,17 @@
import { Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title>
<Text c="dimmed">{t("events.hint")}</Text>
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
</Stack>
);
};
export { AccountEventsPageEmpty };
@@ -0,0 +1,20 @@
import { Button } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
type AccountEventsPageErrorProps = { retry: () => void };
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation();
return (
<StateView
error
title={t("events.errorTitle")}
text={t("events.errorText")}
action={<Button onClick={props.retry}>{t("events.retry")}</Button>}
/>
);
};
export { AccountEventsPageError };
@@ -0,0 +1,25 @@
import { Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import { AccountEventRow } from "./AccountEventRow.view";
type AccountEventsPageLoadedProps = { events: BrokerageEvent[] };
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title>
<Text c="dimmed">{t("events.hint")}</Text>
{props.events.map((event) => (
<AccountEventRow
event={event}
key={`${event.type}-${event.date}-${event.ticker}`}
/>
))}
</Stack>
);
};
export { AccountEventsPageLoaded };
@@ -0,0 +1,19 @@
import { Skeleton } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
const AccountEventsPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="40%" />
<Skeleton height={18} width="70%" />
{[0, 1, 2, 3].map((index) => (
<Skeleton key={index} height={82} radius="md" />
))}
</LoadingSkeleton>
);
};
export { AccountEventsPageLoading };
@@ -1,13 +1,14 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { IncomeAnalytics } from "@/entities/income";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { AccountIncomePage } from "./AccountIncomePage";
import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm";
const analytics = {
accountId: "account-1",
@@ -41,7 +42,7 @@ const analytics = {
},
],
entries: [entry()],
};
} satisfies IncomeAnalytics;
function money(currency: string, value: string) {
return { currency, value };
@@ -65,7 +66,7 @@ function comparison(currency: string) {
};
}
function entry() {
function entry(): IncomeAnalytics["entries"][number] {
return {
id: "coupon-1",
occurredAt: "2026-08-01T12:00:00Z",
@@ -91,36 +92,45 @@ function renderPage() {
refresh: async () => true,
clear: vi.fn(),
});
const Wrapper: FC<PropsWithChildren> = (props) => (
<MantineProvider>
<QueryClientProvider client={new QueryClient()}>
{props.children}
</QueryClientProvider>
</MantineProvider>
);
const { wrapper: QueryWrapper } = createQueryWrapper();
return render(
<Wrapper>
<MemoryRouter
initialEntries={[
"/accounts/account-1/income?from=2026-08-01&to=2026-08-27",
]}
>
<Routes>
<Route
path="/accounts/:accountId/income"
element={<AccountIncomePage />}
/>
<Route path="/accounts/:accountId/events" element={<h1>Events</h1>} />
</Routes>
</MemoryRouter>
</Wrapper>,
<MantineProvider>
<QueryWrapper>
<MemoryRouter
initialEntries={[
"/accounts/account-1/income?from=2026-08-01&to=2026-08-27",
]}
>
<Routes>
<Route
path="/accounts/:accountId/income"
element={<AccountIncomePage />}
/>
<Route
path="/accounts/:accountId/events"
element={<h1>Events</h1>}
/>
</Routes>
</MemoryRouter>
</QueryWrapper>
</MantineProvider>,
);
}
describe("AccountIncomePage", () => {
afterEach(() => {
cleanup();
configureAuthClient(null);
it("rejects malformed calendar dates without throwing", () => {
expect(isDate("2026-99-99")).toBe(false);
expect(isDate("2026-02-29")).toBe(false);
expect(isDate("2024-02-29")).toBe(true);
});
it("groups income entries once in the view model", () => {
const display = toIncomeDisplayModel(analytics);
expect(display.instruments[0].entries).toEqual([analytics.entries[0]]);
expect(display.trend[0].buckets[0].total).toEqual(
analytics.buckets[0].totalsByCurrency[0],
);
});
it("renders realized analytics, drill-down, and the independent forecast", async () => {
@@ -1,10 +1,8 @@
import type { FC } from "react";
import {
AccountIncomePageError,
AccountIncomePageLoaded,
AccountIncomePageLoading,
} from "./AccountIncomePage.view";
import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm";
import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
const AccountIncomePage: FC = () => {
const viewModel = useAccountIncomePageViewModel();
@@ -1,388 +0,0 @@
import {
Accordion,
Alert,
Button,
Group,
Paper,
SegmentedControl,
Skeleton,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import type { FC } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import type { IncomeAnalytics } from "@/entities/income";
import { formatMoney } from "@/entities/portfolio";
import { LoadingSkeleton, StateView } from "@/shared/ui";
import styles from "./AccountIncomePage.module.css";
import type { AccountIncomePageViewModel } from "./AccountIncomePage.vm";
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
const AccountIncomePageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="40%" />
<Skeleton height={120} radius="md" />
<Skeleton height={260} radius="md" />
</LoadingSkeleton>
);
};
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>}
/>
);
};
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
const { analytics, form, forecast, onSubmit, onReset } = props;
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("income.title")}</Title>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Group align="end">
<TextInput
error={form.formState.errors.from?.message}
label={t("income.from")}
type="date"
{...form.register("from")}
/>
<TextInput
error={form.formState.errors.to?.message}
label={t("income.to")}
type="date"
{...form.register("to")}
/>
<Button type="submit">{t("income.apply")}</Button>
<Button onClick={onReset} variant="subtle">
{t("income.reset")}
</Button>
</Group>
</form>
<Text c="dimmed">
{analytics.period.from} — {analytics.period.to}
</Text>
{analytics.totalsByCurrency.length ? (
<IncomeSummary totals={analytics.totalsByCurrency} />
) : (
<StateView
title={t("income.emptyTitle")}
text={t("income.emptyText")}
/>
)}
<Alert color="blue">{t("income.taxBoundary")}</Alert>
<IncomeComparisons analytics={analytics} />
<IncomeTrend analytics={analytics} />
<IncomeBreakdown analytics={analytics} />
<IncomeForecast forecast={forecast} />
</Stack>
);
};
const IncomeSummary: FC<{ totals: IncomeAnalytics["totalsByCurrency"] }> = (
props,
) => {
const { t } = useTranslation();
return (
<Stack>
{props.totals.map((total) => (
<Paper key={total.currency} p="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)}
</Text>
</Paper>
))}
</Stack>
);
};
const IncomeComparisons: FC<{ analytics: IncomeAnalytics }> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<Title order={2}>{t("income.comparisons")}</Title>
<Comparison
title={t("income.previous", {
from: props.analytics.previous.period.from,
to: props.analytics.previous.period.to,
})}
values={props.analytics.previous.totalsByCurrency}
/>
{props.analytics.yearComparison ? (
<Comparison
title={t("income.yearAgo", {
from: props.analytics.yearComparison.period.from,
to: props.analytics.yearComparison.period.to,
})}
values={props.analytics.yearComparison.totalsByCurrency}
/>
) : (
<Text c="dimmed">{t("income.yearUnavailable")}</Text>
)}
</Stack>
);
};
const Comparison: FC<{
title: string;
values: IncomeAnalytics["previous"]["totalsByCurrency"];
}> = (props) => {
const { t } = useTranslation();
return (
<Paper p="sm" withBorder>
<Text fw={700}>{props.title}</Text>
{props.values.map((value) => (
<Stack gap={2} key={value.currency}>
<Text size="sm">{value.currency}</Text>
<Text size="sm">
{t("income.selected")}: {t("income.coupon")}{" "}
{formatMoney(value.selected.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.selected.dividend)} · {t("income.total")}{" "}
{formatMoney(value.selected.total)}
</Text>
<Text size="sm">
{t("income.compared")}: {t("income.coupon")}{" "}
{formatMoney(value.compared.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.compared.dividend)} · {t("income.total")}{" "}
{formatMoney(value.compared.total)}
</Text>
<Text size="sm">
{t("income.difference")}: {t("income.coupon")}{" "}
{formatMoney(value.difference.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.difference.dividend)} · {t("income.total")}{" "}
{formatMoney(value.difference.total)}
</Text>
</Stack>
))}
</Paper>
);
};
const IncomeTrend: FC<{ analytics: IncomeAnalytics }> = (props) => {
const currencies = props.analytics.totalsByCurrency.map(
(total) => total.currency,
);
const [currency, setCurrency] = useState(currencies[0] ?? "");
const { t } = useTranslation();
const buckets = props.analytics.buckets.map((bucket) => ({
period: bucket.period,
total:
bucket.totalsByCurrency.find((item) => item.currency === currency) ??
zeroSummary(currency),
}));
const values = buckets.map((bucket) => ({
...bucket,
coupon: Number(bucket.total.coupon.value),
dividend: Number(bucket.total.dividend.value),
}));
const scale = Math.max(
1,
...values.map((value) =>
Math.max(
positive(value.coupon) + positive(value.dividend),
negative(value.coupon) + negative(value.dividend),
),
),
);
return (
<Stack>
<Title order={2}>{t("income.trend")}</Title>
{currencies.length > 1 ? (
<SegmentedControl
aria-label={t("income.currency")}
data={currencies}
onChange={setCurrency}
value={currency}
/>
) : null}
<div aria-hidden="true" className={styles.trendChart}>
{values.map((value) => (
<div className={styles.trendColumn} key={value.period.from}>
<span className={styles.baseline} />
<div className={styles.positiveStack}>
{value.coupon > 0 ? (
<span
className={styles.coupon}
data-testid={`trend-positive-coupon-${value.period.from}`}
style={{ flexBasis: chartHeight(value.coupon, scale) }}
/>
) : null}
{value.dividend > 0 ? (
<span
className={styles.dividend}
data-testid={`trend-positive-dividend-${value.period.from}`}
style={{ flexBasis: chartHeight(value.dividend, scale) }}
/>
) : null}
</div>
<div className={styles.negativeStack}>
{value.coupon < 0 ? (
<span
className={styles.coupon}
data-testid={`trend-negative-coupon-${value.period.from}`}
style={{ flexBasis: chartHeight(value.coupon, scale) }}
/>
) : null}
{value.dividend < 0 ? (
<span
className={styles.dividend}
data-testid={`trend-negative-dividend-${value.period.from}`}
style={{ flexBasis: chartHeight(value.dividend, scale) }}
/>
) : null}
</div>
</div>
))}
</div>
<Stack gap={2}>
{buckets.map((bucket) => (
<Text key={bucket.period.from} size="sm">
{bucket.period.from} — {bucket.period.to}: {t("income.coupon")}{" "}
{formatMoney(bucket.total?.coupon ?? null)}, {t("income.dividend")}{" "}
{formatMoney(bucket.total?.dividend ?? null)}
</Text>
))}
</Stack>
</Stack>
);
};
const IncomeBreakdown: FC<{ analytics: IncomeAnalytics }> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<Title order={2}>{t("income.instruments")}</Title>
<Accordion>
{props.analytics.instruments.map((instrument) => (
<Accordion.Item key={instrument.key} value={instrument.key}>
<Accordion.Control>
{instrument.displayName} · {formatMoney(instrument.total)} ·{" "}
{instrument.receiptCount}
</Accordion.Control>
<Accordion.Panel>
<Text size="sm">
{t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(instrument.dividend)}
</Text>
<Stack mt="sm">
{props.analytics.entries
.filter((entry) => entryKey(entry) === instrument.key)
.map((entry) => (
<LedgerEntryRow key={entry.id} operation={entry} />
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</Stack>
);
};
const IncomeForecast: FC<{ forecast: LoadedProps["forecast"] }> = (props) => {
const { t } = useTranslation();
if (props.forecast.state === "loading")
return <Text c="dimmed">{t("income.forecastLoading")}</Text>;
if (props.forecast.state === "error")
return (
<StateView
error
title={t("income.forecastError")}
text={t("income.errorText")}
action={
<Button onClick={props.forecast.retry}>{t("income.retry")}</Button>
}
/>
);
if (props.forecast.state === "empty")
return (
<StateView
title={t("income.forecastTitle")}
text={t("income.forecastEmpty")}
/>
);
if (props.forecast.state !== "loaded") return null;
return (
<Stack>
<Title order={2}>{t("income.forecastTitle")}</Title>
<Alert color="yellow">{t("income.forecastWarning")}</Alert>
{props.forecast.totals.map((total) => (
<Text key={total.currency} fw={700}>
{formatMoney(total)}
</Text>
))}
{props.forecast.events.map((event) => (
<ForecastEvent
event={event}
key={`${event.type}-${event.date}-${event.ticker}`}
/>
))}
<Button component={Link} to="../events" variant="subtle">
{t("events.open")}
</Button>
</Stack>
);
};
const ForecastEvent: FC<{ event: BrokerageEvent }> = (props) => (
<Paper p="sm" withBorder>
<Text fw={700}>{props.event.displayName}</Text>
<Text size="sm">
{props.event.date} · {formatMoney(props.event.projectedAmount)}
</Text>
</Paper>
);
function entryKey(entry: IncomeAnalytics["entries"][number]): string {
const base = entry.instrumentUid
? `uid:${entry.instrumentUid}`
: entry.figi
? `figi:${entry.figi}`
: entry.ticker || entry.instrumentType
? `ticker:${entry.ticker ?? ""}:${entry.instrumentType ?? ""}`
: `operation:${entry.id}`;
return `${base}:${entry.payment?.currency ?? ""}`;
}
function zeroSummary(
currency: string,
): IncomeAnalytics["totalsByCurrency"][number] {
const zero = { currency, value: "0" };
return { currency, coupon: zero, dividend: zero, total: zero };
}
function positive(value: number): number {
return Math.max(0, value);
}
function negative(value: number): number {
return Math.max(0, -value);
}
function chartHeight(value: number, scale: number): string {
return `${(Math.abs(value) / scale) * 100}%`;
}
export {
AccountIncomePageError,
AccountIncomePageLoaded,
AccountIncomePageLoading,
};
@@ -20,12 +20,26 @@ type IncomeForecast =
events: BrokerageEvent[];
};
type IncomeDisplayModel = {
trend: {
currency: string;
buckets: {
period: IncomeAnalytics["buckets"][number]["period"];
total: IncomeAnalytics["totalsByCurrency"][number];
}[];
}[];
instruments: (IncomeAnalytics["instruments"][number] & {
entries: IncomeAnalytics["entries"];
})[];
};
type AccountIncomePageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void; period: IncomePeriod }
| {
state: "loaded";
analytics: IncomeAnalytics;
display: IncomeDisplayModel;
forecast: IncomeForecast;
form: ReturnType<typeof useForm<IncomeForm>>;
onReset: () => void;
@@ -81,6 +95,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
return {
state: "loaded",
analytics: analytics.data,
display: toIncomeDisplayModel(analytics.data),
forecast: toForecast(events),
form,
onSubmit,
@@ -126,12 +141,59 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod {
}
function isDate(value: string): boolean {
if (!datePattern.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return (
datePattern.test(value) &&
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value
Number.isFinite(parsed.valueOf()) &&
parsed.toISOString().slice(0, 10) === value
);
}
function toIncomeDisplayModel(analytics: IncomeAnalytics): IncomeDisplayModel {
const entriesByInstrument = new Map<string, IncomeAnalytics["entries"]>();
for (const entry of analytics.entries) {
const key = incomeEntryKey(entry);
entriesByInstrument.set(key, [
...(entriesByInstrument.get(key) ?? []),
entry,
]);
}
return {
trend: analytics.totalsByCurrency.map(({ currency }) => ({
currency,
buckets: analytics.buckets.map((bucket) => ({
period: bucket.period,
total:
bucket.totalsByCurrency.find((item) => item.currency === currency) ??
zeroSummary(currency),
})),
})),
instruments: analytics.instruments.map((instrument) => ({
...instrument,
entries: entriesByInstrument.get(instrument.key) ?? [],
})),
};
}
function incomeEntryKey(entry: IncomeAnalytics["entries"][number]): string {
const base = entry.instrumentUid
? `uid:${entry.instrumentUid}`
: entry.figi
? `figi:${entry.figi}`
: entry.ticker || entry.instrumentType
? `ticker:${entry.ticker ?? ""}:${entry.instrumentType ?? ""}`
: `operation:${entry.id}`;
return `${base}:${entry.payment?.currency ?? ""}`;
}
function zeroSummary(
currency: string,
): IncomeAnalytics["totalsByCurrency"][number] {
const zero = { currency, value: "0" };
return { currency, coupon: zero, dividend: zero, total: zero };
}
function moscowDate(value: Date): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Moscow",
@@ -144,4 +206,9 @@ function moscowDate(value: Date): string {
return `${part("year")}-${part("month")}-${part("day")}`;
}
export { type AccountIncomePageViewModel, useAccountIncomePageViewModel };
export {
type AccountIncomePageViewModel,
isDate,
toIncomeDisplayModel,
useAccountIncomePageViewModel,
};
@@ -0,0 +1,21 @@
import { Button } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
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>}
/>
);
};
export { AccountIncomePageError };
@@ -0,0 +1,68 @@
import {
Alert,
Button,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import { IncomeBreakdown } from "./IncomeBreakdown.view";
import { IncomeComparisons } from "./IncomeComparisons.view";
import { IncomeForecast } from "./IncomeForecast.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { IncomeTrend } from "./IncomeTrend.view";
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
const { analytics, display, form, forecast, onSubmit, onReset } = props;
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("income.title")}</Title>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Group align="end">
<TextInput
error={form.formState.errors.from?.message}
label={t("income.from")}
type="date"
{...form.register("from")}
/>
<TextInput
error={form.formState.errors.to?.message}
label={t("income.to")}
type="date"
{...form.register("to")}
/>
<Button type="submit">{t("income.apply")}</Button>
<Button onClick={onReset} variant="subtle">
{t("income.reset")}
</Button>
</Group>
</form>
<Text c="dimmed">
{analytics.period.from} — {analytics.period.to}
</Text>
{analytics.totalsByCurrency.length ? (
<IncomeSummary totals={analytics.totalsByCurrency} />
) : (
<StateView
title={t("income.emptyTitle")}
text={t("income.emptyText")}
/>
)}
<Alert color="blue">{t("income.taxBoundary")}</Alert>
<IncomeComparisons analytics={analytics} />
<IncomeTrend trend={display.trend} />
<IncomeBreakdown instruments={display.instruments} />
<IncomeForecast forecast={forecast} />
</Stack>
);
};
export { AccountIncomePageLoaded };
@@ -0,0 +1,17 @@
import { Skeleton } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
const AccountIncomePageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="40%" />
<Skeleton height={120} radius="md" />
<Skeleton height={260} radius="md" />
</LoadingSkeleton>
);
};
export { AccountIncomePageLoading };
@@ -0,0 +1,43 @@
import { Accordion, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { formatMoney } from "@/shared/lib";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
type IncomeBreakdownProps = {
instruments: LoadedProps["display"]["instruments"];
};
const IncomeBreakdown: FC<IncomeBreakdownProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<Title order={2}>{t("income.instruments")}</Title>
<Accordion>
{props.instruments.map((instrument) => (
<Accordion.Item key={instrument.key} value={instrument.key}>
<Accordion.Control>
{instrument.displayName} · {formatMoney(instrument.total)} ·{" "}
{instrument.receiptCount}
</Accordion.Control>
<Accordion.Panel>
<Text size="sm">
{t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(instrument.dividend)}
</Text>
<Stack mt="sm">
{instrument.entries.map((entry) => (
<LedgerEntryRow key={entry.id} operation={entry} />
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</Stack>
);
};
export { IncomeBreakdown };
@@ -0,0 +1,72 @@
import { Paper, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { IncomeAnalytics } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
type IncomeComparisonsProps = { analytics: IncomeAnalytics };
type ComparisonProps = {
title: string;
values: IncomeAnalytics["previous"]["totalsByCurrency"];
};
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<Title order={2}>{t("income.comparisons")}</Title>
<Comparison
title={t("income.previous", {
from: props.analytics.previous.period.from,
to: props.analytics.previous.period.to,
})}
values={props.analytics.previous.totalsByCurrency}
/>
{props.analytics.yearComparison ? (
<Comparison
title={t("income.yearAgo", {
from: props.analytics.yearComparison.period.from,
to: props.analytics.yearComparison.period.to,
})}
values={props.analytics.yearComparison.totalsByCurrency}
/>
) : (
<Text c="dimmed">{t("income.yearUnavailable")}</Text>
)}
</Stack>
);
};
const Comparison: FC<ComparisonProps> = (props) => {
const { t } = useTranslation();
return (
<Paper p="sm" withBorder>
<Text fw={700}>{props.title}</Text>
{props.values.map((value) => (
<Stack gap={2} key={value.currency}>
<Text size="sm">{value.currency}</Text>
<Text size="sm">
{t("income.selected")}: {t("income.coupon")}{" "}
{formatMoney(value.selected.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.selected.dividend)} · {t("income.total")}{" "}
{formatMoney(value.selected.total)}
</Text>
<Text size="sm">
{t("income.compared")}: {t("income.coupon")}{" "}
{formatMoney(value.compared.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.compared.dividend)} · {t("income.total")}{" "}
{formatMoney(value.compared.total)}
</Text>
<Text size="sm">
{t("income.difference")}: {t("income.coupon")}{" "}
{formatMoney(value.difference.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.difference.dividend)} · {t("income.total")}{" "}
{formatMoney(value.difference.total)}
</Text>
</Stack>
))}
</Paper>
);
};
export { IncomeComparisons };
@@ -0,0 +1,68 @@
import { Alert, Button, Paper, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import { formatMoney } from "@/shared/lib";
import { StateView } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
type IncomeForecastProps = { forecast: LoadedProps["forecast"] };
type ForecastEventProps = { event: BrokerageEvent };
const IncomeForecast: FC<IncomeForecastProps> = (props) => {
const { t } = useTranslation();
if (props.forecast.state === "loading")
return <Text c="dimmed">{t("income.forecastLoading")}</Text>;
if (props.forecast.state === "error")
return (
<StateView
error
title={t("income.forecastError")}
text={t("income.errorText")}
action={
<Button onClick={props.forecast.retry}>{t("income.retry")}</Button>
}
/>
);
if (props.forecast.state === "empty")
return (
<StateView
title={t("income.forecastTitle")}
text={t("income.forecastEmpty")}
/>
);
if (props.forecast.state !== "loaded") return null;
return (
<Stack>
<Title order={2}>{t("income.forecastTitle")}</Title>
<Alert color="yellow">{t("income.forecastWarning")}</Alert>
{props.forecast.totals.map((total) => (
<Text key={total.currency} fw={700}>
{formatMoney(total)}
</Text>
))}
{props.forecast.events.map((event) => (
<ForecastEvent
event={event}
key={`${event.type}-${event.date}-${event.ticker}`}
/>
))}
<Button component={Link} to="../events" variant="subtle">
{t("events.open")}
</Button>
</Stack>
);
};
const ForecastEvent: FC<ForecastEventProps> = (props) => (
<Paper p="sm" withBorder>
<Text fw={700}>{props.event.displayName}</Text>
<Text size="sm">
{props.event.date} · {formatMoney(props.event.projectedAmount)}
</Text>
</Paper>
);
export { IncomeForecast };
@@ -0,0 +1,26 @@
import { Paper, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { IncomeAnalytics } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
type IncomeSummaryProps = { totals: IncomeAnalytics["totalsByCurrency"] };
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
{props.totals.map((total) => (
<Paper key={total.currency} p="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)}
</Text>
</Paper>
))}
</Stack>
);
};
export { IncomeSummary };
@@ -0,0 +1,107 @@
import { SegmentedControl, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import styles from "../AccountIncomePage.module.css";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
type IncomeTrendProps = { trend: LoadedProps["display"]["trend"] };
const IncomeTrend: FC<IncomeTrendProps> = (props) => {
const currencies = props.trend.map(({ currency }) => currency);
const [currency, setCurrency] = useState(currencies[0] ?? "");
const { t } = useTranslation();
const buckets =
props.trend.find((item) => item.currency === currency)?.buckets ?? [];
const values = buckets.map((bucket) => ({
...bucket,
coupon: Number(bucket.total.coupon.value),
dividend: Number(bucket.total.dividend.value),
}));
const scale = Math.max(
1,
...values.map((value) =>
Math.max(
positive(value.coupon) + positive(value.dividend),
negative(value.coupon) + negative(value.dividend),
),
),
);
return (
<Stack>
<Title order={2}>{t("income.trend")}</Title>
{currencies.length > 1 ? (
<SegmentedControl
aria-label={t("income.currency")}
data={currencies}
onChange={setCurrency}
value={currency}
/>
) : null}
<div aria-hidden="true" className={styles.trendChart}>
{values.map((value) => (
<div className={styles.trendColumn} key={value.period.from}>
<span className={styles.baseline} />
<div className={styles.positiveStack}>
{value.coupon > 0 ? (
<span
className={styles.coupon}
data-testid={`trend-positive-coupon-${value.period.from}`}
style={{ flexBasis: chartHeight(value.coupon, scale) }}
/>
) : null}
{value.dividend > 0 ? (
<span
className={styles.dividend}
data-testid={`trend-positive-dividend-${value.period.from}`}
style={{ flexBasis: chartHeight(value.dividend, scale) }}
/>
) : null}
</div>
<div className={styles.negativeStack}>
{value.coupon < 0 ? (
<span
className={styles.coupon}
data-testid={`trend-negative-coupon-${value.period.from}`}
style={{ flexBasis: chartHeight(value.coupon, scale) }}
/>
) : null}
{value.dividend < 0 ? (
<span
className={styles.dividend}
data-testid={`trend-negative-dividend-${value.period.from}`}
style={{ flexBasis: chartHeight(value.dividend, scale) }}
/>
) : null}
</div>
</div>
))}
</div>
<Stack gap={2}>
{buckets.map((bucket) => (
<Text key={bucket.period.from} size="sm">
{bucket.period.from} — {bucket.period.to}: {t("income.coupon")}{" "}
{formatMoney(bucket.total.coupon)}, {t("income.dividend")}{" "}
{formatMoney(bucket.total.dividend)}
</Text>
))}
</Stack>
</Stack>
);
};
function positive(value: number): number {
return Math.max(0, value);
}
function negative(value: number): number {
return Math.max(0, -value);
}
function chartHeight(value: number, scale: number): string {
return `${(Math.abs(value) / scale) * 100}%`;
}
export { IncomeTrend };
@@ -1,19 +1,17 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation";
import { describe, expect, it, vi } from "vitest";
import {
type BrokerageOperation,
LedgerEntryRow,
} from "@/entities/broker-operation";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { AccountOperationsPage, OperationRow } from "./AccountOperationsPage";
import { AccountOperationsPage } from "./AccountOperationsPage";
const operation: BrokerageOperation = {
id: "operation-1",
@@ -41,14 +39,29 @@ function configureTestAuth() {
});
}
afterEach(cleanup);
function renderPage() {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
}
describe("OperationRow", () => {
describe("LedgerEntryRow", () => {
it("marks cashless operations without rendering operation details", () => {
render(
<MantineProvider>
<MemoryRouter>
<OperationRow operation={operation} />
<LedgerEntryRow operation={operation} />
</MemoryRouter>
</MantineProvider>,
);
@@ -65,7 +78,7 @@ describe("OperationRow", () => {
render(
<MantineProvider>
<MemoryRouter>
<OperationRow
<LedgerEntryRow
operation={{
...operation,
payment: { currency: "RUB", value: "15" },
@@ -83,7 +96,7 @@ describe("OperationRow", () => {
render(
<MantineProvider>
<MemoryRouter>
<OperationRow
<LedgerEntryRow
operation={{
...operation,
type: "OPERATION_TYPE_COUPON",
@@ -103,7 +116,7 @@ describe("OperationRow", () => {
render(
<MantineProvider>
<MemoryRouter>
<OperationRow
<LedgerEntryRow
operation={{
...operation,
type: "OPERATION_TYPE_DIVIDEND",
@@ -123,7 +136,7 @@ describe("OperationRow", () => {
render(
<MantineProvider>
<MemoryRouter>
<OperationRow
<LedgerEntryRow
operation={{
...operation,
type: "OPERATION_TYPE_SERVICE_FEE",
@@ -143,7 +156,7 @@ describe("OperationRow", () => {
render(
<MantineProvider>
<MemoryRouter>
<OperationRow
<LedgerEntryRow
operation={{
...operation,
payment: { currency: "RUB", value: "0" },
@@ -161,7 +174,7 @@ describe("OperationRow", () => {
render(
<MantineProvider>
<MemoryRouter>
<OperationRow operation={operation} />
<LedgerEntryRow operation={operation} />
</MemoryRouter>
</MantineProvider>,
);
@@ -171,8 +184,6 @@ describe("OperationRow", () => {
});
describe("AccountOperationsPage", () => {
afterEach(() => configureAuthClient(null));
it("resets cursor pagination when filters change", async () => {
configureTestAuth();
const searches: string[] = [];
@@ -192,20 +203,7 @@ describe("AccountOperationsPage", () => {
},
),
);
render(
<QueryClientProvider client={new QueryClient()}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
renderPage();
expect(
await screen.findByRole("heading", { name: "Журнал операций" }),
@@ -241,20 +239,7 @@ describe("AccountOperationsPage", () => {
},
),
);
render(
<QueryClientProvider client={new QueryClient()}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
renderPage();
expect(
await screen.findByRole("heading", { name: "Журнал операций" }),
@@ -287,20 +272,7 @@ describe("AccountOperationsPage", () => {
},
),
);
render(
<QueryClientProvider client={new QueryClient()}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
renderPage();
expect(
await screen.findByRole("heading", { name: "Журнал операций" }),
@@ -323,20 +295,7 @@ describe("AccountOperationsPage", () => {
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
);
render(
<QueryClientProvider client={new QueryClient()}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
renderPage();
expect(
await screen.findByText("Не удалось загрузить журнал"),
).toBeInTheDocument();
@@ -1,5 +1,4 @@
import type { FC } from "react";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { useAccountOperationsPageVm } from "./AccountOperationsPage.vm";
import { AccountOperationsPageError } from "./components/AccountOperationsPageError.view";
import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view";
@@ -19,4 +18,4 @@ const AccountOperationsPage: FC = () => {
return <AccountOperationsPageLoaded {...viewModel} />;
};
export { AccountOperationsPage, LedgerEntryRow as OperationRow };
export { AccountOperationsPage };
@@ -3,10 +3,10 @@ import { useState } from "react";
import { useParams } from "react-router";
import {
type BrokerageOperation,
brokerageOperationsQueryOptions,
type OperationCategory,
useBrokerageOperations,
} from "@/entities/broker-operation";
import { apiQueryClient } from "@/shared/api";
type AccountOperationsPageViewModel =
| { state: "loading" }
@@ -51,11 +51,9 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
...(cursor ? { cursor } : {}),
limit: 100,
});
const resetQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/operations",
{ params: { path: { accountId: accountId ?? "" }, query: { limit: 100 } } },
).queryKey;
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
limit: 100,
}).queryKey;
const resetCursors = () => setCursors([]);
+7 -32
View File
@@ -32,61 +32,36 @@
color: var(--mantine-color-red-6);
}
.stockCard {
.expandedCard {
padding: var(--mantine-spacing-md);
}
.stockMain {
.expandedMain {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--mantine-spacing-sm);
align-items: center;
}
.stockIdentity,
.stockValue {
.expandedIdentity,
.expandedValue {
min-width: 0;
}
.stockIdentifiers {
.expandedIdentifiers {
overflow-wrap: anywhere;
}
.stockValue {
.expandedValue {
white-space: nowrap;
}
.stockMeta,
.bondMeta {
.expandedMeta {
padding-top: var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-sm);
border-top: 1px solid var(--mantine-color-gray-2);
}
.bondCard {
padding: var(--mantine-spacing-md);
}
.bondMain {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--mantine-spacing-sm);
align-items: center;
}
.bondIdentity,
.bondValue {
min-width: 0;
}
.bondIdentifiers {
overflow-wrap: anywhere;
}
.bondValue {
white-space: nowrap;
}
.mono {
font-family: var(--mantine-font-family-monospace);
}
+16 -20
View File
@@ -1,28 +1,28 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { describe, expect, it, vi } from "vitest";
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import {
AccountPage,
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
} from "./AccountPage";
import { AccountPage } from "./AccountPage";
import type {
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "./AccountPage.vm";
import { toAccountPagePositionSections } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading.view";
function renderPage(ui: React.ReactNode) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter>{ui}</MemoryRouter>
</MantineProvider>
@@ -113,10 +113,6 @@ function emptyProps() {
}
describe("AccountPage states", () => {
afterEach(() => {
configureAuthClient(null);
cleanup();
});
it("renders loading and empty states", () => {
renderPage(<AccountPageLoading />);
expect(screen.getByRole("status")).toBeInTheDocument();
@@ -528,12 +524,12 @@ describe("AccountPage states", () => {
);
}),
);
const queryClient = new QueryClient();
const queryClient = createTestQueryClient();
renderAccountRoute(queryClient);
await screen.findByText("Вторая");
void queryClient.invalidateQueries({
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"],
queryKey: portfolioQueryOptions("account-1").queryKey,
});
expect(
@@ -563,12 +559,12 @@ describe("AccountPage states", () => {
});
}),
);
const queryClient = new QueryClient();
const queryClient = createTestQueryClient();
renderAccountRoute(queryClient);
await screen.findByText("Вторая");
void queryClient.invalidateQueries({
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"],
queryKey: portfolioQueryOptions("account-1").queryKey,
});
await waitFor(() => expect(requestCount).toBe(2));
+5 -13
View File
@@ -1,11 +1,9 @@
import type { FC } from "react";
import {
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
} from "./AccountPage.view";
import { useAccountPageViewModel } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading.view";
const AccountPage: FC = () => {
const viewModel = useAccountPageViewModel();
@@ -38,10 +36,4 @@ const AccountPage: FC = () => {
);
};
export {
AccountPage,
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
};
export { AccountPage };
-244
View File
@@ -1,244 +0,0 @@
import { Accordion, Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import { formatSnapshotTime } from "@/entities/portfolio";
import { LoadingSkeleton, StateView } from "@/shared/ui";
import classes from "./AccountPage.module.css";
import type {
AccountPageIncomeSummary,
AccountPagePositionSection,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "./AccountPage.vm";
import {
AccountHeading,
CashBalances,
IncomeSummary,
PortfolioRefreshState,
PortfolioTotals,
PositionSection,
RecentEvents,
RecentOperations,
} from "./components";
type AccountPageErrorProps = {
code?: string;
retry: () => void;
};
type AccountPageEmptyProps = {
account?: BrokerageAccount;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
};
type AccountPageLoadedProps = {
account?: BrokerageAccount;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
};
const AccountPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="35%" />
<Skeleton height={120} radius="md" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={52} radius="sm" />
))}
</LoadingSkeleton>
);
};
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code, retry } = props;
const { t } = useTranslation();
if (code === "NOT_FOUND")
return (
<StateView
error
title={t("portfolio.accountUnavailableTitle")}
text={t("portfolio.accountUnavailableText")}
action={
<Button component={Link} to="/accounts">
{t("portfolio.back")}
</Button>
}
/>
);
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("portfolio.connectionErrorTitle")}
text={t("portfolio.connectionErrorText")}
action={
<Button component={Link} to="/settings">
{t("accounts.openSettings")}
</Button>
}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
};
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
const {
account,
refreshing = false,
refreshError = false,
retry,
recentOperations,
recentEvents,
incomeSummary,
} = props;
const { t } = useTranslation();
return (
<Stack>
<AccountHeading account={account} />
<PortfolioRefreshState
refreshing={refreshing}
refreshError={refreshError}
retry={retry}
/>
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
<RecentOperations operations={recentOperations} />
<RecentEvents events={recentEvents} />
{incomeSummary ? <IncomeSummary {...incomeSummary} /> : null}
</Stack>
);
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const {
account,
portfolio,
positionSections,
refreshing = false,
refreshError = false,
retry,
recentOperations,
recentEvents,
incomeSummary,
} = props;
const { t } = useTranslation();
return (
<Stack className={classes.page} gap="lg">
<AccountHeading account={account} />
<PortfolioRefreshState
refreshing={refreshing}
refreshError={refreshError}
retry={retry}
/>
{portfolio.isStale ? (
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null}
<Text c="dimmed" size="sm">
{t("portfolio.updatedAt", {
date: formatSnapshotTime(portfolio.lastSuccessfulAt),
})}
</Text>
<PortfolioTotals totals={portfolio.totalsByCurrency} />
<Accordion
multiple
defaultValue={[
...positionSections.map((s) => s.kind),
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
]}
classNames={{
control: classes.accordionControl,
label: classes.accordionLabel,
chevron: classes.accordionChevron,
panel: classes.accordionPanel,
}}
>
{positionSections.map((section) => (
<Accordion.Item key={section.kind} value={section.kind}>
<Accordion.Control>
{t(positionSectionTitleKey(section.kind))}
</Accordion.Control>
<Accordion.Panel>
<PositionSection section={section} />
</Accordion.Panel>
</Accordion.Item>
))}
<Accordion.Item value="cash">
<Accordion.Control>{t("portfolio.cashBalances")}</Accordion.Control>
<Accordion.Panel>
<CashBalances balances={portfolio.cashBalances} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentOperations">
<Accordion.Control>{t("ledger.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentOperations operations={recentOperations} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentEvents">
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentEvents events={recentEvents} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="incomeSummary">
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
<Accordion.Panel>
{incomeSummary ? <IncomeSummary {...incomeSummary} /> : null}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
};
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks";
if (kind === "bonds") return "portfolio.bonds";
return "portfolio.otherPositions";
}
export {
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
};
+19 -18
View File
@@ -1,12 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { useAccountPageViewModel } from "./AccountPage.vm";
@@ -63,21 +63,21 @@ function configureTestAuth() {
});
}
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<QueryClientProvider client={new QueryClient()}>
function createWrapper() {
const { wrapper: QueryWrapper } = createQueryWrapper();
const Wrapper: FC<PropsWithChildren> = (props) => (
<QueryWrapper>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1"]}>
<Routes>
<Route path="/accounts/:accountId" element={children} />
<Route path="/accounts/:accountId" element={props.children} />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>
</QueryWrapper>
);
};
return Wrapper;
}
function mockLoadedAccountPage() {
server.use(
@@ -130,13 +130,13 @@ function mockLoadedAccountPage() {
}
describe("useAccountPageViewModel", () => {
afterEach(() => configureAuthClient(null));
it("prepares grouped position sections with derived display data", async () => {
configureTestAuth();
mockLoadedAccountPage();
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.state).toBe("loaded"));
const loaded = result.current as Extract<
@@ -146,7 +146,6 @@ describe("useAccountPageViewModel", () => {
expect(loaded.positionSections).toMatchObject([
{
kind: "stocks",
wide: true,
positions: [
{
displayName: "Акция",
@@ -159,7 +158,6 @@ describe("useAccountPageViewModel", () => {
},
{
kind: "bonds",
wide: true,
positions: [
{
displayName: "Облигация",
@@ -172,7 +170,6 @@ describe("useAccountPageViewModel", () => {
},
{
kind: "other",
wide: false,
positions: [
{
displayName: "Фонд",
@@ -190,7 +187,9 @@ describe("useAccountPageViewModel", () => {
configureTestAuth();
mockLoadedAccountPage();
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() =>
expect(
@@ -233,7 +232,9 @@ describe("useAccountPageViewModel", () => {
),
);
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() =>
expect(result.current).toMatchObject({
+12 -18
View File
@@ -12,13 +12,9 @@ import {
} from "@/entities/brokerage-event";
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio";
import {
formatMoney,
formatShare,
instrumentLogoUrl,
portfolioErrorCode,
usePortfolio,
} from "@/entities/portfolio";
import { formatShare, usePortfolio } from "@/entities/portfolio";
import { apiErrorCode } from "@/shared/api";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
type PositionSectionKind = "stocks" | "bonds" | "other";
type AccountPageReturnTone = "positive" | "negative";
@@ -32,7 +28,6 @@ type AccountPagePosition = Portfolio["positions"][number] & {
type AccountPagePositionSection = {
kind: PositionSectionKind;
wide: boolean;
positions: AccountPagePosition[];
};
@@ -108,7 +103,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
if (portfolio.isError)
return {
state: "error" as const,
code: portfolioErrorCode(portfolio.error),
code: apiErrorCode(portfolio.error),
retry: () => portfolio.refetch(),
};
return { state: "loading" as const };
@@ -188,9 +183,9 @@ function toAccountPagePositionSections(
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
);
}
const stocks = toSection("stocks", true, portfolio, denominators);
const bonds = toSection("bonds", true, portfolio, denominators);
const other = toSection("other", false, portfolio, denominators);
const stocks = toSection("stocks", portfolio, denominators);
const bonds = toSection("bonds", portfolio, denominators);
const other = toSection("other", portfolio, denominators);
return [stocks, bonds, other].filter(
(section) => section.positions.length > 0,
);
@@ -198,28 +193,27 @@ function toAccountPagePositionSections(
function toSection(
kind: PositionSectionKind,
wide: boolean,
portfolio: Portfolio,
denominators: Map<string, number>,
): AccountPagePositionSection {
return {
kind,
wide,
positions: 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";
})
.map((position) => toPosition(position, denominators, wide)),
.map((position) => toPosition(position, denominators, kind)),
};
}
function toPosition(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
wide: boolean,
kind: PositionSectionKind,
): AccountPagePosition {
const expanded = kind !== "other";
const returnValue = position.absoluteReturn
? Number(position.absoluteReturn.value)
: null;
@@ -238,10 +232,10 @@ function toPosition(
})
: null,
returnText: position.absoluteReturn
? `${wide && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
? `${expanded && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
: null,
returnTone,
logoSrc: wide ? instrumentLogoUrl(position.logoName) : null,
logoSrc: expanded ? instrumentLogoUrl(position.logoName) : null,
};
}
@@ -0,0 +1,48 @@
import { Stack } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { StateView } from "@/shared/ui";
import type {
AccountPageIncomeSummary,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../AccountPage.vm";
import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
import { RecentEvents } from "./RecentEvents.view";
import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = {
account?: BrokerageAccount;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
};
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<AccountHeading account={props.account} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
refreshError={props.refreshError ?? false}
retry={props.retry}
/>
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
<RecentOperations operations={props.recentOperations} />
<RecentEvents events={props.recentEvents} />
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
</Stack>
);
};
export { AccountPageEmpty };
@@ -0,0 +1,57 @@
import { Button } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { StateView } from "@/shared/ui";
type AccountPageErrorProps = { code?: string; retry: () => void };
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code, retry } = props;
const { t } = useTranslation();
if (code === "NOT_FOUND")
return (
<StateView
error
title={t("portfolio.accountUnavailableTitle")}
text={t("portfolio.accountUnavailableText")}
action={
<Button component={Link} to="/accounts">
{t("portfolio.back")}
</Button>
}
/>
);
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("portfolio.connectionErrorTitle")}
text={t("portfolio.connectionErrorText")}
action={
<Button component={Link} to="/settings">
{t("accounts.openSettings")}
</Button>
}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
};
export { AccountPageError };
@@ -0,0 +1,117 @@
import { Accordion, Alert, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import { formatSnapshotTime } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type {
AccountPageIncomeSummary,
AccountPagePositionSection,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../AccountPage.vm";
import { AccountHeading } from "./AccountHeading.view";
import { CashBalances } from "./CashBalances.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
import { PortfolioTotals } from "./PortfolioTotals.view";
import { PositionSection } from "./PositionSection.view";
import { RecentEvents } from "./RecentEvents.view";
import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = {
account?: BrokerageAccount;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation();
return (
<Stack className={classes.page} gap="lg">
<AccountHeading account={props.account} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
refreshError={props.refreshError ?? false}
retry={props.retry}
/>
{props.portfolio.isStale ? (
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null}
<Text c="dimmed" size="sm">
{t("portfolio.updatedAt", {
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
})}
</Text>
<PortfolioTotals totals={props.portfolio.totalsByCurrency} />
<Accordion
multiple
defaultValue={[
...props.positionSections.map((section) => section.kind),
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
]}
classNames={{
control: classes.accordionControl,
label: classes.accordionLabel,
chevron: classes.accordionChevron,
panel: classes.accordionPanel,
}}
>
{props.positionSections.map((section) => (
<Accordion.Item key={section.kind} value={section.kind}>
<Accordion.Control>
{t(positionSectionTitleKey(section.kind))}
</Accordion.Control>
<Accordion.Panel>
<PositionSection section={section} />
</Accordion.Panel>
</Accordion.Item>
))}
<Accordion.Item value="cash">
<Accordion.Control>{t("portfolio.cashBalances")}</Accordion.Control>
<Accordion.Panel>
<CashBalances balances={props.portfolio.cashBalances} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentOperations">
<Accordion.Control>{t("ledger.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentOperations operations={props.recentOperations} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentEvents">
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentEvents events={props.recentEvents} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="incomeSummary">
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
<Accordion.Panel>
{props.incomeSummary ? (
<IncomeSummary {...props.incomeSummary} />
) : null}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
};
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks";
if (kind === "bonds") return "portfolio.bonds";
return "portfolio.otherPositions";
}
export { AccountPageLoaded };
@@ -0,0 +1,19 @@
import { Skeleton } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
const AccountPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="35%" />
<Skeleton height={120} radius="md" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={52} radius="sm" />
))}
</LoadingSkeleton>
);
};
export { AccountPageLoading };
@@ -1,7 +1,7 @@
import { Paper, Text } from "@mantine/core";
import type { FC } from "react";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
type CashBalancesProps = {
@@ -3,7 +3,7 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import { StateView } from "@/shared/ui";
type IncomeSummaryProps =
@@ -1,7 +1,7 @@
import { Paper, SimpleGrid, Text } from "@mantine/core";
import type { FC } from "react";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
type PortfolioTotalsProps = {
@@ -1,44 +1,28 @@
import { Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type { AccountPagePosition } from "../AccountPage.vm";
import type {
AccountPagePosition,
AccountPagePositionSection,
} from "../AccountPage.vm";
import { PositionLogo } from "./PositionLogo.view";
type PositionCardProps = {
kind: AccountPagePositionSection["kind"];
position: AccountPagePosition;
wide?: boolean;
variant?: "stock" | "bond";
};
const PositionCard: FC<PositionCardProps> = (props) => {
const { position, wide = false, variant } = props;
const { kind, position } = props;
const { t } = useTranslation();
const isExpanded = variant === "stock" || variant === "bond";
const ec =
variant === "bond"
? {
card: classes.bondCard,
main: classes.bondMain,
identity: classes.bondIdentity,
identifiers: classes.bondIdentifiers,
value: classes.bondValue,
meta: classes.bondMeta,
}
: {
card: classes.stockCard,
main: classes.stockMain,
identity: classes.stockIdentity,
identifiers: classes.stockIdentifiers,
value: classes.stockValue,
meta: classes.stockMeta,
};
const isExpanded = kind !== "other";
const returnClass = position.returnTone
? classes[position.returnTone]
: undefined;
const returnText =
position.returnText && wide
position.returnText && isExpanded
? position.returnText
: position.returnText
? t("portfolio.return", {
@@ -46,7 +30,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
})
: null;
const identifier =
variant === "stock" ? (position.ticker ?? position.isin) : position.isin;
kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
const identifierText = [
t("portfolio.quantity", { value: position.quantity }),
identifier,
@@ -68,22 +52,22 @@ const PositionCard: FC<PositionCardProps> = (props) => {
if (isExpanded)
return (
<Card className={`${classes.tile} ${ec.card}`} withBorder>
<div className={ec.main}>
<Card className={`${classes.tile} ${classes.expandedCard}`} withBorder>
<div className={classes.expandedMain}>
<PositionLogo
key={position.logoSrc ?? "no-logo"}
src={position.logoSrc}
label={position.displayName}
/>
<Stack gap={2} miw={0} className={ec.identity}>
<Stack gap={2} miw={0} className={classes.expandedIdentity}>
<Text fw={600} lineClamp={1}>
{position.displayName}
</Text>
<Text c="dimmed" size="xs" className={ec.identifiers}>
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
{identifierText}
</Text>
</Stack>
<Stack gap={2} align="flex-end" className={ec.value}>
<Stack gap={2} align="flex-end" className={classes.expandedValue}>
{position.currentValue ? (
<Text fw={700} size="lg">
{formatMoney(position.currentValue)}
@@ -97,7 +81,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
</Stack>
</div>
{secondaryText ? (
<Text c="dimmed" size="xs" className={ec.meta}>
<Text c="dimmed" size="xs" className={classes.expandedMeta}>
{secondaryText}
</Text>
) : null}
@@ -106,13 +90,6 @@ const PositionCard: FC<PositionCardProps> = (props) => {
return (
<Card className={classes.tile} withBorder>
<Group justify="space-between" align="flex-start" wrap="nowrap">
{wide ? (
<PositionLogo
key={position.logoSrc ?? "no-logo"}
src={position.logoSrc}
label={position.displayName}
/>
) : null}
<Stack gap={2} miw={0}>
<Text fw={700} lineClamp={1}>
{position.displayName}
@@ -9,21 +9,15 @@ type PositionSectionProps = {
const PositionSection: FC<PositionSectionProps> = (props) => {
const { section } = props;
const expanded = section.kind !== "other";
return (
<SimpleGrid cols={section.wide ? 1 : { base: 1, sm: 2, md: 3 }}>
<SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}>
{section.positions.map((position) => (
<PositionCard
kind={section.kind}
key={`${position.figi}-${position.displayName}-${position.quantity}`}
position={position}
wide={section.wide}
variant={
section.kind === "stocks"
? "stock"
: section.kind === "bonds"
? "bond"
: undefined
}
/>
))}
</SimpleGrid>
@@ -3,7 +3,7 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "../AccountPage.module.css";
import type { AccountPageRecentEvents } from "../AccountPage.vm";
-8
View File
@@ -1,8 +0,0 @@
export { AccountHeading } from "./AccountHeading.view";
export { CashBalances } from "./CashBalances.view";
export { IncomeSummary } from "./IncomeSummary.view";
export { PortfolioRefreshState } from "./PortfolioRefreshState.view";
export { PortfolioTotals } from "./PortfolioTotals.view";
export { PositionSection } from "./PositionSection.view";
export { RecentEvents } from "./RecentEvents.view";
export { RecentOperations } from "./RecentOperations.view";
+1 -6
View File
@@ -1,6 +1 @@
export {
AccountsPage,
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoading,
} from "./ui/AccountsPage";
export { AccountsPage } from "./ui/AccountsPage";
+40 -9
View File
@@ -1,13 +1,17 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { AccountsPage } from "./AccountsPage";
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
import { AccountsPageError } from "./components/AccountsPageError.view";
import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
const account = {
id: "account-1",
@@ -38,9 +42,36 @@ function renderPage(queryClient: QueryClient) {
}
describe("AccountsPage", () => {
afterEach(() => {
configureAuthClient(null);
cleanup();
it.each([
["loading", AccountsPageLoading, "status"],
["empty", AccountsPageEmpty, undefined],
] as const)("renders only the %s state", (_name, State, role) => {
render(
<MantineProvider>
<State />
</MantineProvider>,
);
if (role) expect(screen.getByRole(role)).toBeInTheDocument();
else
expect(
screen.getByRole("heading", { name: "Нет инвестиционных счетов" }),
).toBeInTheDocument();
});
it("routes connection errors to settings and keeps retry available", () => {
const retry = vi.fn();
render(
<MantineProvider>
<MemoryRouter>
<AccountsPageError code="TBANK_UNAVAILABLE" retry={retry} />
</MemoryRouter>
</MantineProvider>,
);
screen.getByRole("button", { name: "Повторить" }).click();
expect(retry).toHaveBeenCalledOnce();
expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings");
});
it("keeps the cached account list visible during a background refresh", async () => {
@@ -55,7 +86,7 @@ describe("AccountsPage", () => {
});
}),
);
const queryClient = new QueryClient();
const queryClient = createTestQueryClient();
renderPage(queryClient);
await screen.findByText(account.name);
@@ -86,7 +117,7 @@ describe("AccountsPage", () => {
return HttpResponse.json([account]);
}),
);
const queryClient = new QueryClient();
const queryClient = createTestQueryClient();
renderPage(queryClient);
await screen.findByText(account.name);
@@ -116,7 +147,7 @@ describe("AccountsPage", () => {
);
}),
);
const queryClient = new QueryClient();
const queryClient = createTestQueryClient();
renderPage(queryClient);
await screen.findByText("Нет инвестиционных счетов");
+5 -12
View File
@@ -1,11 +1,9 @@
import type { FC } from "react";
import {
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoaded,
AccountsPageLoading,
} from "./AccountsPage.view";
import { useAccountsPageViewModel } from "./AccountsPage.vm";
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
import { AccountsPageError } from "./components/AccountsPageError.view";
import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view";
import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
const AccountsPage: FC = () => {
const viewModel = useAccountsPageViewModel();
@@ -29,9 +27,4 @@ const AccountsPage: FC = () => {
);
};
export {
AccountsPage,
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoading,
};
export { AccountsPage };
-175
View File
@@ -1,175 +0,0 @@
import {
Alert,
Button,
Group,
Loader,
Skeleton,
Stack,
Text,
Title,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageAccountCard } from "@/entities/brokerage-account";
import { BrokerageAccountList } from "@/entities/brokerage-account";
import { LoadingSkeleton, StateView } from "@/shared/ui";
type AccountsPageErrorProps = {
code?: string;
retry?: () => void;
};
type AccountsPageEmptyProps = {
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
};
type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
};
type AccountsRefreshStateProps = {
refreshing: boolean;
refreshError: boolean;
retry?: () => void;
};
const AccountsPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="30%" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={116} radius="md" />
))}
</LoadingSkeleton>
);
};
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
const { code, retry } = props;
const { t } = useTranslation();
const settingsAction = (
<Button component={Link} to="/settings" variant="light">
{t("accounts.openSettings")}
</Button>
);
if (code === "TBANK_NOT_CONNECTED")
return (
<StateView
error
title={t("accounts.notConnectedTitle")}
text={t("accounts.notConnectedText")}
action={settingsAction}
/>
);
if (code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("accounts.invalidTokenTitle")}
text={t("accounts.invalidTokenText")}
action={settingsAction}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={
<Stack align="flex-start">
<Button onClick={retry}>{t("accounts.retry")}</Button>
{settingsAction}
</Stack>
}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
};
const AccountsPageEmpty: FC<AccountsPageEmptyProps> = (props) => {
const { refreshing = false, refreshError = false, retry } = props;
const { t } = useTranslation();
return (
<Stack>
<AccountsRefreshState
refreshing={refreshing}
refreshError={refreshError}
retry={retry}
/>
<StateView
title={t("accounts.emptyTitle")}
text={t("accounts.emptyText")}
/>
</Stack>
);
};
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { accounts, refreshing = false, refreshError = false, retry } = props;
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("accounts.title")}</Title>
<AccountsRefreshState
refreshing={refreshing}
refreshError={refreshError}
retry={retry}
/>
<BrokerageAccountList accounts={accounts} />
</Stack>
);
};
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
const { refreshing, refreshError, retry } = props;
const { t } = useTranslation();
if (!refreshing && !refreshError) return null;
return (
<>
{refreshing ? (
<Group gap="xs" role="status" aria-label={t("accounts.refreshing")}>
<Loader size="sm" />
<Text c="dimmed" size="sm">
{t("accounts.refreshing")}
</Text>
</Group>
) : null}
{refreshError ? (
<Alert color="orange">
<Stack gap="sm">
<Text>{t("accounts.refreshError")}</Text>
<Button onClick={retry} variant="light">
{t("accounts.retry")}
</Button>
</Stack>
</Alert>
) : null}
</>
);
};
export {
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoaded,
AccountsPageLoading,
};
+2 -2
View File
@@ -1,8 +1,8 @@
import {
brokerageAccountCards,
errorCode,
useBrokerageAccounts,
} from "@/entities/brokerage-account";
import { apiErrorCode } from "@/shared/api";
function useAccountsPageViewModel() {
const accounts = useBrokerageAccounts();
@@ -11,7 +11,7 @@ function useAccountsPageViewModel() {
if (accounts.isError)
return {
state: "error" as const,
code: errorCode(accounts.error),
code: apiErrorCode(accounts.error),
retry: () => accounts.refetch(),
};
}
@@ -0,0 +1,23 @@
import { Stack } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
import {
AccountsRefreshState,
type AccountsRefreshStateProps,
} from "./AccountsRefreshState.view";
const AccountsPageEmpty: FC<AccountsRefreshStateProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<AccountsRefreshState {...props} />
<StateView
title={t("accounts.emptyTitle")}
text={t("accounts.emptyText")}
/>
</Stack>
);
};
export { AccountsPageEmpty };
@@ -0,0 +1,59 @@
import { Button, Stack } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { StateView } from "@/shared/ui";
type AccountsPageErrorProps = { code?: string; retry?: () => void };
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
const { code, retry } = props;
const { t } = useTranslation();
const settingsAction = (
<Button component={Link} to="/settings" variant="light">
{t("accounts.openSettings")}
</Button>
);
if (code === "TBANK_NOT_CONNECTED")
return (
<StateView
error
title={t("accounts.notConnectedTitle")}
text={t("accounts.notConnectedText")}
action={settingsAction}
/>
);
if (code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("accounts.invalidTokenTitle")}
text={t("accounts.invalidTokenText")}
action={settingsAction}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={
<Stack align="flex-start">
<Button onClick={retry}>{t("accounts.retry")}</Button>
{settingsAction}
</Stack>
}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
};
export { AccountsPageError };
@@ -0,0 +1,29 @@
import { Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
type BrokerageAccountCard,
BrokerageAccountList,
} from "@/entities/brokerage-account";
import {
AccountsRefreshState,
type AccountsRefreshStateProps,
} from "./AccountsRefreshState.view";
type AccountsPageLoadedProps = AccountsRefreshStateProps & {
accounts: BrokerageAccountCard[];
};
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { accounts, ...refresh } = props;
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("accounts.title")}</Title>
<AccountsRefreshState {...refresh} />
<BrokerageAccountList accounts={accounts} />
</Stack>
);
};
export { AccountsPageLoaded };
@@ -0,0 +1,18 @@
import { Skeleton } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
const AccountsPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="30%" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={116} radius="md" />
))}
</LoadingSkeleton>
);
};
export { AccountsPageLoading };
@@ -0,0 +1,39 @@
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
type AccountsRefreshStateProps = {
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
};
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
const { refreshing = false, refreshError = false, retry } = props;
const { t } = useTranslation();
if (!refreshing && !refreshError) return null;
return (
<>
{refreshing ? (
<Group gap="xs" role="status" aria-label={t("accounts.refreshing")}>
<Loader size="sm" />
<Text c="dimmed" size="sm">
{t("accounts.refreshing")}
</Text>
</Group>
) : null}
{refreshError ? (
<Alert color="orange">
<Stack gap="sm">
<Text>{t("accounts.refreshError")}</Text>
<Button onClick={retry} variant="light">
{t("accounts.retry")}
</Button>
</Stack>
</Alert>
) : null}
</>
);
};
export { AccountsRefreshState, type AccountsRefreshStateProps };
+4 -10
View File
@@ -1,20 +1,14 @@
import { Paper, Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoginForm } from "@/features/auth";
import { AuthPageLayout, LoginForm } from "@/features/auth";
const LoginPage: FC = () => {
const { t } = useTranslation();
return (
<main className="auth-page">
<Paper className="auth-card" p="xl" radius="lg" withBorder>
<Stack gap="lg">
<Title order={1}>{t("auth.login")}</Title>
<LoginForm />
</Stack>
</Paper>
</main>
<AuthPageLayout title={t("auth.login")}>
<LoginForm />
</AuthPageLayout>
);
};
+2 -2
View File
@@ -1,8 +1,8 @@
import { Stack, Title } from "@mantine/core";
import { Button, Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Button, StateView } from "@/shared/ui";
import { StateView } from "@/shared/ui";
const NotFoundPage: FC = () => {
const { t } = useTranslation();
+4 -10
View File
@@ -1,20 +1,14 @@
import { Paper, Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { RegisterForm } from "@/features/auth";
import { AuthPageLayout, RegisterForm } from "@/features/auth";
const RegisterPage: FC = () => {
const { t } = useTranslation();
return (
<main className="auth-page">
<Paper className="auth-card" p="xl" radius="lg" withBorder>
<Stack gap="lg">
<Title order={1}>{t("auth.register")}</Title>
<RegisterForm />
</Stack>
</Paper>
</main>
<AuthPageLayout title={t("auth.register")}>
<RegisterForm />
</AuthPageLayout>
);
};
+3 -22
View File
@@ -1,31 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { useAuthenticatedQuery } from "./authenticated-query";
import { apiQueryClient, configureAuthClient } from "./client";
function createWrapper() {
const client = new QueryClient();
const TestWrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
};
return TestWrapper;
}
describe("useAuthenticatedQuery", () => {
afterEach(() => configureAuthClient(null));
it("keeps protected queries idle without a session and loads after session configuration", async () => {
const requests = vi.fn();
server.use(
@@ -47,7 +28,7 @@ describe("useAuthenticatedQuery", () => {
useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
),
{ wrapper: createWrapper() },
{ wrapper: createQueryWrapper().wrapper },
);
await new Promise((resolve) => setTimeout(resolve, 0));
+1 -3
View File
@@ -1,11 +1,9 @@
import { HttpResponse, http } from "msw";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { server } from "@/test/server";
import { apiClient, configureAuthClient } from "./client";
describe("apiClient authentication", () => {
afterEach(() => configureAuthClient(null));
it("refreshes once and retries an unauthorized protected request", async () => {
let token = "expired";
const refresh = vi.fn(async () => {
+1 -1
View File
@@ -232,7 +232,7 @@ export interface components {
TbankConnectionStatusDto: {
connected: boolean;
/** Format: date-time */
connectedAt: Record<string, never> | null;
connectedAt: string | null;
};
PutTbankConnectionDto: {
token: string;
+2
View File
@@ -1,2 +1,4 @@
export { useAuthenticatedQuery } from "./authenticated-query";
export { apiClient, apiQueryClient, configureAuthClient } from "./client";
export type { paths } from "./generated/schema";
export { apiErrorCode } from "./problem-details";
+7
View File
@@ -0,0 +1,7 @@
function apiErrorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String(error.code)
: undefined;
}
export { apiErrorCode };
+2 -2
View File
@@ -1275,7 +1275,7 @@
"type": "boolean"
},
"connectedAt": {
"type": "object",
"type": "string",
"nullable": true,
"format": "date-time"
}
@@ -1960,4 +1960,4 @@
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More