refactor: clarify frontend module ownership
This commit is contained in:
@@ -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/сценарии целиком, если задача затрагивает один исход.
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { useCurrentRoute } from "./useCurrentRoute";
|
||||||
@@ -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 };
|
||||||
@@ -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([])),
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 ?? ""} />;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 }>,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
|
||||||
])}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+3
-1
@@ -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>
|
||||||
@@ -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}/`))
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+52
@@ -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: "Ошибка синхронизации сохраняет суммы",
|
||||||
|
};
|
||||||
+8
-7
@@ -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);
|
||||||
|
|||||||
+5
-10
@@ -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),
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
-58
@@ -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: "Ошибка синхронизации сохраняет суммы",
|
|
||||||
};
|
|
||||||
-55
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
+16
-10
@@ -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 },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export {
|
|||||||
formatSnapshotTime,
|
formatSnapshotTime,
|
||||||
instrumentLogoUrl,
|
instrumentLogoUrl,
|
||||||
} from "./display";
|
} from "./display";
|
||||||
|
export { useUrlPeriod } from "./useUrlPeriod";
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user