refactor: clarify frontend module ownership
Frontend verify / commits (pull_request) Failing after 1m19s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped

This commit is contained in:
2026-09-29 07:07:36 +03:00
parent 7c28a423dd
commit f9f0f6921e
91 changed files with 1292 additions and 1050 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ throwing route, безопасная ссылка и reset после navigation
Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx), Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx),
[client test](../../src/shared/api/client.test.ts), [client test](../../src/shared/api/client.test.ts),
[resource lifecycle test](../../src/entities/portfolio/api/resource-lifecycle.test.tsx). [resource lifecycle test](../../src/shared/api/resource-query.test.tsx).
Не копируй fixtures/сценарии целиком, если задача затрагивает один исход. Не копируй fixtures/сценарии целиком, если задача затрагивает один исход.
## Команды ## Команды
+3 -3
View File
@@ -76,9 +76,9 @@ JSX props получают ссылки на функции, включая `onC
Closure над состоянием, mutations и refs является действием VM, а не shared helper. Closure над состоянием, mutations и refs является действием VM, а не shared helper.
VM собирает связные предметные hooks, когда внутри смешиваются независимые VM собирает связные предметные hooks, когда внутри смешиваются независимые
сценарии. Например, `useOpeningDateField` владеет draft/date parsing и событиями сценарии. Например, `useRefundPurchase` владеет выбором исходной покупки,
даты, `usePaymentSystemField` — выбором платёжной системы. Каждый такой hook её загрузкой и пагинацией. Общий адаптер `bankDateField` обслуживает поля даты;
возвращает данные и действия своей ответственности, VM объединяет их для view. простое подключение одного controller остаётся в VM формы.
Не группируй несвязанные функции только по префиксу `on*` и не создавай Не группируй несвязанные функции только по префиксу `on*` и не создавай
passthrough hook над одним вызовом ради формального дробления. passthrough hook над одним вызовом ради формального дробления.
@@ -0,0 +1,38 @@
import "@mantine/core/styles.css";
import "../styles.css";
import { MantineProvider } from "@mantine/core";
// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup.
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { cssVariablesResolver, theme } from "../theme";
import { EntryCard } from "./EntryCard";
afterEach(cleanup);
it("shows the full wrapped title without horizontal overflow", () => {
const title = "Дивидендная выплата с очень длинным описанием, которое должно переноситься и оставаться доступным полностью";
render(
<MantineProvider cssVariablesResolver={cssVariablesResolver} theme={theme}>
<EntryCard
initial="Д"
metadata="29.09.2026"
title={title}
tone="positive"
value="123 456 789,90 ₽"
valueLabel="Дивиденды"
wrap
/>
</MantineProvider>,
);
const description = screen.getByText(title);
expect(description.clientHeight).toBeGreaterThan(0);
expect(description.scrollHeight)
.toBeLessThanOrEqual(description.clientHeight);
expect(document.documentElement.scrollWidth)
.toBeLessThanOrEqual(window.innerWidth);
});
+3 -1
View File
@@ -28,6 +28,8 @@ const EntryCard: FC<EntryCardProps> = (props) => {
} }
: undefined; : undefined;
const titleLineClamp = props.wrap ? undefined : 1;
const valueContainerClass = props.actions ? classes.valueFooter : undefined; const valueContainerClass = props.actions ? classes.valueFooter : undefined;
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`; const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
@@ -49,7 +51,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
</Avatar> </Avatar>
<Stack className={classes.identity} gap={2}> <Stack className={classes.identity} gap={2}>
<Text fw={700} lineClamp={1}> <Text fw={700} lineClamp={titleLineClamp}>
{props.title} {props.title}
</Text> </Text>
+1
View File
@@ -0,0 +1 @@
export { useCurrentRoute } from "./useCurrentRoute";
+23
View File
@@ -0,0 +1,23 @@
import { useMatch } from "react-router";
function useCurrentRoute() {
const account = useMatch("/accounts/:accountId/:section?");
const section = (["events", "income", "operations"] as const)
.find(value => value === account?.params.section?.toLowerCase());
return {
account,
accounts: useMatch("/accounts"),
bond: useMatch("/bonds/:bondId"),
bonds: useMatch("/bonds"),
card: useMatch("/cards/:cardId"),
cards: useMatch("/cards"),
section,
settings: useMatch("/settings"),
stock: useMatch("/stocks/:stockId"),
stocks: useMatch("/stocks"),
};
}
export { useCurrentRoute };
+42 -2
View File
@@ -1,9 +1,11 @@
import type { ReactNode } from "react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { type InitialEntry, MemoryRouter } from "react-router"; import { type InitialEntry, Link, MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AuthProvider } from "@/features/auth"; import { AuthProvider } from "@/features/auth";
@@ -12,10 +14,11 @@ import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes"; import { AppRoutes } from "./AppRoutes";
function renderRoutes(path: InitialEntry) { function renderRoutes(path: InitialEntry, controls?: ReactNode) {
return render( return render(
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={[path]}> <MemoryRouter initialEntries={[path]}>
{controls}
<MantineProvider <MantineProvider
cssVariablesResolver={cssVariablesResolver} cssVariablesResolver={cssVariablesResolver}
theme={theme} theme={theme}
@@ -110,6 +113,43 @@ describe("AppRoutes", () => {
}); });
}); });
it.each([
{ isFailure: false, isNewDestination: false },
{ isFailure: true, isNewDestination: false },
{ isFailure: true, isNewDestination: true },
])("finishes logout safely: failure=$isFailure, new destination=$isNewDestination", async ({ isFailure, isNewDestination }) => {
const { promise: pendingLogout, resolve: finishLogout }
= Promise.withResolvers<void>();
server.use(
http.post("*/api/v1/auth/refresh", () => HttpResponse.json({
accessToken: "test-token", user: { email: "user@example.com", id: "user-1" },
})),
http.post("*/api/v1/auth/logout", async () => {
await pendingLogout;
return isFailure
? HttpResponse.error()
: new HttpResponse(null, { status: 204 });
}),
);
renderRoutes("/settings", <Link to="/settings?source=manual">Открыть настройки</Link>);
fireEvent.click(await screen.findByRole("button", { name: "Выйти" }));
await screen.findByRole("heading", { level: 1, name: "Вход" });
if (isNewDestination) fireEvent.click(screen.getByRole("link", { 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: "Вход" }));
finishLogout();
const destination = isNewDestination
? { level: 1, name: "Настройки" }
: { level: 2, name: "Нет инвестиционных счетов" };
expect(await screen.findByRole("heading", destination)).toBeInTheDocument();
expect(document.title).toBe(isNewDestination ? "Настройки — WealthLedger" : "Счета — WealthLedger");
});
it("returns after login to cards with the selected month without T-Bank", async () => { it("returns after login to cards with the selected month without T-Bank", async () => {
server.use( server.use(
http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])), http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])),
+13 -9
View File
@@ -2,8 +2,8 @@ import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks"; import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMatch } from "react-router";
import { useCurrentRoute } from "@/app/model";
import { useBankCard } from "@/entities/bank-card"; import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond"; import { useBond } from "@/entities/bond";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
@@ -82,14 +82,18 @@ const CardDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
const ProtectedDocumentTitle: FC = () => { const ProtectedDocumentTitle: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const account = useMatch("/accounts/:accountId/:section?");
const bond = useMatch("/bonds/:bondId"); const {
const stock = useMatch("/stocks/:stockId"); account,
const card = useMatch("/cards/:cardId"); bond,
const cards = useMatch("/cards"); bonds,
const bonds = useMatch("/bonds"); card,
const stocks = useMatch("/stocks"); cards,
const settings = useMatch("/settings"); settings,
stock,
stocks,
} = useCurrentRoute();
if (card) return <CardDocumentTitle identifier={card.params.cardId ?? ""} />; if (card) return <CardDocumentTitle identifier={card.params.cardId ?? ""} />;
if (account) if (account)
return ( return (
+5 -8
View File
@@ -1,6 +1,7 @@
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { Container, Group, Skeleton, Stack } from "@mantine/core"; import { Container, Group, Skeleton, Stack } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { Suspense } from "react"; import { Suspense } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -19,11 +20,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
if (variant === "auth") if (variant === "auth")
return ( return (
<Container py="xl" size={420}> <Container py="xl" size={420}>
<Stack <LoadingSkeleton label={t("states.loadingTitle")}>
aria-busy="true"
aria-label={t("states.loadingTitle")}
role="status"
>
<Skeleton height={32} width="45%" /> <Skeleton height={32} width="45%" />
<Skeleton height={48} radius="sm" /> <Skeleton height={48} radius="sm" />
@@ -31,7 +28,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
<Skeleton height={48} radius="sm" /> <Skeleton height={48} radius="sm" />
<Skeleton height={36} radius="sm" /> <Skeleton height={36} radius="sm" />
</Stack> </LoadingSkeleton>
</Container> </Container>
); );
@@ -62,11 +59,11 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
); );
return ( return (
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status"> <LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="30%" /> <Skeleton height={32} width="30%" />
<Skeleton height={160} radius="md" /> <Skeleton height={160} radius="md" />
</Stack> </LoadingSkeleton>
); );
}; };
+9 -4
View File
@@ -1,5 +1,4 @@
import type { FC, PropsWithChildren } from "react"; import { type FC, type PropsWithChildren, useState } from "react";
import { Navigate, useLocation } from "react-router"; import { Navigate, useLocation } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
@@ -8,16 +7,22 @@ import { RouteSkeleton } from "./RouteFallback";
const Protected: FC<PropsWithChildren> = (props) => { const Protected: FC<PropsWithChildren> = (props) => {
const { children } = props; const { children } = props;
const { ready, user } = useAuth(); const { ready, signedOut, user } = useAuth();
const location = useLocation(); const location = useLocation();
const [wasAuthenticated, setWasAuthenticated] = useState(false);
if (user && !wasAuthenticated) setWasAuthenticated(true);
if (!ready) return <RouteSkeleton variant="shell" />; if (!ready) return <RouteSkeleton variant="shell" />;
const loginState = signedOut && wasAuthenticated
? undefined
: { returnTo: `${location.pathname}${location.search}` };
return user return user
? <>{children}</> ? <>{children}</>
: ( : (
<Navigate <Navigate
replace replace
state={{ returnTo: `${location.pathname}${location.search}` }} state={loginState}
to="/login" to="/login"
/> />
); );
+12 -13
View File
@@ -2,7 +2,8 @@ import type { FC } from "react";
import { Stack } from "@mantine/core"; import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMatch } from "react-router";
import { useCurrentRoute } from "@/app/model";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch"; import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
@@ -14,19 +15,17 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => { const AppBreadcrumbs: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const account = useMatch("/accounts/:accountId/:section?"); const {
account,
accounts,
bond,
card,
cards,
section,
settings,
stock,
} = useCurrentRoute();
const section = (["events", "income", "operations"] as const).find(
value => value === account?.params.section?.toLowerCase(),
);
const card = useMatch("/cards/:cardId");
const cards = useMatch("/cards");
const accounts = useMatch({ end: true, path: "/accounts" });
const settings = useMatch({ end: true, path: "/settings" });
const stock = useMatch({ end: true, path: "/stocks/:stockId" });
const bond = useMatch({ end: true, path: "/bonds/:bondId" });
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />; if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
if (stock) if (stock)
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />; return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
+22 -18
View File
@@ -6,6 +6,8 @@ import { apiClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server"; import { server } from "@/test/server";
import type { BankCard, CreateBankOperation } from "./types";
import { import {
useCreateBankOperation, useCreateBankOperation,
useDeleteBankOperation, useDeleteBankOperation,
@@ -14,8 +16,6 @@ import {
useUpdateExpenseCategory, useUpdateExpenseCategory,
} from "./mutations"; } from "./mutations";
import { import {
type BankCard,
type CreateBankOperation,
useBankCard, useBankCard,
useBankCards, useBankCards,
useBankCategories, useBankCategories,
@@ -106,14 +106,14 @@ function useReadModels() {
const categories = useBankCategories(); const categories = useBankCategories();
const history = useBankOperations("a", { const history = useBankOperations("a", {
limit: 20, from: "2026-09-01",
month: "2026-09", limit: 20, page: 2,
page: 2, to: "2026-09-30",
}); });
const expenses = useBankExpenses("a", { page: 1 }); const expenses = useBankExpenses("a", { page: 1 });
const september = useBankSummary("2026-09"); const september = useBankSummary({ from: "2026-09-01", to: "2026-09-30" });
const august = useBankSummary("2026-08", "b"); const august = useBankSummary({ from: "2026-08-01", to: "2026-08-31" }, "b");
const original = useBankOperation("original"); const original = useBankOperation("original");
return { return {
@@ -144,7 +144,8 @@ describe("banking API", () => {
const page = Number(url.searchParams.get("page")); const page = Number(url.searchParams.get("page"));
pages.push(page); pages.push(page);
expect(url.searchParams.get("limit")).toBe("100"); expect(url.searchParams.get("limit")).toBe("100");
expect(url.searchParams.get("month")).toBe("2026-09"); expect(url.searchParams.get("from")).toBe("2026-09-01");
expect(url.searchParams.get("to")).toBe("2026-09-30");
if (isFail && page === 2) if (isFail && page === 2)
return HttpResponse.json({ code: "FAILED" }, { status: 500 }); return HttpResponse.json({ code: "FAILED" }, { status: 500 });
@@ -158,7 +159,7 @@ describe("banking API", () => {
); );
const { result } = renderHook( const { result } = renderHook(
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), () => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
@@ -171,7 +172,7 @@ describe("banking API", () => {
isFail = true; isFail = true;
const { result: failedResult } = renderHook( const { result: failedResult } = renderHook(
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), () => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }),
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
@@ -179,7 +180,7 @@ describe("banking API", () => {
expect(failedResult.current.data).toBeUndefined(); expect(failedResult.current.data).toBeUndefined();
}); });
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => { it("uses session-aware banking endpoints and explicit period/page without T-Bank", async () => {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
server.use(...readHandlers(counts)); server.use(...readHandlers(counts));
@@ -191,12 +192,15 @@ describe("banking API", () => {
expect(result.current.reads.every(read => read.isSuccess)).toBe(true), expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
); );
expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true"); expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true");
expect(counts.keys()).toContain( const urls = Array.from(counts.keys(), path => new URL(path, "http://localhost"));
"/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2", const history = urls.find(url => url.pathname === "/api/v1/bank-cards/a/operations");
); const summary = urls.find(url => url.pathname === "/api/v1/bank-cards/summary" && url.searchParams.get("cardId") === "b");
expect(counts.keys()).toContain( expect(Object.fromEntries(history?.searchParams ?? [])).toEqual({
"/api/v1/bank-cards/summary?cardId=b&month=2026-08", from: "2026-09-01", limit: "20", page: "2", to: "2026-09-30",
); });
expect(Object.fromEntries(summary?.searchParams ?? [])).toEqual({
cardId: "b", from: "2026-08-01", to: "2026-08-31",
});
expect(counts.size).toBe(9); expect(counts.size).toBe(9);
}); });
@@ -303,7 +307,7 @@ describe("banking API", () => {
expect(result.current.query.data?.balance.value).toBe("100"); expect(result.current.query.data?.balance.value).toBe("100");
}); });
it("refetches both cards, every month, categories, journal and source expenses after a transfer without invalidating brokerage", async () => { it("refetches both cards, every period, categories, journal and source expenses after a transfer without invalidating brokerage", async () => {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
server.use( server.use(
...readHandlers(counts), ...readHandlers(counts),
+1 -1
View File
@@ -14,7 +14,7 @@ import type {
UpdateBankCard, UpdateBankCard,
UpdateBankOperation, UpdateBankOperation,
UpdateExpenseCategory, UpdateExpenseCategory,
} from "./queries"; } from "./types";
async function mutationData<T>( async function mutationData<T>(
request: Promise<{ data?: T; error?: unknown }>, request: Promise<{ data?: T; error?: unknown }>,
+5 -56
View File
@@ -1,44 +1,12 @@
import { import {
apiClient, apiClient,
apiQueryClient, apiQueryClient,
type paths,
useAuthenticatedQuery, useAuthenticatedQuery,
} from "@/shared/api"; } from "@/shared/api";
import { moneyCents } from "../model/form-validation"; import type { BankOperation, BankOperations, BankSummary } from "./types";
type BankCard import { moneyCents } from "../model/money";
= paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperations
= paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
type BankSummary
= paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
type CreateBankCard
= paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
type CreateBankOperation
= paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
type CreateExpenseCategory
= paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
type ExpenseCategory
= paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
type UpdateBankCard
= paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateBankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateExpenseCategory
= paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
const OPERATIONS_PAGE_SIZE = 100; const OPERATIONS_PAGE_SIZE = 100;
@@ -110,7 +78,6 @@ function useBankOperations(
query: { query: {
from?: string; from?: string;
limit?: "all" | number; limit?: "all" | number;
month?: string;
page: number; page: number;
to?: string; to?: string;
}, },
@@ -134,7 +101,7 @@ function useBankOperations(
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity. // eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
return useAuthenticatedQuery({ return useAuthenticatedQuery({
enabled: Boolean(id && (query.month || (query.from && query.to))), enabled: Boolean(id && query.from && query.to),
placeholderData: ( placeholderData: (
previousData: BankOperations | undefined, previousData: BankOperations | undefined,
previousQuery, previousQuery,
@@ -144,7 +111,6 @@ function useBankOperations(
return !isAll return !isAll
&& previousQuery?.queryKey.length === options.queryKey.length && previousQuery?.queryKey.length === options.queryKey.length
&& previous?.path.id === id && previous?.path.id === id
&& previous?.query?.month === query.month
&& previous?.query?.from === query.from && previous?.query?.from === query.from
&& previous?.query?.to === query.to && previous?.query?.to === query.to
&& previous?.query?.limit === query.limit && previous?.query?.limit === query.limit
@@ -179,36 +145,19 @@ function useBankOperations(
} }
function useBankSummary( function useBankSummary(
period: string period: { from: string; to: string },
| { from: string; to: string },
cardId?: string, cardId?: string,
) { ) {
const query
= typeof period === "string"
? { cardId, month: period }
: { ...period, cardId };
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", { ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
params: { query }, params: { query: { ...period, cardId } },
}), }),
enabled: Boolean(period),
select: sortBankSummary, select: sortBankSummary,
}); });
} }
export { export {
type BankCard,
type BankOperation,
type BankSummary,
type CreateBankCard,
type CreateBankOperation,
type CreateExpenseCategory,
type ExpenseCategory,
sortBankSummary, sortBankSummary,
type UpdateBankCard,
type UpdateBankOperation,
type UpdateExpenseCategory,
useBankCard, useBankCard,
useBankCards, useBankCards,
useBankCategories, useBankCategories,
+3 -1
View File
@@ -1,6 +1,8 @@
import { expect, it } from "vitest"; import { expect, it } from "vitest";
import { type BankSummary, sortBankSummary } from "./queries"; import type { BankSummary } from "./types";
import { sortBankSummary } from "./queries";
const money = (value: string) => ({ currency: "RUB", value }); const money = (value: string) => ({ currency: "RUB", value });
+48
View File
@@ -0,0 +1,48 @@
import type { paths } from "@/shared/api";
type BankCard
= paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperations
= paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
type BankSummary
= paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
type CreateBankCard
= paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
type CreateBankOperation
= paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
type CreateExpenseCategory
= paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
type ExpenseCategory
= paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
type UpdateBankCard
= paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateBankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateExpenseCategory
= paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
export type {
BankCard,
BankOperation,
BankOperations,
BankSummary,
CreateBankCard,
CreateBankOperation,
CreateExpenseCategory,
ExpenseCategory,
UpdateBankCard,
UpdateBankOperation,
UpdateExpenseCategory,
};
+4 -4
View File
@@ -5,8 +5,6 @@ export {
useUpdateBankOperation, useUpdateBankOperation,
} from "./api/mutations"; } from "./api/mutations";
export { export {
type BankCard,
type BankOperation,
useBankCard, useBankCard,
useBankCards, useBankCards,
useBankCategories, useBankCategories,
@@ -15,14 +13,16 @@ export {
useBankOperations, useBankOperations,
useBankSummary, useBankSummary,
} from "./api/queries"; } from "./api/queries";
export { bankDateInputValue, bankMoneyValue, bankToday, moneyCents } from "./model/form-validation"; export type { BankCard, BankOperation } from "./api/types";
export { bankToday } from "./model/form-validation";
export { bankDateSchema, positiveMoneySchema } from "./model/form.schema"; export { bankDateSchema, positiveMoneySchema } from "./model/form.schema";
export { bankMoneyValue, moneyCents } from "./model/money";
export { bankOperationAppearance } from "./model/operation-appearance"; export { bankOperationAppearance } from "./model/operation-appearance";
export { bankOperationKinds } from "./model/operation-kinds"; export { bankOperationKinds } from "./model/operation-kinds";
export { bankPeriod } from "./model/period"; export { bankPeriod } from "./model/period";
export { BankCardFace } from "./ui/BankCardFace/BankCardFace"; export { BankCardFace } from "./ui/BankCardFace/BankCardFace";
export { BankCardForm } from "./ui/BankCardForm/BankCardForm"; export { BankCardForm } from "./ui/BankCardForm/BankCardForm";
export { bankDateField } from "./ui/bankDateField";
export { BankFeedback } from "./ui/BankFeedback"; export { BankFeedback } from "./ui/BankFeedback";
export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker"; export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker";
export { BankSummaryView } from "./ui/BankSummary/BankSummary.view"; export { BankSummaryView } from "./ui/BankSummary/BankSummary.view";
@@ -2,7 +2,7 @@ import { z } from "zod";
import { validationMessage } from "@/shared/i18n"; import { validationMessage } from "@/shared/i18n";
import type { ExpenseCategory } from "../api/queries"; import type { ExpenseCategory } from "../api/types";
const MAX_CATEGORY_NAME_LENGTH = 80; const MAX_CATEGORY_NAME_LENGTH = 80;
function categorySchema(categories?: ExpenseCategory[], editingId?: string) { function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
@@ -1,7 +1,12 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { bankDateInputValue, bankMoneyValue, bankMonth, bankToday } from "./form-validation"; import { bankDateInputValue, bankMonth, bankToday } from "./form-validation";
import { bankCardSchema, bankDateSchema, positiveMoneySchema } from "./form.schema"; import {
bankCardSchema,
bankDateSchema,
positiveMoneySchema,
} from "./form.schema";
import { bankMoneyValue } from "./money";
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
describe("bank form input", () => { describe("bank form input", () => {
@@ -1,21 +1,11 @@
import { moscowDate } from "@/shared/lib"; import { moscowDate } from "@/shared/lib";
const DECIMAL_PART_COUNT = 2;
const CENTS_PRECISION = 2;
const ISO_MONTH_LENGTH = 7; const ISO_MONTH_LENGTH = 7;
function bankDateInputValue(value: string) { function bankDateInputValue(value: string) {
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1"); return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
} }
function bankMoneyValue(value: string) {
return value
.trim()
.replace(",", ".")
.replace(/^(-?)0+(?=\d)/, "$1");
}
function bankMonth(value: null | string) { function bankMonth(value: null | string) {
return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value) return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value)
? value ? value
@@ -26,19 +16,4 @@ function bankToday() {
return moscowDate(new Date()); return moscowDate(new Date());
} }
function moneyCents(value: string): bigint | null { export { bankDateInputValue, bankMonth, bankToday };
const normalized = value.trim().replace(",", ".");
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
return null;
const [whole, fraction = ""] = normalized
.replace("-", "")
.split(".", DECIMAL_PART_COUNT);
return (
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
* (normalized.startsWith("-") ? -1n : 1n)
);
}
export { bankDateInputValue, bankMoneyValue, bankMonth, bankToday, moneyCents };
+2 -1
View File
@@ -3,7 +3,8 @@ import { z } from "zod";
import { validationMessage } from "@/shared/i18n"; import { validationMessage } from "@/shared/i18n";
import { calendarDate } from "@/shared/lib"; import { calendarDate } from "@/shared/lib";
import { bankToday, moneyCents } from "./form-validation"; import { bankToday } from "./form-validation";
import { moneyCents } from "./money";
const MAX_CARD_NAME_LENGTH = 100; const MAX_CARD_NAME_LENGTH = 100;
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
+26
View File
@@ -0,0 +1,26 @@
const DECIMAL_PART_COUNT = 2;
const CENTS_PRECISION = 2;
function bankMoneyValue(value: string) {
return value
.trim()
.replace(",", ".")
.replace(/^(-?)0+(?=\d)/, "$1");
}
function moneyCents(value: string): bigint | null {
const normalized = value.trim().replace(",", ".");
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
return null;
const [whole, fraction = ""] = normalized
.replace("-", "")
.split(".", DECIMAL_PART_COUNT);
return (
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
* (normalized.startsWith("-") ? -1n : 1n)
);
}
export { bankMoneyValue, moneyCents };
@@ -1,6 +1,6 @@
import type { OperationIconProps } from "@wealthledger/icons"; import type { OperationIconProps } from "@wealthledger/icons";
import type { BankOperation } from "../api/queries"; import type { BankOperation } from "../api/types";
const categoryIcons: { const categoryIcons: {
appearance: OperationIconProps; appearance: OperationIconProps;
@@ -1,4 +1,4 @@
import type { BankOperation } from "../api/queries"; import type { BankOperation } from "../api/types";
const bankOperationKinds = [ const bankOperationKinds = [
"expense", "expense",
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import type { BankCard } from "../../api/queries"; import type { BankCard } from "../../api/types";
import classes from "./BankCardFace.module.css"; import classes from "./BankCardFace.module.css";
@@ -1,10 +1,9 @@
import { useFormContext } from "react-hook-form"; import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { BankCardValues } from "../../model/form.schema"; import type { BankCardValues } from "../../model/form.schema";
import { useOpeningDateField } from "./useOpeningDateField"; import { bankDateField } from "../bankDateField";
import { usePaymentSystemField } from "./usePaymentSystemField";
function useBankCardFieldsVm() { function useBankCardFieldsVm() {
const { const {
@@ -14,10 +13,26 @@ function useBankCardFieldsVm() {
} = useFormContext<BankCardValues>(); } = useFormContext<BankCardValues>();
const { t } = useTranslation(); const { t } = useTranslation();
const openingDate = useOpeningDateField(control); const { field: openingDateField } = useController({ control, name: "openingDate" });
const paymentSystem = usePaymentSystemField(control); const { field: paymentSystemField } = useController({ control, name: "paymentSystem" });
return { ...openingDate, ...paymentSystem, errors, register, t }; const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}));
const onPaymentSystemChange = (value: null | string) =>
paymentSystemField.onChange(value ?? "");
return {
errors,
onPaymentSystemChange,
openingDate: { ...openingDateField, ...bankDateField(openingDateField) },
paymentSystemField,
paymentSystems,
register,
t,
};
} }
export { useBankCardFieldsVm }; export { useBankCardFieldsVm };
@@ -8,7 +8,7 @@ import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server"; import { server } from "@/test/server";
import type { BankCard } from "../../api/queries"; import type { BankCard } from "../../api/types";
import { BankCardForm } from "./BankCardForm"; import { BankCardForm } from "./BankCardForm";
@@ -4,7 +4,8 @@ import { FormProvider } from "react-hook-form";
import { formatDate } from "@/shared/lib"; import { formatDate } from "@/shared/lib";
import { type BankCard } from "../../api/queries"; import type { BankCard } from "../../api/types";
import { BankFeedback } from "../BankFeedback"; import { BankFeedback } from "../BankFeedback";
import { BankCardFields } from "./BankCardForm.view"; import { BankCardFields } from "./BankCardForm.view";
import { useBankCardFormVm } from "./BankCardForm.vm"; import { useBankCardFormVm } from "./BankCardForm.vm";
@@ -10,14 +10,9 @@ type BankCardFieldsProps = { archived: boolean };
const BankCardFields: FC<BankCardFieldsProps> = (props) => { const BankCardFields: FC<BankCardFieldsProps> = (props) => {
const { const {
dateParser,
errors, errors,
onOpeningDateBlur,
onOpeningDateChange,
onOpeningDateInput,
onPaymentSystemChange, onPaymentSystemChange,
openingDateField, openingDate,
openingDateValue,
paymentSystemField, paymentSystemField,
paymentSystems, paymentSystems,
register, register,
@@ -71,22 +66,17 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
/> />
<DateInput <DateInput
{...openingDateField} {...openingDate}
dateParser={dateParser}
error={errors.openingDate?.message} error={errors.openingDate?.message}
fixOnBlur={false} fixOnBlur={false}
label={t("bank.openingDate")} label={t("bank.openingDate")}
maxDate={bankToday()} maxDate={bankToday()}
onBlur={onOpeningDateBlur}
onChange={onOpeningDateChange}
onInput={onOpeningDateInput}
popoverProps={{ popoverProps={{
middlewares: { shift: { crossAxis: true } }, middlewares: { shift: { crossAxis: true } },
withinPortal: false, withinPortal: false,
}} }}
readOnly={props.archived} readOnly={props.archived}
required required
value={openingDateValue}
valueFormat="DD.MM.YYYY" valueFormat="DD.MM.YYYY"
/> />
@@ -3,10 +3,13 @@ import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { BankCard } from "../../api/types";
import { useCreateBankCard, useUpdateBankCard } from "../../api/mutations"; import { useCreateBankCard, useUpdateBankCard } from "../../api/mutations";
import { type BankCard, useBankCard } from "../../api/queries"; import { useBankCard } from "../../api/queries";
import { bankMoneyValue, bankToday } from "../../model/form-validation"; import { bankToday } from "../../model/form-validation";
import { bankCardSchema, type BankCardValues } from "../../model/form.schema"; import { bankCardSchema, type BankCardValues } from "../../model/form.schema";
import { bankMoneyValue } from "../../model/money";
import { useBankFormFocus } from "../useBankFormFocus"; import { useBankFormFocus } from "../useBankFormFocus";
type BankCardFormProps = { card?: BankCard; onClose: () => void }; type BankCardFormProps = { card?: BankCard; onClose: () => void };
@@ -1,49 +0,0 @@
import type { DateInput } from "@mantine/dates";
import type { ComponentProps } from "react";
import type { Control } from "react-hook-form";
import { useController } from "react-hook-form";
import { bankDateInputValue } from "../../model/form-validation";
import { type BankCardValues, bankDateSchema } from "../../model/form.schema";
function useOpeningDateField(control: Control<BankCardValues>) {
const { field: openingDateField } = useController({
control,
name: "openingDate",
});
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value)
: null;
const onOpeningDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (
event,
) => {
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
openingDateField.onBlur();
};
const onOpeningDateChange = (value: null | string) =>
openingDateField.onChange(value ?? "");
const onOpeningDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
const openingDateValue = bankDateSchema.safeParse(openingDateField.value)
.success
? openingDateField.value
: undefined;
return {
dateParser,
onOpeningDateBlur,
onOpeningDateChange,
onOpeningDateInput,
openingDateField,
openingDateValue,
};
}
export { useOpeningDateField };
@@ -1,27 +0,0 @@
import type { Control } from "react-hook-form";
import { useController } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { BankCardValues } from "../../model/form.schema";
function usePaymentSystemField(control: Control<BankCardValues>) {
const { t } = useTranslation();
const { field: paymentSystemField } = useController({
control,
name: "paymentSystem",
});
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}));
const onPaymentSystemChange = (value: null | string) =>
paymentSystemField.onChange(value ?? "");
return { onPaymentSystemChange, paymentSystemField, paymentSystems };
}
export { usePaymentSystemField };
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import type { BankSummary } from "../../api/queries"; import type { BankSummary } from "../../api/types";
import { bankOperationAppearance } from "../../model/operation-appearance"; import { bankOperationAppearance } from "../../model/operation-appearance";
import classes from "./BankSummary.module.css"; import classes from "./BankSummary.module.css";
@@ -5,11 +5,13 @@ import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ExpenseCategory } from "../../api/types";
import { import {
useCreateExpenseCategory, useCreateExpenseCategory,
useUpdateExpenseCategory, useUpdateExpenseCategory,
} from "../../api/mutations"; } from "../../api/mutations";
import { type ExpenseCategory, useBankCategories } from "../../api/queries"; import { useBankCategories } from "../../api/queries";
import { categorySchema } from "../../model/category.schema"; import { categorySchema } from "../../model/category.schema";
import { useBankFormFocus } from "../useBankFormFocus"; import { useBankFormFocus } from "../useBankFormFocus";
@@ -0,0 +1,39 @@
import type { DateInput } from "@mantine/dates";
import type { ComponentProps } from "react";
import { bankDateInputValue } from "../model/form-validation";
import { bankDateSchema } from "../model/form.schema";
type DateField = {
onBlur: () => void;
onChange: (value: string) => void;
value: string;
};
function bankDateField(field: DateField) {
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = (value) => {
const parsed = bankDateInputValue(value);
return bankDateSchema.safeParse(parsed).success ? parsed : null;
};
const onBlur: ComponentProps<typeof DateInput>["onBlur"] = (event) => {
field.onChange(bankDateInputValue(event.currentTarget.value));
field.onBlur();
};
const onInput: ComponentProps<typeof DateInput>["onInput"] = event =>
field.onChange(bankDateInputValue(event.currentTarget.value));
return {
dateParser,
onBlur,
onChange: (value: null | string) => field.onChange(value ?? ""),
onInput,
value: bankDateSchema.safeParse(field.value).success
? field.value
: undefined,
};
}
export { bankDateField };
+6
View File
@@ -1,5 +1,9 @@
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
type BondCandles = NonNullable<paths["/api/v1/bonds/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type BondPosition = NonNullable<paths["/api/v1/bonds/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const MIN_BOND_SEARCH_LENGTH = 2; const MIN_BOND_SEARCH_LENGTH = 2;
type BondCoupons = NonNullable<paths["/api/v1/bonds/{uid}/coupons"]["get"]["responses"][200]["content"]["application/json"]["data"]>; type BondCoupons = NonNullable<paths["/api/v1/bonds/{uid}/coupons"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
@@ -101,8 +105,10 @@ function useBondSearch(query: string) {
} }
export { export {
type BondCandles,
type BondCoupons, type BondCoupons,
type BondMetrics, type BondMetrics,
type BondPosition,
MIN_BOND_SEARCH_LENGTH, MIN_BOND_SEARCH_LENGTH,
useBond, useBond,
useBondCandles, useBondCandles,
+2
View File
@@ -1,6 +1,8 @@
export { export {
type BondCandles,
type BondCoupons, type BondCoupons,
type BondMetrics, type BondMetrics,
type BondPosition,
MIN_BOND_SEARCH_LENGTH, MIN_BOND_SEARCH_LENGTH,
useBond, useBond,
useBondCandles, useBondCandles,
@@ -19,6 +19,7 @@ import { operationLabelKey, paymentPresentation } from "../model/display";
type LedgerEntryRowProps = { type LedgerEntryRowProps = {
operation: BrokerageOperation; operation: BrokerageOperation;
withDetails?: boolean; withDetails?: boolean;
wrap?: boolean;
}; };
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => { const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
@@ -84,6 +85,7 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
tone={payment.tone} tone={payment.tone}
value={payment.amount} value={payment.amount}
valueLabel={t(payment.label)} valueLabel={t(payment.label)}
wrap={props.wrap}
/> />
); );
}; };
+1
View File
@@ -2,6 +2,7 @@ export {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
} from "./api/useBrokerageEvents"; } from "./api/useBrokerageEvents";
export { brokerageEventKey } from "./model/event-key";
export { BrokerageEventCard } from "./ui/BrokerageEventCard"; export { BrokerageEventCard } from "./ui/BrokerageEventCard";
export { export {
BrokerageEventsPreview, BrokerageEventsPreview,
@@ -0,0 +1,10 @@
import type { BrokerageEvent } from "../api/useBrokerageEvents";
function brokerageEventKey(event: BrokerageEvent) {
return JSON.stringify([
event.account?.id, event.type, event.date, event.instrumentUid,
event.figi, event.ticker, event.logoName,
]);
}
export { brokerageEventKey };
@@ -9,6 +9,7 @@ import { formatSnapshotTime } from "@/shared/lib";
import type { BrokerageEvent } from "../../api/useBrokerageEvents"; import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { brokerageEventKey } from "../../model/event-key";
import { BrokerageEventCard } from "../BrokerageEventCard"; import { BrokerageEventCard } from "../BrokerageEventCard";
type BrokerageEventsPreviewProps = { type BrokerageEventsPreviewProps = {
@@ -66,15 +67,7 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
&& events.items.map(event => ( && events.items.map(event => (
<BrokerageEventCard <BrokerageEventCard
event={event} event={event}
key={JSON.stringify([ key={brokerageEventKey(event)}
event.account?.id,
event.type,
event.date,
event.instrumentUid,
event.figi,
event.ticker,
event.logoName,
])}
/> />
))} ))}
+6
View File
@@ -4,6 +4,10 @@ import {
useResourceQuery, useResourceQuery,
} from "@/shared/api"; } from "@/shared/api";
type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const MIN_STOCK_SEARCH_LENGTH = 2; const MIN_STOCK_SEARCH_LENGTH = 2;
type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>; type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
@@ -104,7 +108,9 @@ function useStockSearch(query: string) {
export { export {
MIN_STOCK_SEARCH_LENGTH, MIN_STOCK_SEARCH_LENGTH,
type StockCandles,
type StockDividends, type StockDividends,
type StockPosition,
useStock, useStock,
useStockCandles, useStockCandles,
useStockCompany, useStockCompany,
+2
View File
@@ -1,6 +1,8 @@
export { export {
MIN_STOCK_SEARCH_LENGTH, MIN_STOCK_SEARCH_LENGTH,
type StockCandles,
type StockDividends, type StockDividends,
type StockPosition,
useStock, useStock,
useStockCandles, useStockCandles,
useStockCompany, useStockCompany,
+1
View File
@@ -7,6 +7,7 @@ type AuthContextValue = {
logout: () => Promise<void>; logout: () => Promise<void>;
ready: boolean; ready: boolean;
register: (email: string, password: string) => Promise<void>; register: (email: string, password: string) => Promise<void>;
signedOut: boolean;
user: undefined | User; user: undefined | User;
}; };
+4
View File
@@ -31,6 +31,7 @@ function useAuthProviderVm() {
const [user, setUser] = useState<undefined | User>(undefined); const [user, setUser] = useState<undefined | User>(undefined);
const accessTokenRef = useRef<string | undefined>(undefined); const accessTokenRef = useRef<string | undefined>(undefined);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [signedOut, setSignedOut] = useState(false);
const generationRef = useRef(0); const generationRef = useRef(0);
const userRef = useRef<undefined | User>(undefined); const userRef = useRef<undefined | User>(undefined);
@@ -57,6 +58,7 @@ function useAuthProviderVm() {
const changedIdentity const changedIdentity
= userRef.current && userRef.current.id !== authentication.user.id; = userRef.current && userRef.current.id !== authentication.user.id;
setSignedOut(false);
accessTokenRef.current = authentication.accessToken; accessTokenRef.current = authentication.accessToken;
if (changedIdentity) { if (changedIdentity) {
@@ -136,6 +138,7 @@ function useAuthProviderVm() {
} }
async function logout() { async function logout() {
setSignedOut(true);
clearSession(); clearSession();
await serialize(async () => { await serialize(async () => {
await apiClient.POST("/api/v1/auth/logout"); await apiClient.POST("/api/v1/auth/logout");
@@ -159,6 +162,7 @@ function useAuthProviderVm() {
ready, ready,
register: (email: string, password: string) => register: (email: string, password: string) =>
authenticate("/api/v1/auth/register", email, password), authenticate("/api/v1/auth/register", email, password),
signedOut,
user, user,
}; };
+7 -2
View File
@@ -1,10 +1,15 @@
export { section, type SectionState } from "./model/section"; export { section, type SectionState } from "./model/section";
export { useInstrumentParameters } from "./model/useInstrumentParameters"; export { useInstrumentParameters } from "./model/useInstrumentParameters";
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view"; export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view";
export { default as instrumentDetailsStyles } from "./ui/InstrumentDetails.module.css";
export { InstrumentEvents } from "./ui/InstrumentEvents.view"; export { InstrumentEvents } from "./ui/InstrumentEvents.view";
export { InstrumentHeader } from "./ui/InstrumentHeader/InstrumentHeader.view"; export { InstrumentHeader } from "./ui/InstrumentHeader/InstrumentHeader.view";
export { InstrumentMetric } from "./ui/InstrumentMetric.view"; export {
InstrumentMetrics,
InstrumentPage,
InstrumentPrice,
InstrumentTerms,
} from "./ui/InstrumentLayout.view";
export { InstrumentMetric } from "./ui/InstrumentMetric/InstrumentMetric.view";
export { InstrumentPageState } from "./ui/InstrumentPageState.view"; export { InstrumentPageState } from "./ui/InstrumentPageState.view";
export { InstrumentPosition } from "./ui/InstrumentPosition.view"; export { InstrumentPosition } from "./ui/InstrumentPosition.view";
export { InstrumentQuoteStatus } from "./ui/InstrumentQuoteStatus.view"; export { InstrumentQuoteStatus } from "./ui/InstrumentQuoteStatus.view";
@@ -1,10 +1,13 @@
import type { BondCandles, BondPosition } from "@/entities/bond";
import type { BrokerageOperation } from "@/entities/broker-operation"; import type { BrokerageOperation } from "@/entities/broker-operation";
import type { paths } from "@/shared/api"; import type { StockCandles, StockPosition } from "@/entities/stock";
import type { SectionState } from "./section"; import type { SectionState } from "./section";
type Candles = NonNullable< type Candles = Pick<BondCandles | StockCandles,
paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"] "candles"
| "currency"
| "historyFrom"
>; >;
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm }; type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
@@ -33,8 +36,6 @@ type InstrumentPositionVm = {
positionState: SectionState; positionState: SectionState;
}; };
type Position = NonNullable< type Position = BondPosition | StockPosition;
paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
export type { InstrumentChartProps, InstrumentEventsVm, InstrumentPositionVm }; export type { InstrumentChartProps, InstrumentEventsVm, InstrumentPositionVm };
@@ -75,15 +75,14 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.metricsGrid { .metricsGrid {
--instrument-metric-padding: var(--mantine-spacing-sm);
--instrument-metric-background: var(--wl-color-subtle);
--instrument-metric-radius: var(--mantine-radius-md);
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
gap: var(--mantine-spacing-lg); gap: var(--mantine-spacing-lg);
} }
.metricsGrid > div {
padding: var(--mantine-spacing-sm);
background: var(--wl-color-subtle);
border-radius: var(--mantine-radius-md);
}
@media (max-width: 48em) { @media (max-width: 48em) {
.metricsGrid { .metricsGrid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -0,0 +1,31 @@
import type { FC, PropsWithChildren } from "react";
import { Stack, Text, type TextProps, Title } from "@mantine/core";
import classes from "./InstrumentDetails.module.css";
type InstrumentPriceProps = PropsWithChildren<Pick<TextProps, "mt">>;
type InstrumentTermsProps = PropsWithChildren<{ title: string }>;
const InstrumentPage: FC<PropsWithChildren> = props => (
<Stack className={classes.page} gap="lg">{props.children}</Stack>
);
const InstrumentPrice: FC<InstrumentPriceProps> = props => (
<Text className={classes.price} mt={props.mt}>{props.children}</Text>
);
const InstrumentMetrics: FC<PropsWithChildren> = props => (
<div className={classes.metricsGrid}>{props.children}</div>
);
const InstrumentTerms: FC<InstrumentTermsProps> = props => (
<div className={classes.embeddedSection}>
<Title mb="md" order={2} size="h3">{props.title}</Title>
{props.children}
</div>
);
export { InstrumentMetrics, InstrumentPage, InstrumentPrice, InstrumentTerms };
@@ -0,0 +1,5 @@
.metric {
padding: var(--instrument-metric-padding, 0);
background: var(--instrument-metric-background, transparent);
border-radius: var(--instrument-metric-radius, 0);
}
@@ -2,10 +2,12 @@ import type { FC } from "react";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
import classes from "./InstrumentMetric.module.css";
type InstrumentMetricProps = { label: string; value: string }; type InstrumentMetricProps = { label: string; value: string };
const InstrumentMetric: FC<InstrumentMetricProps> = props => ( const InstrumentMetric: FC<InstrumentMetricProps> = props => (
<div> <div className={classes.metric}>
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{props.label} {props.label}
</Text> </Text>
+4
View File
@@ -1,2 +1,6 @@
export { useScreenSync } from "./model/screen-sync"; export { useScreenSync } from "./model/screen-sync";
export {
InvestmentRefreshState,
type InvestmentRefreshStateProps,
} from "./ui/InvestmentRefreshState/InvestmentRefreshState.view";
export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection"; export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection";
@@ -9,7 +9,14 @@ import {
type TbankConnectionStatus type TbankConnectionStatus
= paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; = paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
const brokerageAccountsPath = "/api/v1/brokerage-accounts"; function isInvestmentPath(path: string) {
return [
"/api/v1/brokerage-accounts",
"/api/v1/stocks",
"/api/v1/bonds",
"/api/v1/tbank-sync",
].some(root => path === root || path.startsWith(`${root}/`));
}
const tbankConnectionQueryOptions = () => const tbankConnectionQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"); apiQueryClient.queryOptions("get", "/api/v1/tbank-connection");
@@ -20,13 +27,7 @@ function commitTbankConnectionBoundary(
queryClient: QueryClient, queryClient: QueryClient,
status: TbankConnectionStatus, status: TbankConnectionStatus,
) { ) {
invalidateApiRequests( invalidateApiRequests(isInvestmentPath);
path =>
path.startsWith("/api/v1/brokerage-accounts")
|| path.startsWith("/api/v1/stocks")
|| path.startsWith("/api/v1/bonds")
|| path.startsWith("/api/v1/tbank-sync"),
);
queryClient.setQueryData(tbankConnectionQueryKey, status); queryClient.setQueryData(tbankConnectionQueryKey, status);
void queryClient.resetQueries({ void queryClient.resetQueries({
predicate: ({ queryKey }) => { predicate: ({ queryKey }) => {
@@ -35,13 +36,7 @@ function commitTbankConnectionBoundary(
return ( return (
method === "get" method === "get"
&& typeof path === "string" && typeof path === "string"
&& (path.startsWith("/api/v1/tbank-sync") && isInvestmentPath(path)
|| path === "/api/v1/bonds"
|| path.startsWith("/api/v1/bonds/")
|| path === "/api/v1/stocks"
|| path.startsWith("/api/v1/stocks/")
|| path === brokerageAccountsPath
|| path.startsWith(`${brokerageAccountsPath}/`))
); );
}, },
}); });
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Stack, Text } from "@mantine/core";
import { fn } from "storybook/test";
import { InvestmentRefreshState } from "./InvestmentRefreshState.view";
const meta = {
args: { labelScope: "portfolio", refreshError: false, refreshing: false, retry: fn() },
argTypes: {
refreshError: { control: "boolean" },
refreshing: { control: "boolean" },
retry: { control: false },
},
component: InvestmentRefreshState,
parameters: {
docs: {
description: {
component:
"Обновление уже показанных данных портфеля. Синтетические суммы остаются видимыми вместе с InvestmentRefreshState при refetch и его ошибке. Суммы синтетические; это presentation Client Cached Snapshot, без QueryClient и запроса API. Retry регистрирует локальное действие в Actions и не доказывает восстановление API.",
},
},
},
render: arguments_ => (
<Stack>
<Text>125 000,50 ₽ · 320 USD</Text>
<InvestmentRefreshState {...arguments_} />
</Stack>
),
tags: ["autodocs"],
title: "Product/InvestmentRefreshState",
} satisfies Meta<typeof InvestmentRefreshState>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Idle: Story = { name: "Сохранённые данные" };
export const Refreshing: Story = {
args: { refreshing: true },
name: "Фоновое обновление",
};
export const RefreshError: Story = {
args: { labelScope: "portfolio", refreshError: true },
name: "Ошибка обновления с retry",
};
export const SourceFailure: Story = {
args: { labelScope: "portfolio", refreshError: true, sourceError: true },
name: "Ошибка синхронизации сохраняет суммы",
};
@@ -5,7 +5,8 @@ import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib"; import { formatSnapshotTime } from "@/shared/lib";
type AccountsRefreshStateProps = { type InvestmentRefreshStateProps = {
labelScope?: "accounts" | "portfolio";
lastSuccessfulAt?: null | string; lastSuccessfulAt?: null | string;
refreshError?: boolean; refreshError?: boolean;
refreshing?: boolean; refreshing?: boolean;
@@ -13,8 +14,8 @@ type AccountsRefreshStateProps = {
sourceError?: boolean; sourceError?: boolean;
}; };
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => { const InvestmentRefreshState: FC<InvestmentRefreshStateProps> = (props) => {
const { refreshError = false, refreshing = false, retry } = props; const { labelScope = "accounts", refreshError = false, refreshing = false, retry } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -37,11 +38,11 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
</Button> </Button>
{!!refreshing && ( {!!refreshing && (
<Group aria-label={t("accounts.refreshing")} gap="xs" role="status"> <Group aria-label={t(`${labelScope}.refreshing`)} gap="xs" role="status">
<Loader size="sm" /> <Loader size="sm" />
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("accounts.refreshing")} {t(`${labelScope}.refreshing`)}
</Text> </Text>
</Group> </Group>
)} )}
@@ -53,7 +54,7 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
{t( {t(
props.sourceError props.sourceError
? "investment.syncFailed" ? "investment.syncFailed"
: "accounts.refreshError", : `${labelScope}.refreshError`,
)} )}
</Text> </Text>
</Stack> </Stack>
@@ -63,4 +64,4 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
); );
}; };
export { AccountsRefreshState, type AccountsRefreshStateProps }; export { InvestmentRefreshState, type InvestmentRefreshStateProps };
@@ -1,12 +1,12 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { useParams, useSearchParams } from "react-router"; import { useParams } from "react-router";
import { import {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { isValidPeriod } from "@/shared/lib"; import { isValidPeriod, useUrlPeriod } from "@/shared/lib";
type AccountEventsPageViewModel type AccountEventsPageViewModel
= | { = | {
@@ -29,18 +29,12 @@ type AccountEventsPageViewModel
function useAccountEventsPageViewModel(): AccountEventsPageViewModel { function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const { accountId = "" } = useParams(); const { accountId = "" } = useParams();
const [searchParameters, setSearchParameters] = useSearchParams();
const urlFrom = searchParameters.get("from");
const urlTo = searchParameters.get("to");
const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = isValidUrlPeriod const {
? urlFrom from: appliedFrom,
: null; setParameters: setSearchParameters,
to: appliedTo,
const appliedTo = isValidUrlPeriod } = useUrlPeriod();
? urlTo
: null;
const [draftPeriod, setDraftPeriod] = useState<[ const [draftPeriod, setDraftPeriod] = useState<[
null | string, null | string,
@@ -48,13 +42,6 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
]>([appliedFrom, appliedTo]); ]>([appliedFrom, appliedTo]);
const [draftFrom, draftTo] = draftPeriod; const [draftFrom, draftTo] = draftPeriod;
useEffect(() => {
if (isValidUrlPeriod) return;
const normalized = new URLSearchParams(searchParameters);
normalized.delete("from");
normalized.delete("to");
setSearchParameters(normalized, { replace: true });
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
const scope = JSON.stringify([accountId, appliedFrom, appliedTo]); const scope = JSON.stringify([accountId, appliedFrom, appliedTo]);
const [previousScope, setPreviousScope] = useState(scope); const [previousScope, setPreviousScope] = useState(scope);
@@ -12,7 +12,10 @@ import {
import { DatePickerInput } from "@mantine/dates"; import { DatePickerInput } from "@mantine/dates";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BrokerageEventCard } from "@/entities/brokerage-event"; import {
BrokerageEventCard,
brokerageEventKey,
} from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib"; import { formatSnapshotTime } from "@/shared/lib";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -33,15 +36,7 @@ const AccountEventsPageLoadedEvents: FC<AccountEventsPageLoadedProps> = (
{props.events.map(event => ( {props.events.map(event => (
<BrokerageEventCard <BrokerageEventCard
event={event} event={event}
key={JSON.stringify([ key={brokerageEventKey(event)}
event.account?.id,
event.type,
event.date,
event.instrumentUid,
event.figi,
event.ticker,
event.logoName,
])}
/> />
))} ))}
</Stack> </Stack>
@@ -22,13 +22,6 @@
min-width: 0; min-width: 0;
} }
/* Keep the reused Ledger row's full text readable inside income cards. */
.entries :global(.mantine-Text-root) {
-webkit-line-clamp: unset;
white-space: normal;
overflow-wrap: anywhere;
}
.comparison { .comparison {
container-type: inline-size; container-type: inline-size;
} }
@@ -5,13 +5,11 @@ import { useTranslation } from "react-i18next";
import { LedgerEntryRow } from "@/entities/broker-operation"; import { LedgerEntryRow } from "@/entities/broker-operation";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import type { IncomeDisplayModel } from "../../../model/display";
import classes from "../AccountIncomePage.module.css"; import classes from "../AccountIncomePage.module.css";
type IncomePaymentsProps = { entries: LoadedProps["display"]["entries"] }; type IncomePaymentsProps = { entries: IncomeDisplayModel["entries"] };
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
const IncomePayments: FC<IncomePaymentsProps> = (props) => { const IncomePayments: FC<IncomePaymentsProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -23,7 +21,7 @@ const IncomePayments: FC<IncomePaymentsProps> = (props) => {
<Stack className={classes.entries} component="ul" gap="sm"> <Stack className={classes.entries} component="ul" gap="sm">
{props.entries.map(({ key, operation }) => ( {props.entries.map(({ key, operation }) => (
<li key={key}> <li key={key}>
<LedgerEntryRow operation={operation} /> <LedgerEntryRow operation={operation} wrap />
</li> </li>
))} ))}
</Stack> </Stack>
@@ -1,6 +1,6 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useParams, useSearchParams } from "react-router"; import { useParams } from "react-router";
import { import {
type BrokerageOperation, type BrokerageOperation,
@@ -11,7 +11,7 @@ import {
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { isValidPeriod } from "@/shared/lib"; import { isValidPeriod, useUrlPeriod } from "@/shared/lib";
const operationCategorySet = new Set<string>(operationCategories); const operationCategorySet = new Set<string>(operationCategories);
@@ -44,18 +44,13 @@ type AccountOperationsPageViewModel
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const { accountId } = useParams(); const { accountId } = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchParameters, setSearchParameters] = useSearchParams();
const urlFrom = searchParameters.get("from");
const urlTo = searchParameters.get("to");
const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = isValidUrlPeriod const {
? urlFrom from: appliedFrom,
: null; parameters: searchParameters,
setParameters: setSearchParameters,
const appliedTo = isValidUrlPeriod to: appliedTo,
? urlTo } = useUrlPeriod();
: null;
const appliedCategories = operationCategories.filter(category => const appliedCategories = operationCategories.filter(category =>
(searchParameters.get("categories") ?? "").split(",").includes(category), (searchParameters.get("categories") ?? "").split(",").includes(category),
@@ -71,14 +66,6 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const [draftCategories, setDraftCategories] const [draftCategories, setDraftCategories]
= useState<OperationCategory[]>(appliedCategories); = useState<OperationCategory[]>(appliedCategories);
useEffect(() => {
if (isValidUrlPeriod) return;
const normalized = new URLSearchParams(searchParameters);
normalized.delete("from");
normalized.delete("to");
setSearchParameters(normalized, { replace: true });
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
const firstPageQuery = { const firstPageQuery = {
from: dateStart(appliedFrom), from: dateStart(appliedFrom),
to: dateEnd(appliedTo), to: dateEnd(appliedTo),
+144
View File
@@ -0,0 +1,144 @@
import type { BrokerageOperation } from "@/entities/broker-operation";
import type {
BrokerageEvent,
BrokerageEventsPreviewState,
} from "@/entities/brokerage-event";
import type { IncomeSummary } from "@/entities/income";
import type { InvestmentResourceMeta } from "@/shared/api";
const RECENT_EVENTS_LIMIT = 5;
type AccountPageIncomeSummary
= | {
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "loaded";
summary: IncomeSummary;
to: string;
}
| { retry: () => Promise<unknown> | undefined; state: "error"; to: string }
| {
state: "empty"
| "loading"
| "partial";
to: string;
};
type AccountPageRecentEvents = BrokerageEventsPreviewState;
type AccountPageRecentOperations
= | {
items: BrokerageOperation[];
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "loaded" | "partial";
to: string;
}
| {
state: "empty"
| "error"
| "loading";
to: string;
};
type PreviewResource<T> = {
data?: T;
isError: boolean;
meta?: Pick<InvestmentResourceMeta,
"availability"
| "freshness"
| "lastSuccessfulAt"
| "sync"
>;
};
function toIncomeSummary(
accountId: string | undefined,
income: PreviewResource<IncomeSummary>,
retry: () => Promise<unknown>,
): AccountPageIncomeSummary {
const to = `/accounts/${accountId}/income`;
if (!income.data && !income.isError && income.meta?.sync.state !== "failed")
return { state: "loading", to };
if (income.meta?.availability === "partial") return { state: "partial", to };
if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
return { retry, state: "error", to };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
return {
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
refreshError: income.isError || income.meta?.sync.state === "failed",
stale: income.meta?.freshness === "stale",
state: "loaded",
summary: income.data,
to,
};
}
function toRecentEvents(
accountId: string | undefined,
events: PreviewResource<{ events: BrokerageEvent[] }>,
): AccountPageRecentEvents {
const to = `/accounts/${accountId}/events`;
if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
return { state: "loading", to };
if (events.meta?.availability === "partial")
return {
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
state: "partial",
to,
};
if (!events.data && (events.isError || events.meta?.sync.state === "failed"))
return { state: "error", to };
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
if (items.length === 0) return { state: "empty", to };
return {
items,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
refreshError: events.isError || events.meta?.sync.state === "failed",
stale: events.meta?.freshness === "stale",
state: "loaded",
to,
};
}
function toRecentOperations(
accountId: string | undefined,
operations: PreviewResource<{ items: BrokerageOperation[] }>,
): AccountPageRecentOperations {
const to = `/accounts/${accountId}/operations`;
if (
!operations.data
&& !operations.isError
&& operations.meta?.sync.state !== "failed"
)
return { state: "loading", to };
if (operations.meta?.availability === "partial")
return { items: operations.data?.items ?? [], state: "partial", to };
if (
!operations.data
&& (operations.isError || operations.meta?.sync.state === "failed")
)
return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to };
return {
items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
refreshError:
operations.isError || operations.meta?.sync.state === "failed",
stale: operations.meta?.freshness === "stale",
state: "loaded",
to,
};
}
export { toIncomeSummary, toRecentEvents, toRecentOperations };
export type {
AccountPageIncomeSummary,
AccountPageRecentEvents,
AccountPageRecentOperations,
};
@@ -22,7 +22,7 @@ import { server } from "@/test/server";
import type { import type {
AccountPageRecentEvents, AccountPageRecentEvents,
AccountPageRecentOperations, AccountPageRecentOperations,
} from "./AccountPage.vm"; } from "../../model/previews";
import { toAccountPagePositionSections } from "../../model/positions"; import { toAccountPagePositionSections } from "../../model/positions";
import { AccountPage } from "./AccountPage"; import { AccountPage } from "./AccountPage";
@@ -1,6 +1,5 @@
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { BrokerageOperation } from "@/entities/broker-operation";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { useBrokerageOperations } from "@/entities/broker-operation"; import { useBrokerageOperations } from "@/entities/broker-operation";
@@ -9,17 +8,25 @@ import {
useBrokerageAccounts, useBrokerageAccounts,
} from "@/entities/brokerage-account"; } from "@/entities/brokerage-account";
import { import {
type BrokerageEventsPreviewState,
useBrokerageEvents, useBrokerageEvents,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { type IncomeSummary, useIncomeSummary } from "@/entities/income"; import { useIncomeSummary } from "@/entities/income";
import { usePortfolio } from "@/entities/portfolio"; import { usePortfolio } from "@/entities/portfolio";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
import { type AccountPagePositionSection, toAccountPagePositionSections } from "../../model/positions"; import {
type AccountPagePositionSection,
const RECENT_EVENTS_LIMIT = 5; toAccountPagePositionSections,
} from "../../model/positions";
import {
type AccountPageIncomeSummary,
type AccountPageRecentEvents,
type AccountPageRecentOperations,
toIncomeSummary,
toRecentEvents,
toRecentOperations,
} from "../../model/previews";
type AccountPageContent = { type AccountPageContent = {
account?: BrokerageAccount; account?: BrokerageAccount;
@@ -38,131 +45,18 @@ type AccountPageEmptyViewModel = AccountPageContent & {
state: "empty"; state: "empty";
}; };
type AccountPageIncomeSummary
= | {
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "loaded";
summary: IncomeSummary;
to: string;
}
| { retry: () => Promise<unknown> | undefined; state: "error"; to: string }
| { state: "empty"
| "loading"
| "partial"; to: string; };
type AccountPageLoadedViewModel = AccountPageContent & { type AccountPageLoadedViewModel = AccountPageContent & {
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
state: "loaded"; state: "loaded";
}; };
type AccountPageRecentEvents = BrokerageEventsPreviewState;
type AccountPageRecentOperations
= | {
items: BrokerageOperation[];
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "loaded" | "partial";
to: string;
}
| {
state: "empty"
| "error"
| "loading";
to: string;
};
type AccountPageViewModel type AccountPageViewModel
= | AccountPageEmptyViewModel = | AccountPageEmptyViewModel
| AccountPageLoadedViewModel | AccountPageLoadedViewModel
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" } | { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
| { state: "loading" }; | { state: "loading" };
function toIncomeSummary(
accountId: string | undefined,
income: ReturnType<typeof useIncomeSummary>,
): AccountPageIncomeSummary {
const to = `/accounts/${accountId}/income`;
if (!income.data && !income.isError && income.meta?.sync.state !== "failed")
return { state: "loading", to };
if (income.meta?.availability === "partial") return { state: "partial", to };
if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
return { retry: () => income.refetch(), state: "error", to };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
return {
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
refreshError: income.isError || income.meta?.sync.state === "failed",
stale: income.meta?.freshness === "stale",
state: "loaded",
summary: income.data,
to,
};
}
function toRecentEvents(
accountId: string | undefined,
events: ReturnType<typeof useBrokerageEvents>,
): AccountPageRecentEvents {
const to = `/accounts/${accountId}/events`;
if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
return { state: "loading", to };
if (events.meta?.availability === "partial")
return {
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
state: "partial",
to,
};
if (!events.data && (events.isError || events.meta?.sync.state === "failed"))
return { state: "error", to };
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
if (items.length === 0) return { state: "empty", to };
return {
items,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
refreshError: events.isError || events.meta?.sync.state === "failed",
stale: events.meta?.freshness === "stale",
state: "loaded",
to,
};
}
function toRecentOperations(
accountId: string | undefined,
operations: ReturnType<typeof useBrokerageOperations>,
): AccountPageRecentOperations {
const to = `/accounts/${accountId}/operations`;
if (
!operations.data
&& !operations.isError
&& operations.meta?.sync.state !== "failed"
)
return { state: "loading", to };
if (operations.meta?.availability === "partial")
return { items: operations.data?.items ?? [], state: "partial", to };
if (
!operations.data
&& (operations.isError || operations.meta?.sync.state === "failed")
)
return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to };
return {
items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
refreshError:
operations.isError || operations.meta?.sync.state === "failed",
stale: operations.meta?.freshness === "stale",
state: "loaded",
to,
};
}
function useAccountPageViewModel(): AccountPageViewModel { function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams(); const { accountId } = useParams();
const portfolio = usePortfolio(accountId); const portfolio = usePortfolio(accountId);
@@ -197,7 +91,10 @@ function useAccountPageViewModel(): AccountPageViewModel {
const account = accounts.data?.find(item => item.id === accountId); const account = accounts.data?.find(item => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations); const recentOperations = toRecentOperations(accountId, operations);
const recentEvents = toRecentEvents(accountId, events); const recentEvents = toRecentEvents(accountId, events);
const incomeSummary = toIncomeSummary(accountId, income);
const incomeSummary = toIncomeSummary(
accountId, income, () => income.refetch(),
);
const refresh = { const refresh = {
partial: portfolio.meta?.availability === "partial", partial: portfolio.meta?.availability === "partial",
@@ -235,9 +132,4 @@ function useAccountPageViewModel(): AccountPageViewModel {
}; };
} }
export { export { useAccountPageViewModel };
type AccountPageIncomeSummary,
type AccountPageRecentEvents,
type AccountPageRecentOperations,
useAccountPageViewModel,
};
@@ -7,16 +7,16 @@ import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { InvestmentRefreshState } from "@/features/tbank-connection";
import type { import type {
AccountPageIncomeSummary, AccountPageIncomeSummary,
AccountPageRecentEvents, AccountPageRecentEvents,
AccountPageRecentOperations, AccountPageRecentOperations,
} from "../AccountPage.vm"; } from "../../../model/previews";
import { AccountHeading } from "./AccountHeading.view"; import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view"; import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState/PortfolioRefreshState.view";
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure"; import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { RecentOperations } from "./RecentOperations.view"; import { RecentOperations } from "./RecentOperations.view";
@@ -45,7 +45,8 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<Stack> <Stack>
<AccountHeading account={props.account} accountId={props.accountId} /> <AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState <InvestmentRefreshState
labelScope="portfolio"
refreshError={props.refreshError ?? false} refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false} refreshing={props.refreshing ?? false}
retry={props.retry} retry={props.retry}
@@ -7,16 +7,20 @@ import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { InvestmentRefreshState } from "@/features/tbank-connection";
import { formatSnapshotTime } from "@/shared/lib"; import { formatSnapshotTime } from "@/shared/lib";
import type { AccountPagePositionSection } from "../../../model/positions"; import type { AccountPagePositionSection } from "../../../model/positions";
import type { AccountPageIncomeSummary, AccountPageRecentEvents, AccountPageRecentOperations } from "../AccountPage.vm"; import type {
AccountPageIncomeSummary,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../../../model/previews";
import classes from "../AccountPage.module.css"; import classes from "../AccountPage.module.css";
import { AccountHeading } from "./AccountHeading.view"; import { AccountHeading } from "./AccountHeading.view";
import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view"; import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
import { IncomeSummary } from "./IncomeSummary.view"; import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState/PortfolioRefreshState.view";
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure"; import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { PositionSection } from "./PositionSection.view"; import { PositionSection } from "./PositionSection.view";
import { RecentOperations } from "./RecentOperations.view"; import { RecentOperations } from "./RecentOperations.view";
@@ -51,7 +55,8 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Stack gap="lg"> <Stack gap="lg">
<AccountHeading account={props.account} accountId={props.accountId} /> <AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState <InvestmentRefreshState
labelScope="portfolio"
refreshError={props.refreshError ?? false} refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false} refreshing={props.refreshing ?? false}
retry={props.retry} retry={props.retry}
@@ -1,58 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Stack } from "@mantine/core";
import { fn } from "storybook/test";
import { CurrencyCards } from "../CurrencyCards/CurrencyCards.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
const meta = {
args: { refreshError: false, refreshing: false, retry: fn() },
argTypes: {
refreshError: { control: "boolean" },
refreshing: { control: "boolean" },
retry: { control: false },
},
component: PortfolioRefreshState,
parameters: {
docs: {
description: {
component:
"Обновление уже показанных данных портфеля. Реальные CurrencyCards остаются видимыми вместе с PortfolioRefreshState при refetch и его ошибке. Суммы синтетические; это presentation Client Cached Snapshot, без QueryClient и запроса API. Retry регистрирует локальное действие в Actions и не доказывает восстановление API.",
},
},
},
render: arguments_ => (
<Stack>
<CurrencyCards
amounts={[
{ currency: "RUB", value: "125000.50" },
{ currency: "USD", value: "320" },
]}
/>
<PortfolioRefreshState {...arguments_} />
</Stack>
),
tags: ["autodocs"],
title: "Product/PortfolioRefreshState",
} satisfies Meta<typeof PortfolioRefreshState>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Idle: Story = { name: "Сохранённые данные" };
export const Refreshing: Story = {
args: { refreshing: true },
name: "Фоновое обновление",
};
export const RefreshError: Story = {
args: { refreshError: true },
name: "Ошибка обновления с retry",
};
export const SourceFailure: Story = {
args: { refreshError: true, sourceError: true },
name: "Ошибка синхронизации сохраняет суммы",
};
@@ -1,55 +0,0 @@
import type { FC } from "react";
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
type PortfolioRefreshStateProps = {
refreshError: boolean;
refreshing: boolean;
retry?: () => void;
sourceError?: boolean;
};
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
const { refreshError, refreshing, retry } = props;
const { t } = useTranslation();
return (
<>
<Button
disabled={refreshing}
onClick={retry}
variant="light"
w="fit-content"
>
{t(refreshError ? "accounts.retry" : "investment.refresh")}
</Button>
{!!refreshing && (
<Group aria-label={t("portfolio.refreshing")} gap="xs" role="status">
<Loader size="sm" />
<Text c="dimmed" size="sm">
{t("portfolio.refreshing")}
</Text>
</Group>
)}
{!!refreshError && (
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="sm">
<Text>
{t(
props.sourceError
? "investment.syncFailed"
: "portfolio.refreshError",
)}
</Text>
</Stack>
</Alert>
)}
</>
);
};
export { PortfolioRefreshState };
@@ -8,7 +8,7 @@ import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation"; import { LedgerEntryRow } from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib"; import { formatSnapshotTime } from "@/shared/lib";
import type { AccountPageRecentOperations } from "../AccountPage.vm"; import type { AccountPageRecentOperations } from "../../../model/previews";
type RecentOperationsProps = { type RecentOperationsProps = {
operations: AccountPageRecentOperations; operations: AccountPageRecentOperations;
@@ -5,16 +5,16 @@ import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
AccountsRefreshState, InvestmentRefreshState,
type AccountsRefreshStateProps, type InvestmentRefreshStateProps,
} from "./AccountsRefreshState.view"; } from "@/features/tbank-connection";
const AccountsPageEmpty: FC<AccountsRefreshStateProps> = (props) => { const AccountsPageEmpty: FC<InvestmentRefreshStateProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<AccountsRefreshState {...props} /> <InvestmentRefreshState {...props} />
<StateView <StateView
text={t("accounts.emptyText")} text={t("accounts.emptyText")}
@@ -8,13 +8,12 @@ import {
type BrokerageAccountCard, type BrokerageAccountCard,
BrokerageAccountList, BrokerageAccountList,
} from "@/entities/brokerage-account"; } from "@/entities/brokerage-account";
import { import {
AccountsRefreshState, InvestmentRefreshState,
type AccountsRefreshStateProps, type InvestmentRefreshStateProps,
} from "./AccountsRefreshState.view"; } from "@/features/tbank-connection";
type AccountsPageLoadedProps = AccountsRefreshStateProps & { type AccountsPageLoadedProps = InvestmentRefreshStateProps & {
accounts: BrokerageAccountCard[]; accounts: BrokerageAccountCard[];
}; };
@@ -32,7 +31,7 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
</Text> </Text>
</Stack> </Stack>
<AccountsRefreshState {...refresh} /> <InvestmentRefreshState {...refresh} />
<Button <Button
component={Link} component={Link}
+12 -13
View File
@@ -7,19 +7,21 @@ import {
Skeleton, Skeleton,
Stack, Stack,
Text, Text,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui"; import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
InstrumentChart, InstrumentChart,
instrumentDetailsStyles,
InstrumentEvents, InstrumentEvents,
InstrumentHeader, InstrumentHeader,
InstrumentMetric, InstrumentMetric,
InstrumentMetrics,
InstrumentPage,
InstrumentPosition, InstrumentPosition,
InstrumentPrice,
InstrumentQuoteStatus, InstrumentQuoteStatus,
InstrumentTerms,
} from "@/features/instrument-details"; } from "@/features/instrument-details";
import { import {
formatDate, formatDate,
@@ -75,11 +77,11 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
{t("bond.percentOfNominal")} {t("bond.percentOfNominal")}
</Text> </Text>
<Text className={instrumentDetailsStyles.price}> <InstrumentPrice>
{vm.quote?.pricePercent == undefined {vm.quote?.pricePercent == undefined
? t("bond.unknown") ? t("bond.unknown")
: `${formatDecimal(vm.quote.pricePercent)} %`} : `${formatDecimal(vm.quote.pricePercent)} %`}
</Text> </InstrumentPrice>
{vm.quote?.changePercentagePoints != undefined && ( {vm.quote?.changePercentagePoints != undefined && (
<Text fw={600}> <Text fw={600}>
@@ -131,7 +133,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
} }
return ( return (
<Stack className={instrumentDetailsStyles.page} gap="lg"> <InstrumentPage>
<InstrumentHeader <InstrumentHeader
logoSrc={vm.logoSrc} logoSrc={vm.logoSrc}
name={bond.name} name={bond.name}
@@ -168,12 +170,9 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
</Text> </Text>
)} )}
<div className={instrumentDetailsStyles.embeddedSection}> <InstrumentTerms title={t("bond.terms")}>
<Title mb="md" order={2} size="h3">
{t("bond.terms")}
</Title>
<div className={instrumentDetailsStyles.metricsGrid}> <InstrumentMetrics>
<InstrumentMetric <InstrumentMetric
label={t("bond.nominal")} label={t("bond.nominal")}
value={formatMoney(bond.nominal)} value={formatMoney(bond.nominal)}
@@ -191,8 +190,8 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
<InstrumentMetric label={t("bond.couponFrequency")} value={couponFrequency} /> <InstrumentMetric label={t("bond.couponFrequency")} value={couponFrequency} />
<InstrumentMetric label={t("bond.lot")} value={lotSize} /> <InstrumentMetric label={t("bond.lot")} value={lotSize} />
</div> </InstrumentMetrics>
</div> </InstrumentTerms>
</InstrumentHeader> </InstrumentHeader>
<BondMetrics metrics={vm.metrics} state={vm.metricsState} /> <BondMetrics metrics={vm.metrics} state={vm.metricsState} />
@@ -218,7 +217,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
incomeLabel={t("bond.coupons")} incomeLabel={t("bond.coupons")}
vm={vm} vm={vm}
/> />
</Stack> </InstrumentPage>
); );
}; };
@@ -5,8 +5,18 @@ import { useTranslation } from "react-i18next";
import type { BondMetrics as Metrics } from "@/entities/bond"; import type { BondMetrics as Metrics } from "@/entities/bond";
import { instrumentDetailsStyles, InstrumentMetric, InstrumentSection, type SectionState } from "@/features/instrument-details"; import {
import { formatDate, formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib"; InstrumentMetric,
InstrumentMetrics,
InstrumentSection,
type SectionState,
} from "@/features/instrument-details";
import {
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
type BondMetricsProps = { metrics?: Metrics; state: SectionState }; type BondMetricsProps = { metrics?: Metrics; state: SectionState };
@@ -72,7 +82,7 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
<InstrumentSection state={state} title={t("bond.metrics")}> <InstrumentSection state={state} title={t("bond.metrics")}>
{!!metrics && ( {!!metrics && (
<Stack gap="md"> <Stack gap="md">
<div className={instrumentDetailsStyles.metricsGrid}> <InstrumentMetrics>
<InstrumentMetric label={t("bond.creditRating")} value={rating} /> <InstrumentMetric label={t("bond.creditRating")} value={rating} />
<InstrumentMetric <InstrumentMetric
@@ -92,7 +102,7 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
<InstrumentMetric label={t("bond.effectiveYield")} value={effectiveYield} /> <InstrumentMetric label={t("bond.effectiveYield")} value={effectiveYield} />
<InstrumentMetric label={t("bond.couponYield")} value={couponYield} /> <InstrumentMetric label={t("bond.couponYield")} value={couponYield} />
</div> </InstrumentMetrics>
{!!metrics.creditRating && ( {!!metrics.creditRating && (
<Group gap="xs"> <Group gap="xs">
@@ -0,0 +1,126 @@
import {
ActionIcon,
Anchor,
Group,
Skeleton,
Stack,
Text,
} from "@mantine/core";
import {
DeleteIcon,
EditIcon,
OperationIcon,
} from "@wealthledger/icons";
import { EntryCard, LoadingSkeleton } from "@wealthledger/ui";
import { type FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
bankOperationAppearance,
} from "@/entities/bank-card";
import type { CardPageVm } from "./CardPage.vm";
import classes from "./CardPage.module.css";
type CardHistoryProps = Pick<CardPageVm,
"history"
| "period"
| "rows"
>;
// eslint-disable-next-line sonarjs/function-return-type -- React renders elements, lists and empty output.
const CardHistory: FC<CardHistoryProps> = (props) => {
const vm = props;
const { t } = useTranslation();
if (vm.rows) {
if (vm.rows.length > 0) {
return vm.rows.map((row) => {
const details = row.otherCard
? (
<Anchor
component={Link}
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
>
{t("bank.card")}
</Anchor>
)
: undefined;
return (
<section
aria-label={`${row.title} ${row.item.date}`}
className={classes.operation}
key={row.item.id}
>
<EntryCard
actions={(
<Group gap={4} wrap="nowrap">
<ActionIcon
aria-label={t("bank.edit")}
disabled={!row.editable}
onClick={row.onEdit}
size="sm"
title={t("bank.edit")}
variant="subtle"
>
<EditIcon size={18} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
aria-label={t("bank.delete")}
color="red"
disabled={!row.editable}
onClick={row.onDelete}
size="sm"
title={t("bank.delete")}
variant="subtle"
>
<DeleteIcon size={18} strokeWidth={1.5} />
</ActionIcon>
</Group>
)}
color={row.color}
details={details}
icon={(
<OperationIcon
{...bankOperationAppearance(
row.item.kind,
row.item.categoryName,
)}
/>
)}
initial={row.title.charAt(0)}
metadata={row.metadata}
title={row.title}
tone={row.tone}
value={row.value}
valueLabel={t(`bank.kinds.${row.item.kind}`)}
wrap
/>
</section>
);
});
}
return <Text>{t("bank.noOperations")}</Text>;
}
if (vm.history.loading) {
return (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<Stack>
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
);
}
return null;
};
export { CardHistory };
+3 -101
View File
@@ -1,6 +1,5 @@
import { import {
ActionIcon, ActionIcon,
Anchor,
Button, Button,
Group, Group,
Pagination, Pagination,
@@ -14,10 +13,9 @@ import {
AddIcon, AddIcon,
DeleteIcon, DeleteIcon,
EditIcon, EditIcon,
OperationIcon,
RestoreIcon, RestoreIcon,
} from "@wealthledger/icons"; } from "@wealthledger/icons";
import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui"; import { LoadingSkeleton, StateView } from "@wealthledger/ui";
import { type FC } from "react"; import { type FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
@@ -25,114 +23,18 @@ import { Link } from "react-router";
import { import {
BankCardFace, BankCardFace,
BankFeedback, BankFeedback,
bankOperationAppearance,
BankPeriodPicker, BankPeriodPicker,
BankSummaryView, BankSummaryView,
} from "@/entities/bank-card"; } from "@/entities/bank-card";
import type { CardPageVm } from "./CardPage.vm"; import type { CardPageVm } from "./CardPage.vm";
import { CardHistory } from "./CardHistory.view";
import classes from "./CardPage.module.css"; import classes from "./CardPage.module.css";
import { useCardPagePresentationVm } from "./CardPagePresentation.vm"; import { useCardPagePresentationVm } from "./CardPagePresentation.vm";
type CardPageViewProps = { vm: CardPageVm }; type CardPageViewProps = { vm: CardPageVm };
// eslint-disable-next-line sonarjs/function-return-type -- React renders elements, lists and empty output.
const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const card = vm.card;
if (!card) return;
if (vm.rows) {
if (vm.rows.length > 0) {
return vm.rows.map((row) => {
const details = row.otherCard
? (
<Anchor
component={Link}
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
>
{t("bank.card")}
</Anchor>
)
: undefined;
return (
<section
aria-label={`${row.title} ${row.item.date}`}
className={classes.operation}
key={row.item.id}
>
<EntryCard
actions={(
<Group gap={4} wrap="nowrap">
<ActionIcon
aria-label={t("bank.edit")}
disabled={!row.editable}
onClick={row.onEdit}
size="sm"
title={t("bank.edit")}
variant="subtle"
>
<EditIcon size={18} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
aria-label={t("bank.delete")}
color="red"
disabled={!row.editable}
onClick={row.onDelete}
size="sm"
title={t("bank.delete")}
variant="subtle"
>
<DeleteIcon size={18} strokeWidth={1.5} />
</ActionIcon>
</Group>
)}
color={row.color}
details={details}
icon={(
<OperationIcon
{...bankOperationAppearance(
row.item.kind,
row.item.categoryName,
)}
/>
)}
initial={row.title.charAt(0)}
metadata={row.metadata}
title={row.title}
tone={row.tone}
value={row.value}
valueLabel={t(`bank.kinds.${row.item.kind}`)}
wrap
/>
</section>
);
});
}
return <Text>{t("bank.noOperations")}</Text>;
}
if (vm.history.loading) {
return (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<Stack>
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
);
}
return null;
};
const CardPageView: FC<CardPageViewProps> = (props) => { const CardPageView: FC<CardPageViewProps> = (props) => {
const { vm } = props; const { vm } = props;
@@ -291,7 +193,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
gap="lg" gap="lg"
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.page ?? "loading"}`} key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.page ?? "loading"}`}
> >
<CardPageViewHistory {...props} /> <CardHistory history={vm.history} period={vm.period} rows={vm.rows} />
</Stack> </Stack>
{vm.history.error && ( {vm.history.error && (
@@ -6,7 +6,13 @@ import { useTranslation } from "react-i18next";
import type { CardPageVm } from "./CardPage.vm"; import type { CardPageVm } from "./CardPage.vm";
function useCardPagePresentationVm(vm: CardPageVm) { function useCardPagePresentationVm(vm: Pick<CardPageVm,
"card"
| "history"
| "pageSize"
| "period"
| "setPage"
>) {
const { t } = useTranslation(); const { t } = useTranslation();
const card = vm.card; const card = vm.card;
const paginationRef = useRef<HTMLDivElement>(null); const paginationRef = useRef<HTMLDivElement>(null);
@@ -1,6 +1,3 @@
import type { DateInput } from "@mantine/dates";
import type { ComponentProps } from "react";
import { useController, useFormContext } from "react-hook-form"; import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -10,10 +7,9 @@ import type {
} from "./OperationForm.types"; } from "./OperationForm.types";
function useOperationFieldsVm(props: Pick<OperationFieldsProps, function useOperationFieldsVm(props: Pick<OperationFieldsProps,
"dateInputValue" "bindDate"
| "kind" | "kind"
| "refundCardId" | "refundCardId"
| "validDate"
>) { >) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -48,38 +44,13 @@ function useOperationFieldsVm(props: Pick<OperationFieldsProps,
props.kind === "transfer" ? "bank.source" : "bank.operationCard", props.kind === "transfer" ? "bank.source" : "bank.operationCard",
); );
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
props.validDate(props.dateInputValue(value))
? props.dateInputValue(value)
: null;
const onDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (event) => {
dateField.onChange(props.dateInputValue(event.currentTarget.value));
dateField.onBlur();
};
const onDateChange: (value: null | string) => void = value =>
dateField.onChange(value ?? "");
const onDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
dateField.onChange(props.dateInputValue(event.currentTarget.value));
const dateValue = props.validDate(dateField.value)
? dateField.value
: undefined;
return { return {
cardIdField, cardIdField,
categoryIdField, categoryIdField,
dateField, date: { ...dateField, ...props.bindDate(dateField) },
dateParser,
dateValue,
errors, errors,
isSubmitting, isSubmitting,
kindField, kindField,
onDateBlur,
onDateChange,
onDateInput,
onKindChange, onKindChange,
register, register,
sourceCardLabel, sourceCardLabel,
@@ -43,9 +43,14 @@ const OperationExample: FC<ExampleProps> = (props) => {
) )
} }
fields={{ fields={{
bindDate: field => ({
...field,
dateParser: () => "2026-08-01",
onChange: value => field.onChange(value ?? ""),
onInput: () => {},
}),
cards: [{ label: "Учебная карта", value: "demo" }], cards: [{ label: "Учебная карта", value: "demo" }],
categories: [], categories: [],
dateInputValue: () => "2026-08-01",
defaultCategory: "", defaultCategory: "",
editing: true, editing: true,
kind, kind,
@@ -53,7 +58,6 @@ const OperationExample: FC<ExampleProps> = (props) => {
maxDate: () => "2026-08-27", maxDate: () => "2026-08-27",
refundCardId: "demo", refundCardId: "demo",
targets: [], targets: [],
validDate: value => value === "2026-08-01",
}} }}
latest={ latest={
props.conflict props.conflict
@@ -107,7 +111,7 @@ const meta = {
docs: { docs: {
description: { description: {
component: component:
"Synthetic presentation без API/VM. Date callbacks фиксируют fixture 01.08.2026; денежная validation, recovery и nested category flow проверяются application tests. Actions только локальные.", "Synthetic presentation без API/VM. денежная validation, recovery и nested category flow проверяются application tests. Actions только локальные.",
}, },
}, },
}, },
@@ -1,11 +1,15 @@
import type { BankCard, BankOperation } from "@/entities/bank-card"; import type {
BankCard,
bankDateField,
BankOperation,
} from "@/entities/bank-card";
type Choice = { label: string; value: string }; type Choice = { label: string; value: string };
type OperationFieldsProps = { type OperationFieldsProps = {
bindDate: typeof bankDateField;
cards: Choice[]; cards: Choice[];
categories: Choice[]; categories: Choice[];
dateInputValue: (value: string) => string;
defaultCategory: string; defaultCategory: string;
editing: boolean; editing: boolean;
kind: OperationValues["kind"]; kind: OperationValues["kind"];
@@ -13,7 +17,6 @@ type OperationFieldsProps = {
maxDate: () => string; maxDate: () => string;
refundCardId: string; refundCardId: string;
targets: Choice[]; targets: Choice[];
validDate: (value: string) => boolean;
}; };
type OperationFormProps = { type OperationFormProps = {
@@ -1,6 +1,7 @@
import type { FC, ReactNode, SubmitEventHandler } from "react"; import type { FC, ReactNode, SubmitEventHandler } from "react";
import { Button, import {
Button,
Fieldset, Fieldset,
Group, Group,
Modal, Modal,
@@ -26,15 +27,10 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
const { const {
cardIdField, cardIdField,
categoryIdField, categoryIdField,
dateField, date,
dateParser,
dateValue,
errors, errors,
isSubmitting, isSubmitting,
kindField, kindField,
onDateBlur,
onDateChange,
onDateInput,
onKindChange, onKindChange,
register, register,
sourceCardLabel, sourceCardLabel,
@@ -82,21 +78,16 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
/> />
<DateInput <DateInput
{...dateField} {...date}
dateParser={dateParser}
error={errors.date?.message} error={errors.date?.message}
fixOnBlur={false} fixOnBlur={false}
label={t("bank.date")} label={t("bank.date")}
maxDate={props.maxDate()} maxDate={props.maxDate()}
onBlur={onDateBlur}
onChange={onDateChange}
onInput={onDateInput}
popoverProps={{ popoverProps={{
middlewares: { shift: { crossAxis: true } }, middlewares: { shift: { crossAxis: true } },
withinPortal: false, withinPortal: false,
}} }}
required required
value={dateValue}
valueFormat="DD.MM.YYYY" valueFormat="DD.MM.YYYY"
/> />
@@ -4,14 +4,12 @@ import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
bankDateInputValue, bankDateField,
bankDateSchema,
bankMoneyValue, bankMoneyValue,
type BankOperation, type BankOperation,
bankOperationKinds, bankOperationKinds,
bankToday, bankToday,
useBankCategories, useBankCategories,
useBankExpenses,
useBankFormFocus, useBankFormFocus,
useBankOperation, useBankOperation,
useCreateBankOperation, useCreateBankOperation,
@@ -25,6 +23,7 @@ import type {
} from "./OperationForm.types"; } from "./OperationForm.types";
import { createOperationSchema } from "./OperationForm.schema"; import { createOperationSchema } from "./OperationForm.schema";
import { useRefundPurchase } from "./useRefundPurchase";
const useOperationFormVm = (props: OperationFormProps) => { const useOperationFormVm = (props: OperationFormProps) => {
useBankFormFocus(); useBankFormFocus();
@@ -33,7 +32,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
const [categoriesOpen, setCategoriesOpen] = useState(false); const [categoriesOpen, setCategoriesOpen] = useState(false);
const [version, setVersion] = useState(props.operation?.version ?? 1); const [version, setVersion] = useState(props.operation?.version ?? 1);
const [latest, setLatest] = useState<BankOperation>(); const [latest, setLatest] = useState<BankOperation>();
const [sourcePage, setSourcePage] = useState(1);
const currentQuery = useBankOperation(props.operation?.id); const currentQuery = useBankOperation(props.operation?.id);
@@ -72,20 +70,14 @@ const useOperationFormVm = (props: OperationFormProps) => {
)(values, context, options), )(values, context, options),
}); });
const sourceId = useWatch({
control: methods.control,
name: "originalExpenseId",
});
const sourceQuery = useBankOperation(sourceId || undefined);
const kind = useWatch({ control: methods.control, name: "kind" }); const kind = useWatch({ control: methods.control, name: "kind" });
const expenses = useBankExpenses( const purchase = useRefundPurchase(
kind === "refund" ? props.card.id : undefined, methods.control, props.card.id, kind, Boolean(props.operation),
{ page: sourcePage },
); );
const { sourceId, sourceQuery } = purchase;
const source = useWatch({ control: methods.control, name: "cardId" }); const source = useWatch({ control: methods.control, name: "cardId" });
const isPending = !opened || methods.formState.isSubmitting; const isPending = !opened || methods.formState.isSubmitting;
@@ -149,23 +141,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
else setError(result.error); else setError(result.error);
}; };
const expenseChoice = (item: BankOperation) => ({
label: t("bank.expenseChoice", {
amount: formatMoney(item.amount),
available: formatMoney(item.availableRefund),
date: formatDate(item.date),
name: item.description || item.categoryName,
}),
value: item.id,
});
const expenseOptions
= expenses.data?.items.map(element => expenseChoice(element)) ?? [];
const selected = sourceQuery.data
? expenseChoice(sourceQuery.data)
: undefined;
return { return {
categoriesError: categories.isError categoriesError: categories.isError
? categories.error ? categories.error
@@ -173,16 +148,14 @@ const useOperationFormVm = (props: OperationFormProps) => {
categoriesOpen, categoriesOpen,
closeCategories: () => setCategoriesOpen(false), closeCategories: () => setCategoriesOpen(false),
error, error,
expensesError: expenses.isError expensesError: purchase.error,
? expenses.error
: null,
methods, methods,
onCategoryCreated: (id: string) => { onCategoryCreated: (id: string) => {
methods.setValue("categoryId", id); methods.setValue("categoryId", id);
setCategoriesOpen(false); setCategoriesOpen(false);
}, },
retryCategories: () => void categories.refetch(), retryCategories: () => void categories.refetch(),
retryExpenses: () => void expenses.refetch(), retryExpenses: purchase.retry,
retrySource: () => void sourceQuery.refetch(), retrySource: () => void sourceQuery.refetch(),
sourceError: sourceQuery.isError sourceError: sourceQuery.isError
? sourceQuery.error ? sourceQuery.error
@@ -190,14 +163,12 @@ const useOperationFormVm = (props: OperationFormProps) => {
view: { view: {
categoriesOpen, categoriesOpen,
editing: Boolean(props.operation), editing: Boolean(props.operation),
emptyExpenses: Boolean( emptyExpenses: purchase.empty,
expenses.data && expenses.data.items.length === 0 && !props.operation,
),
fields: { fields: {
bindDate: bankDateField,
cards: options, cards: options,
categories: categories:
categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [], categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [],
dateInputValue: bankDateInputValue,
defaultCategory: categories.data?.find(c => c.isDefault)?.id ?? "", defaultCategory: categories.data?.find(c => c.isDefault)?.id ?? "",
editing: Boolean(props.operation), editing: Boolean(props.operation),
kind, kind,
@@ -208,7 +179,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
maxDate: bankToday, maxDate: bankToday,
refundCardId: props.card.id, refundCardId: props.card.id,
targets: options.filter(c => c.value !== source), targets: options.filter(c => c.value !== source),
validDate: (value: string) => bankDateSchema.safeParse(value).success,
}, },
latest: latest latest: latest
? { ? {
@@ -221,26 +191,22 @@ const useOperationFormVm = (props: OperationFormProps) => {
version: latest.version, version: latest.version,
} }
: undefined, : undefined,
loadingExpenses: expenses.isLoading, loadingExpenses: purchase.loading,
nextDisabled: !expenses.data?.hasNext || expenses.isFetching, nextDisabled: purchase.nextDisabled,
onAddCategory: () => setCategoriesOpen(true), onAddCategory: () => setCategoriesOpen(true),
onCancel: () => setOpened(false), onCancel: () => setOpened(false),
onClose: () => !isPending && !categoriesOpen && setOpened(false), onClose: () => !isPending && !categoriesOpen && setOpened(false),
onExitTransitionEnd: props.onClose, onExitTransitionEnd: props.onClose,
onNext: () => setSourcePage(sourcePage + 1), onNext: purchase.onNext,
onPrevious: () => setSourcePage(sourcePage - 1), onPrevious: purchase.onPrevious,
onReload: error && (sourceId || props.operation) ? onReload : undefined, onReload: error && (sourceId || props.operation) ? onReload : undefined,
onSubmit, onSubmit,
opened, opened,
pending: isPending, pending: isPending,
previousDisabled: sourcePage === 1 || expenses.isFetching, previousDisabled: purchase.previousDisabled,
refund: { refund: {
editing: Boolean(props.operation), editing: Boolean(props.operation),
expenses: expenses: purchase.options,
selected
&& expenseOptions.every(item => item.value !== selected.value)
? [selected, ...expenseOptions]
: expenseOptions,
limit: props.operation limit: props.operation
? refundLimit ? refundLimit
: sourceQuery.data?.availableRefund, : sourceQuery.data?.availableRefund,
@@ -248,7 +214,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
saveDisabled: saveDisabled:
(kind === "expense" && !categories.data) (kind === "expense" && !categories.data)
|| (kind === "refund" && !sourceQuery.data), || (kind === "refund" && !sourceQuery.data),
sourcePage, sourcePage: purchase.page,
}, },
}; };
}; };
@@ -0,0 +1,61 @@
import type { Control } from "react-hook-form";
import { useState } from "react";
import { useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type BankOperation,
useBankExpenses,
useBankOperation,
} from "@/entities/bank-card";
import { formatDate, formatMoney } from "@/shared/lib";
import type { OperationValues } from "./OperationForm.types";
function useRefundPurchase(
control: Control<OperationValues>,
cardId: string,
kind: OperationValues["kind"],
isEditing: boolean,
) {
const { t } = useTranslation();
const [page, setPage] = useState(1);
const sourceId = useWatch({ control, name: "originalExpenseId" });
const sourceQuery = useBankOperation(sourceId || undefined);
const expenses = useBankExpenses(kind === "refund" ? cardId : undefined, { page });
const choice = (item: BankOperation) => ({
label: t("bank.expenseChoice", {
amount: formatMoney(item.amount),
available: formatMoney(item.availableRefund),
date: formatDate(item.date),
name: item.description || item.categoryName,
}),
value: item.id,
});
const options = expenses.data?.items.map(item => choice(item)) ?? [];
const selected = sourceQuery.data ? choice(sourceQuery.data) : undefined;
return {
empty: Boolean(
!isEditing && expenses.data && expenses.data.items.length === 0,
),
error: expenses.isError ? expenses.error : null,
loading: expenses.isLoading,
nextDisabled: !expenses.data?.hasNext || expenses.isFetching,
onNext: () => setPage(page + 1),
onPrevious: () => setPage(page - 1),
options: selected && options.every(item => item.value !== selected.value)
? [selected, ...options]
: options,
page,
previousDisabled: page === 1 || expenses.isFetching,
retry: () => void expenses.refetch(),
sourceId,
sourceQuery,
};
}
export { useRefundPurchase };
@@ -1,16 +1,18 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
function useSettingsPageVm() { function useSettingsPageVm() {
const { t } = useTranslation(); const { t } = useTranslation();
const { logout } = useAuth(); const { logout } = useAuth();
const navigate = useNavigate();
const onLogout = async () => { const onLogout = async () => {
await logout(); try {
await navigate("/login", { replace: true }); await logout();
}
catch {
// The session is already cleared; the route guard owns navigation.
}
}; };
return { onLogout, t }; return { onLogout, t };
+11 -172
View File
@@ -2,45 +2,23 @@ import type { FC } from "react";
import { import {
Alert, Alert,
Button,
Group,
Skeleton,
Stack,
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
instrumentDetailsStyles,
InstrumentHeader, InstrumentHeader,
InstrumentMetric, InstrumentPage,
InstrumentQuoteStatus,
InstrumentChart as StockChart, InstrumentChart as StockChart,
InstrumentEvents as StockEvents, InstrumentEvents as StockEvents,
InstrumentPosition as StockPosition, InstrumentPosition as StockPosition,
InstrumentSection as StockSection,
} from "@/features/instrument-details"; } from "@/features/instrument-details";
import {
formatDate,
formatDecimal,
formatMoney,
} from "@/shared/lib";
import type { StockPageVm } from "./StockPage.vm"; import type { StockPageVm } from "./StockPage.vm";
import { StockCompany } from "./components/StockCompany.view";
import { StockDividends } from "./components/StockDividends.view"; import { StockDividends } from "./components/StockDividends.view";
import classes from "./StockPage.module.css"; import { StockQuote } from "./components/StockQuote.view";
const compactMetricFormatter = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation: "compact",
});
const metricFormatter = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation: "standard",
});
type StockPageViewProps = { vm: StockPageVm }; type StockPageViewProps = { vm: StockPageVm };
@@ -59,96 +37,8 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
</> </>
); );
const appearanceClass = vm.quote?.change?.value.startsWith("-")
? classes.negative
: classes.positive;
let quoteContent;
if (vm.quoteState.loading) {
quoteContent = (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton h={55} mt="lg" w="50%" />
</LoadingSkeleton>
);
}
else {
quoteContent = (
<div aria-live="polite">
<Text className={instrumentDetailsStyles.price} mt="lg">
{formatMoney(vm.quote?.price)}
</Text>
{!!vm.quote?.change && (
<Text className={appearanceClass} fw={600}>
{vm.quote.change.value.startsWith("-") ? "" : "+"}
{formatMoney(vm.quote.change)}
{vm.quote.changePercent === null
? ""
: ` (${formatDecimal(vm.quote.changePercent)} %)`}
</Text>
)}
{vm.quote?.previousCloseDate
? (
<Text c="dimmed" size="xs">
{t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})}
</Text>
)
: (
<Text c="dimmed" size="xs">
{t("stock.noComparison")}
</Text>
)}
<Group gap="sm" mt="sm">
<InstrumentQuoteStatus quote={vm.quote} />
</Group>
{!!vm.quote?.unavailable.length && (
<Text c="dimmed" mt="xs" size="xs">
{t("stock.partialQuote")}
</Text>
)}
{!!vm.quoteState.refreshing && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
</div>
);
}
let companyMetricsContent;
if (vm.company) {
if (vm.company.metrics.length > 0) {
companyMetricsContent = (
<div className={instrumentDetailsStyles.metricsGrid}>
{vm.company.metrics.map((metric) => {
const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · ");
const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter;
const value = `${formatter.format(metric.value)} ${metricUnit(metric, t("stock.currencyUnknown"))}`;
return (
<InstrumentMetric key={metric.key} label={label} value={value} />
);
})}
</div>
);
}
else {
companyMetricsContent = <Text c="dimmed">{t("stock.noFundamentals")}</Text>;
}
}
return ( return (
<Stack className={instrumentDetailsStyles.page} gap="lg"> <InstrumentPage>
<InstrumentHeader <InstrumentHeader
logoSrc={vm.logoSrc} logoSrc={vm.logoSrc}
name={stock.name} name={stock.name}
@@ -169,52 +59,13 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
</Alert> </Alert>
)} )}
{quoteContent} <StockQuote quote={vm.quote} quoteState={vm.quoteState} />
<StockSection <StockCompany
embedded company={vm.company}
state={vm.companyState} companyState={vm.companyState}
title={t("stock.company")} stock={stock}
> />
<Group gap="xl" mb="md">
{!!stock.sector && (
<Text size="sm">
{t("stock.sector")}:{" "}
{t(`stock.sectors.${stock.sector}`, {
defaultValue: stock.sector,
})}
</Text>
)}
{stock.lot !== null && <Text size="sm">{t("stock.lot", { value: stock.lot })}</Text>}
</Group>
{!!vm.company && (
<Stack gap="lg">
{vm.company.unavailable.length > 0 && (
<Alert aria-live="polite" color="orange" role="status">
<Group justify="space-between">
<Text size="sm">{t("stock.partialCompany")}</Text>
<Button
onClick={vm.companyState.retry}
size="xs"
variant="default"
>
{t("accounts.retry")}
</Button>
</Group>
</Alert>
)}
{companyMetricsContent}
<Text c="dimmed" size="xs">
{t("stock.periodNote")}
</Text>
</Stack>
)}
</StockSection>
</InstrumentHeader> </InstrumentHeader>
<StockChart vm={vm} /> <StockChart vm={vm} />
@@ -229,20 +80,8 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
/> />
<StockEvents vm={vm} /> <StockEvents vm={vm} />
</Stack> </InstrumentPage>
); );
}; };
function metricUnit(
metric: NonNullable<StockPageVm["company"]>["metrics"][number],
unknownCurrency: string,
) {
if (metric.unit === "money")
return metric.currency === "RUB"
? "₽"
: (metric.currency ?? unknownCurrency);
return metric.unit === "percent" ? "%" : "×";
}
export { StockPageView }; export { StockPageView };
@@ -0,0 +1,120 @@
import type { FC } from "react";
import {
Alert,
Button,
Group,
Stack,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
InstrumentMetric,
InstrumentMetrics,
InstrumentSection as StockSection,
} from "@/features/instrument-details";
import type { StockPageVm } from "../StockPage.vm";
const compactMetricFormatter = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation: "compact",
});
const metricFormatter = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation: "standard",
});
type StockCompanyProps = Pick<StockPageVm, "company" | "companyState"> & {
stock: NonNullable<StockPageVm["stock"]>;
};
const StockCompany: FC<StockCompanyProps> = (props) => {
const { stock } = props;
const { t } = useTranslation();
let companyMetricsContent;
if (props.company) {
if (props.company.metrics.length > 0) {
companyMetricsContent = (
<InstrumentMetrics>
{props.company.metrics.map((metric) => {
const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · ");
const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter;
const value = `${formatter.format(metric.value)} ${metricUnit(metric, t("stock.currencyUnknown"))}`;
return (
<InstrumentMetric key={metric.key} label={label} value={value} />
);
})}
</InstrumentMetrics>
);
}
else {
companyMetricsContent = <Text c="dimmed">{t("stock.noFundamentals")}</Text>;
}
}
return (
<StockSection
embedded
state={props.companyState}
title={t("stock.company")}
>
<Group gap="xl" mb="md">
{!!stock.sector && (
<Text size="sm">
{t("stock.sector")}:{" "}
{t(`stock.sectors.${stock.sector}`, {
defaultValue: stock.sector,
})}
</Text>
)}
{stock.lot !== null && <Text size="sm">{t("stock.lot", { value: stock.lot })}</Text>}
</Group>
{!!props.company && (
<Stack gap="lg">
{props.company.unavailable.length > 0 && (
<Alert aria-live="polite" color="orange" role="status">
<Group justify="space-between">
<Text size="sm">{t("stock.partialCompany")}</Text>
<Button
onClick={props.companyState.retry}
size="xs"
variant="default"
>
{t("accounts.retry")}
</Button>
</Group>
</Alert>
)}
{companyMetricsContent}
<Text c="dimmed" size="xs">
{t("stock.periodNote")}
</Text>
</Stack>
)}
</StockSection>
);
};
function metricUnit(
metric: NonNullable<StockPageVm["company"]>["metrics"][number],
unknownCurrency: string,
) {
if (metric.unit === "money")
return metric.currency === "RUB"
? "₽"
: (metric.currency ?? unknownCurrency);
return metric.unit === "percent" ? "%" : "×";
}
export { StockCompany };
@@ -0,0 +1,98 @@
import type { FC } from "react";
import {
Group,
Skeleton,
Text,
} from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import {
InstrumentPrice,
InstrumentQuoteStatus,
} from "@/features/instrument-details";
import {
formatDate,
formatDecimal,
formatMoney,
} from "@/shared/lib";
import type { StockPageVm } from "../StockPage.vm";
import classes from "../StockPage.module.css";
type StockQuoteProps = Pick<StockPageVm, "quote" | "quoteState">;
const StockQuote: FC<StockQuoteProps> = (props) => {
const { t } = useTranslation();
const appearanceClass = props.quote?.change?.value.startsWith("-")
? classes.negative
: classes.positive;
let quoteContent;
if (props.quoteState.loading) {
quoteContent = (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton h={55} mt="lg" w="50%" />
</LoadingSkeleton>
);
}
else {
quoteContent = (
<div aria-live="polite">
<InstrumentPrice mt="lg">
{formatMoney(props.quote?.price)}
</InstrumentPrice>
{!!props.quote?.change && (
<Text className={appearanceClass} fw={600}>
{props.quote.change.value.startsWith("-") ? "" : "+"}
{formatMoney(props.quote.change)}
{props.quote.changePercent === null
? ""
: ` (${formatDecimal(props.quote.changePercent)} %)`}
</Text>
)}
{props.quote?.previousCloseDate
? (
<Text c="dimmed" size="xs">
{t("stock.changeBase", {
date: formatDate(props.quote.previousCloseDate),
})}
</Text>
)
: (
<Text c="dimmed" size="xs">
{t("stock.noComparison")}
</Text>
)}
<Group gap="sm" mt="sm">
<InstrumentQuoteStatus quote={props.quote} />
</Group>
{!!props.quote?.unavailable.length && (
<Text c="dimmed" mt="xs" size="xs">
{t("stock.partialQuote")}
</Text>
)}
{!!props.quoteState.refreshing && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
</div>
);
}
return quoteContent;
};
export { StockQuote };
@@ -7,11 +7,17 @@ import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { import { apiQueryClient, type paths, useResourceQuery } from "./index";
type Portfolio,
portfolioQueryOptions, type Portfolio = NonNullable<paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
usePortfolio,
} from "./usePortfolio"; function resourceOptions(accountId: string) {
return apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId } } });
}
function usePrivateResource(accountId: string) {
return useResourceQuery({ ...resourceOptions(accountId), meta: { accessGroup: "brokerage" } });
}
const snapshot: Portfolio = { const snapshot: Portfolio = {
cashBalances: [], cashBalances: [],
@@ -52,7 +58,7 @@ describe("persistent resource query lifecycle", () => {
), ),
); );
const { queryClient, wrapper } = createQueryWrapper(); const { queryClient, wrapper } = createQueryWrapper();
const { result } = renderHook(() => usePortfolio("a"), { wrapper }); const { result } = renderHook(() => usePrivateResource("a"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
await act(() => result.current.observe("new")); await act(() => result.current.observe("new"));
await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new")); await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
@@ -60,7 +66,7 @@ describe("persistent resource query lifecycle", () => {
expect(modes).toEqual([null, "observe", "observe"]); expect(modes).toEqual([null, "observe", "observe"]);
expect(queryClient.getQueryCache().getAll()).toHaveLength(1); expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
expect( expect(
queryClient.getQueryData(portfolioQueryOptions("a").queryKey), queryClient.getQueryData(resourceOptions("a").queryKey),
).toMatchObject({ data: snapshot, meta: { stateVersion: "new" } }); ).toMatchObject({ data: snapshot, meta: { stateVersion: "new" } });
}); });
it("does not convert missing or failed source data to empty financial success", async () => { it("does not convert missing or failed source data to empty financial success", async () => {
@@ -79,7 +85,7 @@ describe("persistent resource query lifecycle", () => {
), ),
); );
const { result } = renderHook(() => usePortfolio("a"), { const { result } = renderHook(() => usePrivateResource("a"), {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
}); });
@@ -109,7 +115,7 @@ describe("persistent resource query lifecycle", () => {
), ),
); );
const { result } = renderHook(() => usePortfolio("a"), { const { result } = renderHook(() => usePrivateResource("a"), {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
}); });
@@ -186,7 +192,7 @@ describe("persistent resource query lifecycle", () => {
const { wrapper } = createQueryWrapper(); const { wrapper } = createQueryWrapper();
const { result } = renderHook( const { result } = renderHook(
() => ({ a: usePortfolio("a"), b: usePortfolio("b") }), () => ({ a: usePrivateResource("a"), b: usePrivateResource("b") }),
{ wrapper }, { wrapper },
); );
+27 -20
View File
@@ -1,14 +1,13 @@
import { import {
type QueryClient, type QueryClient,
type QueryFunctionContext, type QueryFunctionContext,
type QueryKey,
useQueryClient, useQueryClient,
type UseQueryOptions, type UseQueryOptions,
type UseQueryResult, type UseQueryResult,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useEffect } from "react"; import { useEffect } from "react";
import type { components } from "./generated/schema"; import type { components, paths } from "./generated/schema";
import { useAuthenticatedQuery } from "./authenticated-query"; import { useAuthenticatedQuery } from "./authenticated-query";
import { getAuthenticatedSessionSnapshot } from "./client"; import { getAuthenticatedSessionSnapshot } from "./client";
@@ -23,6 +22,19 @@ type Resource = {
meta: InvestmentResourceMeta; meta: InvestmentResourceMeta;
}; };
type ResourcePath = {
[P in keyof paths]: paths[P]["get"] extends {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers -- HTTP success response in the generated contract.
responses: { 200: { content: { "application/json": Resource } } };
} ? P : never;
}[keyof paths];
type ResourceQueryKey = readonly [
"get",
ResourcePath,
{ params?: { path?: object; query?: { sync?: "observe" } } }?,
];
function invalidateResourceAccess( function invalidateResourceAccess(
client: QueryClient, client: QueryClient,
group: string, group: string,
@@ -58,6 +70,17 @@ function invalidateResourceAccess(
}); });
} }
// Keep endpoint and parameters intact. The generated queryFn accepts the
// optional sync mode even for a cache key originally created without init.
function observationKey<K extends ResourceQueryKey>(key: K): K {
const [method, path, init] = key;
return [method, path, {
...init,
params: { ...init?.params, query: { ...init?.params?.query, sync: "observe" } },
}] as unknown as K;
}
function unwrapResourceResult<T extends Resource, E>( function unwrapResourceResult<T extends Resource, E>(
result: UseQueryResult<T, E>, result: UseQueryResult<T, E>,
) { ) {
@@ -86,7 +109,7 @@ function unwrapResourceResult<T extends Resource, E>(
} }
// Observation changes transport mode, never the generated cache identity. // Observation changes transport mode, never the generated cache identity.
function useResourceQuery<T extends Resource, E, K extends QueryKey>( function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>(
options: UseQueryOptions<T, E, T, K>, options: UseQueryOptions<T, E, T, K>,
) { ) {
const client = useQueryClient(); const client = useQueryClient();
@@ -137,23 +160,7 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
return client.query({ return client.query({
...options, ...options,
queryFn: async (context: QueryFunctionContext<K>) => { queryFn: async (context: QueryFunctionContext<K>) => {
const [method, path, input] = context.queryKey; const queryKey = observationKey(context.queryKey);
const init = input as
undefined
| { params?: { query?: Record<string, unknown> } };
const queryKey = [
method,
path,
{
...init,
params: {
...init?.params,
query: { ...init?.params?.query, sync: "observe" },
},
},
] as unknown as K;
const response = await queryFunction({ ...context, queryKey }); const response = await queryFunction({ ...context, queryKey });
+1
View File
@@ -8,3 +8,4 @@ export {
formatSnapshotTime, formatSnapshotTime,
instrumentLogoUrl, instrumentLogoUrl,
} from "./display"; } from "./display";
export { useUrlPeriod } from "./useUrlPeriod";
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from "react";
import { useSearchParams } from "react-router";
import { isValidPeriod } from "./calendar";
function useUrlPeriod() {
const [parameters, setParameters] = useSearchParams();
const from = parameters.get("from");
const to = parameters.get("to");
const isValid = isValidPeriod(from, to);
useEffect(() => {
if (isValid || (!parameters.has("from") && !parameters.has("to"))) return;
const normalized = new URLSearchParams(parameters);
normalized.delete("from");
normalized.delete("to");
setParameters(normalized, { replace: true });
}, [isValid, parameters, setParameters]);
return {
from: isValid ? from : null,
parameters,
setParameters,
to: isValid ? to : null,
};
}
export { useUrlPeriod };
+1 -1
View File
@@ -8,9 +8,9 @@ import * as UiLoadingStories from "../../packages/ui/src/LoadingSkeleton/Loading
import * as StateStories from "../../packages/ui/src/StateView/StateView.stories"; import * as StateStories from "../../packages/ui/src/StateView/StateView.stories";
import * as AccountStories from "../entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories"; import * as AccountStories from "../entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories";
import * as EventStories from "../entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories"; import * as EventStories from "../entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories";
import * as RefreshStories from "../features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories";
import * as IncomePeriodStories from "../pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories"; import * as IncomePeriodStories from "../pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories";
import * as LoadingStories from "../pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.stories"; import * as LoadingStories from "../pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.stories";
import * as RefreshStories from "../pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories";
import * as PositionStories from "../pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories"; import * as PositionStories from "../pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories";
import * as OperationStories from "../pages/card/ui/OperationForm/OperationForm.stories"; import * as OperationStories from "../pages/card/ui/OperationForm/OperationForm.stories";
+1 -1
View File
@@ -59,7 +59,7 @@ export default defineConfig({
], ],
provider: playwright(), provider: playwright(),
}, },
include: ["src/**/*.browser.test.tsx"], include: ["src/**/*.browser.test.tsx", "packages/*/src/**/*.browser.test.tsx"],
name: "browser", name: "browser",
}, },
}, },