refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
@@ -31,12 +31,15 @@ import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
const Navigation: FC = () => {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output aria-label="URL">{location.search}</output>
<button onClick={() => navigate(-1)} type="button">
History back
</button>
<button onClick={() => navigate(1)} type="button">
History forward
</button>
@@ -55,10 +58,12 @@ describe("Upcoming event date filters", () => {
server.use(
http.all("*/api/v1/*", ({ request }) => {
requests.push(request.url);
return new HttpResponse(null, { status: 503 });
}),
);
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
@@ -68,6 +73,7 @@ describe("Upcoming event date filters", () => {
</QueryClientProvider>
),
});
await new Promise(resolve => setTimeout(resolve, 0));
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
@@ -91,20 +97,25 @@ describe("Upcoming event date filters", () => {
server.use(
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
null,
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed",
},
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
}),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
@@ -113,6 +124,7 @@ describe("Upcoming event date filters", () => {
);
const period = Object.fromEntries(new URLSearchParams(search));
const queryClient = createTestQueryClient();
const { result } = renderHook(
() => useAccountEventsPageViewModel("all"),
{
@@ -125,6 +137,7 @@ describe("Upcoming event date filters", () => {
),
},
);
await waitFor(() => expect(result.current.state).toBe("error"));
expect(requested).toEqual([`?${search}`]);
expect(syncRequests).toEqual([]);
@@ -153,6 +166,7 @@ describe("Upcoming event date filters", () => {
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => {
searches.push(new URL(request.url).search);
return HttpResponse.json(resource({ events: [] }));
},
),
@@ -171,6 +185,7 @@ describe("Upcoming event date filters", () => {
initialIndex={0}
>
<Navigation />
<Routes>
<Route
element={<AccountEventsPage />}
@@ -182,6 +197,7 @@ describe("Upcoming event date filters", () => {
</QueryClientProvider>,
);
await screen.findByLabelText("Период");
for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) {
fireEvent.click(
screen.getByRole("button", { name: "History forward" }),
@@ -192,6 +208,7 @@ describe("Upcoming event date filters", () => {
);
await screen.findByLabelText("Период");
}
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("URL")).toHaveTextContent(
@@ -227,6 +244,7 @@ describe("Upcoming event date filters", () => {
initialEntries={[`/accounts/${accountId}/events?from=2099-02-01`]}
>
<Navigation />
<Routes>
<Route
element={<AccountEventsPage />}
@@ -258,6 +276,7 @@ describe("Upcoming event date filters", () => {
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => {
searches.push(new URL(request.url).search);
return HttpResponse.json(
resource({
accountId,
@@ -278,6 +297,7 @@ describe("Upcoming event date filters", () => {
]}
>
<Navigation />
<Routes>
<Route
element={<AccountEventsPage />}
@@ -333,6 +353,7 @@ it("retains the previous complete forecast and timestamp when its source refresh
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const events = [
{
date: "2026-10-01",
@@ -341,16 +362,19 @@ it("retains the previous complete forecast and timestamp when its source refresh
type: "coupon",
},
];
const snapshot = resource(
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
{ sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } },
);
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json(snapshot),
),
);
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageViewModel("all"), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
@@ -358,6 +382,7 @@ it("retains the previous complete forecast and timestamp when its source refresh
</QueryClientProvider>
),
});
await waitFor(() =>
expect(result.current).toMatchObject({
events,
@@ -37,7 +37,8 @@ function renderPage() {
describe("AccountEventsPage", () => {
beforeEach(() => {
refresh.mockClear();
query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
query.mockReturnValue(
{
data: {
accountId: "account",
@@ -51,8 +52,8 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
} as /* eslint-enable unicorn/no-null */ never,
);
});
it("passes the selected route account to the query", () => {
@@ -72,7 +73,8 @@ describe("AccountEventsPage", () => {
});
it("keeps the loading fallback when no result or account is available", () => {
query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
query.mockReturnValue(
{
data: undefined,
isError: false,
@@ -82,8 +84,8 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
} as /* eslint-enable unicorn/no-null */ never,
);
renderPage();
expect(query).toHaveBeenCalledWith("", {});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
@@ -93,7 +95,8 @@ describe("AccountEventsPage", () => {
});
it("renders events and marks an offer without an amount", () => {
query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
query.mockReturnValue(
{
data: {
accountId: "account",
@@ -132,8 +135,8 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
} as /* eslint-enable unicorn/no-null */ never,
);
renderPage();
expect(screen.getByText("1 250 ₽")).toBeInTheDocument();
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
@@ -180,7 +183,8 @@ describe("AccountEventsPage", () => {
});
it("shows a structural busy state before events are available", () => {
query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
query.mockReturnValue(
{
data: undefined,
isError: false,
@@ -190,8 +194,8 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
}
/* eslint-enable unicorn/no-null */) as never);
} as /* eslint-enable unicorn/no-null */ never,
);
renderPage();
@@ -10,7 +10,14 @@ type AccountEventsPageProps = { accountId?: string };
const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
return viewModel.state === "error" ? <AccountEventsPageError {...viewModel} /> : <AccountEventsPageLoaded {...viewModel} />;
return viewModel.state === "error"
? (
<AccountEventsPageError {...viewModel} />
)
: (
<AccountEventsPageLoaded {...viewModel} />
);
};
export { AccountEventsPage };
@@ -1,12 +1,12 @@
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection";
import { isValidPeriod } from "@/shared/lib";
type AccountEventsPageViewModel
= | {
@@ -38,8 +38,15 @@ function useAccountEventsPageViewModel(
const urlFrom = searchParameters.get("from");
const urlTo = searchParameters.get("to");
const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = isValidUrlPeriod ? urlFrom : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const appliedTo = isValidUrlPeriod ? urlTo : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const appliedFrom = isValidUrlPeriod
? urlFrom
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
const appliedTo = isValidUrlPeriod
? urlTo
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
const [draftFrom, setDraftFrom] = useState<null | string>(appliedFrom);
const [draftTo, setDraftTo] = useState<null | string>(appliedTo);
useEffect(() => {
@@ -49,19 +56,23 @@ function useAccountEventsPageViewModel(
normalized.delete("to");
setSearchParameters(normalized, { replace: true });
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
const [previousPeriod, setPreviousPeriod] = useState([
appliedFrom,
appliedTo,
]);
if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) {
setPreviousPeriod([appliedFrom, appliedTo]);
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
}
const events = useBrokerageEvents(accountId, {
...(appliedFrom && { from: appliedFrom }),
...(appliedTo && { to: appliedTo }),
});
const sync = useScreenSync(
{
accountId,
@@ -71,12 +82,15 @@ function useAccountEventsPageViewModel(
},
[events],
);
if (events.isLoading) return { state: "loading" };
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParameters({});
};
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
return {
onReset,
@@ -86,6 +100,7 @@ function useAccountEventsPageViewModel(
state: "error",
};
if (!events.data) return { state: "loading" };
return {
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo)
@@ -115,14 +130,4 @@ function useAccountEventsPageViewModel(
};
}
const calendarDate = z.iso.date();
function isValidPeriod(from: null | string, to: null | string) {
return (
[from, to].every(
date => date === null || calendarDate.safeParse(date).success,
)
&& (!from || !to || from <= to)
);
}
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
return (
<StateView text={t("events.emptyText")} title={t("events.emptyTitle")} />
);
@@ -8,11 +8,13 @@ type AccountEventsPageErrorProps = { onReset: () => void; retry: () => void };
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation();
return (
<StateView
action={(
<Group>
<Button onClick={props.retry}>{t("events.retry")}</Button>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
@@ -10,7 +10,6 @@ import {
Title,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useTranslation } from "react-i18next";
import { BrokerageEventCard } from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib";
@@ -18,82 +17,94 @@ import { formatSnapshotTime } from "@/shared/lib";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
import { useAccountEventsPageLoadedVm } from "./AccountEventsPageLoaded.vm";
type AccountEventsPageLoadedProps = Extract<
AccountEventsPageViewModel,
{ state: "loaded" }
>;
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { t } = useTranslation();
function renderEvents() {
if (props.events.length > 0) {
return (
<Stack gap="sm">
{props.events.map(event => (
<BrokerageEventCard
event={event}
key={JSON.stringify([
event.account?.id,
event.type,
event.date,
event.instrumentUid,
event.figi,
event.ticker,
event.logoName,
])}
/>
))}
</Stack>
);
}
if (props.partial) {
return;
}
return <AccountEventsPageEmpty />;
const AccountEventsPageLoadedEvents: FC<AccountEventsPageLoadedProps> = (
props,
) => {
if (props.events.length > 0) {
return (
<Stack gap="sm">
{props.events.map(event => (
<BrokerageEventCard
event={event}
key={JSON.stringify([
event.account?.id,
event.type,
event.date,
event.instrumentUid,
event.figi,
event.ticker,
event.logoName,
])}
/>
))}
</Stack>
);
}
if (props.partial) {
return;
}
return <AccountEventsPageEmpty />;
};
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { onPeriodChange, t } = useAccountEventsPageLoadedVm(props);
return (
<Stack gap="lg">
{!!(props.lastSuccessfulAt) && ((
{!!props.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
))}
)}
<Title order={1}>{t("events.title")}</Title>
<Button disabled={props.refreshing} onClick={props.retry} w="fit-content">
{t("investment.refresh")}
</Button>
{!!(props.partial) && ((
{!!props.partial && (
<Alert role="status">{t("investment.partial")}</Alert>
))}
{!!(props.refreshing) && ((
)}
{!!props.refreshing && (
<Progress animated aria-label={t("accounts.refreshing")} value={100} />
))}
{!!(props.refreshError) && ((
)}
{!!props.refreshError && (
<Alert aria-live="polite" color="yellow" role="status">
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button onClick={props.retry} variant="subtle">
{t("accounts.retry")}
</Button>
</Alert>
))}
)}
<Text c="dimmed">{t("events.hint")}</Text>
<Group align="end">
<DatePickerInput
allowSingleDateInRange
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
onChange={([from, to]) => {
props.onDraftFromChange(from);
props.onDraftToChange(to);
}}
onChange={onPeriodChange}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
returnFocus: true,
@@ -103,15 +114,18 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
valueFormat="DD.MM.YYYY"
/>
</Group>
<Group>
<Button disabled={!props.canApply} onClick={props.onApply}>
{t("ledger.apply")}
</Button>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
{renderEvents()}
<AccountEventsPageLoadedEvents {...props} />
</Stack>
);
};
@@ -0,0 +1,24 @@
import { useTranslation } from "react-i18next";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
type AccountEventsPageLoadedProps = Extract<
AccountEventsPageViewModel,
{ state: "loaded" }
>;
function useAccountEventsPageLoadedVm(props: AccountEventsPageLoadedProps) {
const { t } = useTranslation();
const onPeriodChange: (value: [null | string, null | string]) => void = ([
from,
to,
]) => {
props.onDraftFromChange(from);
props.onDraftToChange(to);
};
return { onPeriodChange, t };
}
export { useAccountEventsPageLoadedVm };
@@ -8,18 +8,25 @@ const skeletonRowIds = ["first", "second", "third", "fourth"];
const AccountEventsPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="40%" />
<Skeleton height={18} width="70%" />
<Group>
<Skeleton height={60} width={220} />
<Skeleton height={60} width={220} />
</Group>
<Group>
<Skeleton height={36} width={116} />
<Skeleton height={36} width={108} />
</Group>
{skeletonRowIds.map(id => (
<Skeleton height={82} key={id} radius="md" />
))}
@@ -0,0 +1,31 @@
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
import { moscowDate, parseDraftDate } from "./period";
const draftDate = z
.string()
.refine(value => parseDraftDate(value) !== null, {
error: validationMessage("income.validation.date"),
})
.refine(
(value) => {
const date = parseDraftDate(value);
return !date || date <= moscowDate(new Date());
},
{ error: validationMessage("income.validation.future") },
);
const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine(
({ from, to }) => {
const start = parseDraftDate(from);
const end = parseDraftDate(to);
return !start || !end || start <= end;
},
{ path: ["to"], ...validationMessage("income.validation.period")({}) },
);
export { incomeFormSchema };
+13 -3
View File
@@ -7,12 +7,14 @@ const datePattern = /^\d{4}-\d{2}-\d{2}$/;
function formatDraftDate(value: string): string {
const [year, month, day] = value.split("-", DATE_PART_COUNT);
return `${day}.${month}.${year}`;
}
function isDate(value: string): boolean {
if (!datePattern.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return (
Number.isFinite(parsed.valueOf())
&& parsed.toISOString().slice(0, ISO_DATE_LENGTH) === value
@@ -26,19 +28,27 @@ function moscowDate(value: Date): string {
timeZone: "Europe/Moscow",
year: "numeric",
}).formatToParts(value);
const part = (type: string) =>
parts.find(item => item.type === type)?.value;
return `${part("year")}-${part("month")}-${part("day")}`;
}
function parseDraftDate(value: string): null | string {
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return /* eslint-disable-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date. */ null;
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value))
// eslint-disable-next-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date.
return null;
const [day, month, year] = value.split(".", DATE_PART_COUNT);
const date = `${year}-${month}-${day}`;
return isDate(date) ? date : /* eslint-disable-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date. */ null;
return isDate(date)
? date
: null; // eslint-disable-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date.
}
export { formatDraftDate,
export {
formatDraftDate,
type IncomePeriod as IncomeForm,
type IncomePeriod,
isDate,
@@ -1,6 +1,5 @@
import type { FC } from "react";
import "dayjs/locale/ru";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -13,6 +12,7 @@ import {
waitFor,
within,
} from "@testing-library/react";
import "dayjs/locale/ru";
import { http, HttpResponse } from "msw";
import {
MemoryRouter,
@@ -38,49 +38,50 @@ import {
} from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
const analytics = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accountId: "account-1",
bucketGranularity: "day",
buckets: [
{
period: { from: "2026-08-01", to: "2026-08-01" },
totalsByCurrency: [summary("RUB", "10", "2")],
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const analytics
= {
accountId: "account-1",
bucketGranularity: "day",
buckets: [
{
period: { from: "2026-08-01", to: "2026-08-01" },
totalsByCurrency: [summary("RUB", "10", "2")],
},
],
calculatedAt: "2026-08-27T00:00:00Z",
entries: [
entry(),
{
...entry(),
id: "dividend-1",
payment: money("RUB", "2"),
type: "OPERATION_TYPE_DIVIDEND",
},
],
instruments: [
{
coupon: money("RUB", "10"),
displayName: "BOND",
dividend: money("RUB", "2"),
figi: "FIGI",
instrumentType: "bond",
instrumentUid: null,
key: "income-group-opaque-v2",
receiptCount: 2,
ticker: "BOND",
total: money("RUB", "12"),
},
],
period: { from: "2026-08-01", to: "2026-08-27" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [comparison("RUB")],
},
],
calculatedAt: "2026-08-27T00:00:00Z",
entries: [
entry(),
{
...entry(),
id: "dividend-1",
payment: money("RUB", "2"),
type: "OPERATION_TYPE_DIVIDEND",
},
],
instruments: [
{
coupon: money("RUB", "10"),
displayName: "BOND",
dividend: money("RUB", "2"),
figi: "FIGI",
instrumentType: "bond",
instrumentUid: null,
key: "income-group-opaque-v2",
receiptCount: 2,
ticker: "BOND",
total: money("RUB", "12"),
},
],
period: { from: "2026-08-01", to: "2026-08-27" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [comparison("RUB")],
},
totalsByCurrency: [summary("RUB", "10", "2")],
yearComparison: null,
}
/* eslint-enable unicorn/no-null */) satisfies IncomeAnalytics;
totalsByCurrency: [summary("RUB", "10", "2")],
yearComparison: null,
} satisfies /* eslint-enable unicorn/no-null */ IncomeAnalytics;
function comparison(currency: string) {
return {
@@ -92,7 +93,8 @@ function comparison(currency: string) {
}
function entry(): IncomeAnalytics["entries"][number] {
return (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
return (
{
accruedInterest: null,
category: "income",
@@ -111,7 +113,8 @@ function entry(): IncomeAnalytics["entries"][number] {
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
}
/* eslint-enable unicorn/no-null */);
/* eslint-enable unicorn/no-null */
);
}
function money(currency: string, value: string) {
@@ -125,6 +128,7 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
refresh: () => Promise.resolve(true),
});
const queryClient = createTestQueryClient();
const view = render(
<MantineProvider env="test">
<DatesProvider settings={{ locale: "ru" }}>
@@ -133,11 +137,13 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
initialEntries={[`/accounts/account-1/income?${search}`]}
>
<LocationProbe />
<Routes>
<Route
element={<AccountIncomePage />}
path="/accounts/:accountId/income"
/>
<Route
element={<h1>Events</h1>}
path="/accounts/:accountId/events"
@@ -148,6 +154,7 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
</DatesProvider>
</MantineProvider>,
);
return { ...view, queryClient };
}
@@ -171,10 +178,12 @@ describe("AccountIncomePage", () => {
server.use(
http.all("*/api/v1/*", ({ request }) => {
requests.push(request.url);
return new HttpResponse(null, { status: 503 });
}),
);
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
@@ -184,6 +193,7 @@ describe("AccountIncomePage", () => {
</QueryClientProvider>
),
});
await new Promise(resolve => setTimeout(resolve, 0));
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
@@ -198,21 +208,26 @@ describe("AccountIncomePage", () => {
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
null,
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed",
},
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
},
),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
@@ -243,6 +258,7 @@ describe("AccountIncomePage", () => {
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(resource(analytics));
},
),
@@ -255,18 +271,21 @@ describe("AccountIncomePage", () => {
it("preserves every repeated-ID payment when an open card updates", async () => {
const first = { ...entry(), description: "Выплата A", id: "same-id" };
const second = {
...entry(),
description: "Выплата B",
id: "same-id",
payment: money("RUB", "-2"),
};
const third = {
...entry(),
description: "Выплата C",
id: "other-id",
payment: money("RUB", "1"),
};
function breakdown(entries: IncomeAnalytics["entries"], total: string) {
const display = toIncomeDisplayModel({
...analytics,
@@ -281,19 +300,24 @@ describe("AccountIncomePage", () => {
},
],
});
expect(display).not.toBeNull();
return (
<MantineProvider env="test">
<IncomePayments entries={display?.entries ?? []} />
</MantineProvider>
);
}
const { rerender } = render(breakdown([first, second, third], "9"));
const panel = await screen.findByRole("region", { name: "Выплаты" });
const payments = () =>
within(panel)
.getAllByText(/^Выплата [ABC]$/)
.map(element => element.textContent);
expect(payments()).toEqual(["Выплата A", "Выплата B", "Выплата C"]);
rerender(breakdown([third, first, second], "9"));
@@ -323,6 +347,7 @@ describe("AccountIncomePage", () => {
analytics.entries[1],
],
};
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(response)),
@@ -409,8 +434,9 @@ describe("AccountIncomePage", () => {
});
it("renders the signed drill-down using opaque membership rather than instrument identity", async () => {
const response = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const response
= {
...analytics,
entries: [
{
@@ -436,8 +462,9 @@ describe("AccountIncomePage", () => {
total: money("RUB", "8"),
},
],
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(response)),
@@ -486,9 +513,11 @@ describe("AccountIncomePage", () => {
expect(eventsRequest).not.toHaveBeenCalled();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
const year = screen.getByRole("region", { name: "Годом ранее" });
const selected = within(year).getByRole("region", {
name: "01.08.2026 – 27.08.2026",
});
expect(
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
).toBeInTheDocument();
@@ -502,13 +531,17 @@ describe("AccountIncomePage", () => {
).toBeInTheDocument();
expect(screen.queryByText("Сравнения")).not.toBeInTheDocument();
expect(screen.queryByText("RUB", { exact: true })).not.toBeInTheDocument();
for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) {
expect(within(selected).getByText(amount)).toBeInTheDocument();
}
const difference = within(year).getByRole("region", { name: "Разница" });
for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) {
expect(within(difference).getByText(amount)).toBeInTheDocument();
}
expect(within(difference).getByText("+900 %")).toBeInTheDocument();
expect(within(difference).getByText("+1 100 %")).toBeInTheDocument();
expect(within(difference).queryByText("нет базы")).not.toBeInTheDocument();
@@ -523,14 +556,13 @@ describe("AccountIncomePage", () => {
const dependencies = [
{ ...resource(analytics).meta, role: "selected" as const },
{ ...resource(analytics).meta, role: "previous" as const },
(
{
...resource(null).meta,
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
}
),
{
...resource(null).meta,
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { dependencies })),
@@ -572,16 +604,47 @@ describe("income comparison display", () => {
["1", "3", "+33,3 %"],
["0.01", "0.04", "+25 %"],
["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"],
["10", "0", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
["0", "0", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
["10", "-0.00", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
["10", "-100", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
["9".repeat(400), "1", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
["1", "9".repeat(400), /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
[
"10",
"0",
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
],
[
"0",
"0",
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
],
[
"10",
"-0.00",
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
],
[
"10",
"-100",
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
],
[
"9".repeat(400),
"1",
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
],
[
"1",
"9".repeat(400),
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
],
])("formats change %s against base %s", (difference, base, expected) => {
const value = comparison("RUB");
value.difference.coupon = money("RUB", difference);
value.compared.coupon = money("RUB", base);
const display = toIncomeDisplayModel({
...analytics,
yearComparison: {
@@ -589,10 +652,13 @@ describe("income comparison display", () => {
totalsByCurrency: [value],
},
});
const result = display?.comparison?.values[0];
expect(result?.percentages.coupon?.replaceAll("\u{A0}", " ") ?? /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null).toBe(
expected,
);
expect(
result?.percentages.coupon?.replaceAll("\u{A0}", " ")
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
?? null,
).toBe(expected);
expect(result?.difference.coupon.value).toBe(difference);
expect(result?.compared.coupon.value).toBe(base);
});
@@ -606,6 +672,7 @@ describe("income comparison display", () => {
totalsByCurrency: [comparison("RUB"), comparison("USD")],
},
});
expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe(
"01.12.2025 – 09.01.2026",
);
@@ -622,9 +689,11 @@ describe("income comparison display", () => {
const LocationProbe: FC = () => {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output data-testid="location">{location.search}</output>
<button onClick={() => navigate(-1)} type="button">
Back
</button>
@@ -639,6 +708,7 @@ function respondWithRequestedPeriod(requests: string[]) {
({ request }) => {
const url = new URL(request.url);
requests.push(url.search);
return HttpResponse.json(
resource({
...analytics,
@@ -753,12 +823,15 @@ describe("income period and snapshot boundary", () => {
await screen.findByLabelText("Период");
selectPeriod();
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
const { promise: pending, resolve: release }
= Promise.withResolvers<void>();
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
async () => {
await pending;
return new HttpResponse(null, { status: 503 });
},
),
@@ -790,7 +863,8 @@ describe("income period and snapshot boundary", () => {
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
HttpResponse.json(
{
errorCode: null,
id: "income",
@@ -799,8 +873,9 @@ describe("income period and snapshot boundary", () => {
resources: [resource(null, { stateVersion: "2" }).meta],
scope: null,
state: "succeeded",
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
),
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { stateVersion: "2" })),
@@ -822,12 +897,15 @@ describe("income period and snapshot boundary", () => {
renderPage();
await screen.findByLabelText("Период");
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
const { promise: pending, resolve: release }
= Promise.withResolvers<void>();
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
async () => {
await pending;
return HttpResponse.json(
resource({
...analytics,
@@ -882,6 +960,7 @@ describe("income period and snapshot boundary", () => {
total: money("RUB", "-3"),
},
];
const display = toIncomeDisplayModel({
...analytics,
entries: instruments.map(instrument => ({
@@ -892,6 +971,7 @@ describe("income period and snapshot boundary", () => {
instruments,
totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")],
});
render(
<MantineProvider env="test">
<IncomePayments entries={display?.entries ?? []} />
@@ -917,8 +997,11 @@ describe("income period and snapshot boundary", () => {
it("maps comparison notices by role regardless of dependency order or resource key", () => {
const selected = { ...resource(analytics).meta, role: "selected" as const };
const previous = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const previous
= {
...resource(null).meta,
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
@@ -927,16 +1010,16 @@ it("maps comparison notices by role regardless of dependency order or resource k
id: null,
state: "failed" as const,
},
}
/* eslint-enable unicorn/no-null */);
const year = (
{
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
}
);
};
/* eslint-enable unicorn/no-null */
const year = {
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
};
for (const dependencies of [
[selected, previous, year],
[year, selected, previous],
@@ -945,6 +1028,7 @@ it("maps comparison notices by role regardless of dependency order or resource k
...resource(analytics).meta,
dependencies,
});
expect(display?.previousNotice).toBe("comparisonFailed");
expect(display?.yearNotice).toBe("yearUnavailable");
expect(display?.previousComparison).not.toBeNull();
@@ -957,21 +1041,18 @@ it.each([false, true])(
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...analytics, previous: null, yearComparison: null }
/* eslint-enable unicorn/no-null */),
(
{
dependencies: legacy
? [
resource(null).meta,
resource(null).meta,
resource(null).meta,
]
: [],
}
),
{ ...analytics, previous: null, yearComparison: null },
/* eslint-enable unicorn/no-null */ {
dependencies: legacy
? [
resource(null).meta,
resource(null).meta,
resource(null).meta,
]
: [],
},
),
),
),
@@ -992,21 +1073,22 @@ it.each([false, true])(
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
const dependencies = (["year", "selected", "previous"] as const).map(
role => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
role =>
({
...resource(role === "selected" ? analytics : null).meta,
role,
}
/* eslint-enable unicorn/no-null */)),
}),
/* eslint-enable unicorn/no-null */
);
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...analytics, previous: null, yearComparison: null }
/* eslint-enable unicorn/no-null */),
{ dependencies },
{ ...analytics, previous: null, yearComparison: null },
/* eslint-enable unicorn/no-null */ { dependencies },
),
),
),
@@ -1031,20 +1113,26 @@ it("shows selected income before comparison coverage without inventing a zero ba
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "previous" as const },
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...resource(null).meta,
role: "year" as const,
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed" as const,
},
}
/* eslint-enable unicorn/no-null */),
{ ...resource(analytics).meta, role: "selected" as const },
{
...resource(analytics).meta,
role: "previous" as const,
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
},
{
...resource(null).meta,
role: "year" as const,
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed" as const,
},
},
/* eslint-enable unicorn/no-null */ {
...resource(analytics).meta,
role: "selected" as const,
},
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { dependencies })),
@@ -10,7 +10,14 @@ type AccountIncomePageProps = { accountId?: string };
const AccountIncomePage: FC<AccountIncomePageProps> = (props) => {
const viewModel = useAccountIncomePageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountIncomePageLoading />;
return viewModel.state === "error" ? <AccountIncomePageError {...viewModel} /> : <AccountIncomePageLoaded {...viewModel} />;
return viewModel.state === "error"
? (
<AccountIncomePageError {...viewModel} />
)
: (
<AccountIncomePageLoaded {...viewModel} />
);
};
export { AccountIncomePage };
@@ -2,13 +2,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection";
import { validationMessage } from "@/shared/i18n";
import { formatDate } from "@/shared/lib";
import { incomeFormSchema } from "../../model/income.schema";
import {
formatDraftDate,
type IncomeForm,
@@ -43,6 +42,7 @@ type AccountIncomePageViewModel
state: "error";
}
| { state: "loading" };
type ComparisonNotice
= | "comparisonFailed"
| "comparisonPending"
@@ -50,15 +50,18 @@ type ComparisonNotice
| "yearUnavailable";
type IncomeDisplayModel = {
comparison: null | {
comparedPeriod: string;
selectedPeriod: string;
values: (NonNullable<
IncomeAnalytics["yearComparison"]
>["totalsByCurrency"][number] & {
percentages: Record<"coupon" | "dividend" | "total", null | string>;
})[];
};
comparison: null
| {
comparedPeriod: string;
selectedPeriod: string;
values: (NonNullable<
IncomeAnalytics["yearComparison"]
>["totalsByCurrency"][number] & {
percentages: Record<"coupon"
| "dividend"
| "total", null | string>;
})[];
};
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
@@ -67,33 +70,13 @@ type IncomeDisplayModel = {
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
const draftDate = z
.string()
.refine(value => parseDraftDate(value) !== null, {
error: validationMessage("income.validation.date"),
})
.refine(
(value) => {
const date = parseDraftDate(value);
return !date || date <= moscowDate(new Date());
},
{ error: validationMessage("income.validation.future") },
);
const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine(
({ from, to }) => {
const start = parseDraftDate(from);
const end = parseDraftDate(to);
return !start || !end || start <= end;
},
{ path: ["to"], ...validationMessage("income.validation.period")({}) },
);
const useAccountIncomePageViewModel = (
scopeId?: string,
): AccountIncomePageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParameters, setSearchParameters] = useSearchParams();
const requested = useMemo(
() => ({
from: searchParameters.get("from") ?? "",
@@ -101,31 +84,39 @@ const useAccountIncomePageViewModel = (
}),
[searchParameters],
);
const period = useMemo(
() => (isValidPeriod(requested) ? requested : defaultPeriod()),
[requested],
);
const form = useForm<IncomeForm>({
defaultValues: draftPeriod(period),
resolver: zodResolver(incomeFormSchema),
});
const [resetRevision, setResetRevision] = useState(0);
const analytics = useIncomeAnalytics(accountId, period);
const sync = useScreenSync(
{ accountId, from: period.from, screen: "income", to: period.to },
[analytics],
);
useEffect(() => {
if (!isValidPeriod(requested)) setSearchParameters(period, { replace: true });
if (!isValidPeriod(requested))
setSearchParameters(period, { replace: true });
}, [period, requested, setSearchParameters]);
useEffect(() => {
form.reset(draftPeriod(period));
}, [form, period]);
if (analytics.isLoading || !analytics.data) {
return analytics.isError || analytics.meta?.sync.state === "failed" ? { period, retry: sync.retry, state: "error" } : { state: "loading" };
return analytics.isError || analytics.meta?.sync.state === "failed"
? { period, retry: sync.retry, state: "error" }
: { state: "loading" };
}
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
if (!display)
return {
@@ -133,11 +124,13 @@ const useAccountIncomePageViewModel = (
retry: () => analytics.observe(),
state: "error",
};
const onSubmit = (values: IncomeForm) => {
const from = parseDraftDate(values.from);
const to = parseDraftDate(values.to);
if (from && to) setSearchParameters({ from, to });
};
const onReset = () => {
const next = defaultPeriod();
form.reset(draftPeriod(next));
@@ -145,6 +138,7 @@ const useAccountIncomePageViewModel = (
setResetRevision(revision => revision + 1);
setSearchParameters(next, { replace: true });
};
return {
analytics: analytics.data,
display,
@@ -166,7 +160,10 @@ function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
if (!comparison) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
if (!comparison)
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
return null;
return {
comparedPeriod: formatComparisonPeriod(comparison.period),
selectedPeriod: formatComparisonPeriod(period),
@@ -192,11 +189,11 @@ function comparisonDisplay(
function comparisonNotice(
dependency:
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
| undefined,
NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number] | undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
@@ -204,6 +201,7 @@ function comparisonNotice(
function defaultPeriod(): IncomePeriod {
const today = moscowDate(new Date());
return { from: `${today.slice(0, ISO_MONTH_LENGTH)}-01`, to: today };
}
@@ -221,9 +219,14 @@ function formatIncomeChange(
): null | string {
// Display-only ratio; server-provided Money values remain exact decimal strings.
const base = Number(compared);
if (!Number.isFinite(base) || base <= 0) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
if (!Number.isFinite(base) || base <= 0)
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
return null;
const ratio = Number(difference) / base;
if (!Number.isFinite(ratio)) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
if (!Number.isFinite(ratio))
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
return null;
return new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
signDisplay: "exceptZero",
@@ -236,6 +239,7 @@ function isValidPeriod(period: IncomePeriod): period is IncomePeriod {
const from = new Date(`${period.from}T00:00:00Z`);
const to = new Date(`${period.to}T00:00:00Z`);
const today = new Date(`${moscowDate(new Date())}T00:00:00Z`);
return (
from.toISOString().slice(0, ISO_DATE_LENGTH) === period.from
&& to.toISOString().slice(0, ISO_DATE_LENGTH) === period.to
@@ -256,9 +260,11 @@ function toIncomeDisplayModel(
receiptCount: number;
}
>();
const currencies = new Set(
analytics.totalsByCurrency.map(({ currency }) => currency),
);
for (const instrument of analytics.instruments) {
if (
typeof instrument.key !== "string"
@@ -274,8 +280,10 @@ function toIncomeDisplayModel(
receiptCount: instrument.receiptCount,
});
}
const entries: IncomeDisplayModel["entries"] = [];
const occurrences = new Map<string, number>();
for (const entry of analytics.entries) {
if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim())
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
@@ -298,12 +306,16 @@ function toIncomeDisplayModel(
operation: entry,
});
}
for (const instrument of instruments.values()) {
if (instrument.includedCount !== instrument.receiptCount) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
if (instrument.includedCount !== instrument.receiptCount)
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
return null;
}
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
const year = meta?.dependencies?.find(({ role }) => role === "year");
return {
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
entries,
@@ -10,6 +10,7 @@ type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
const AccountIncomePageError: FC<ErrorProps> = (props) => {
const { t } = useTranslation();
return (
<StateView
action={<Button onClick={props.retry}>{t("income.retry")}</Button>}
@@ -25,45 +25,46 @@ import { IncomeSummary } from "./IncomeSummary.view";
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
const {
analytics,
display,
form,
onReset,
onSubmit,
refreshError,
refreshing,
retry,
} = props;
const AccountIncomePageLoadedSummary: FC<LoadedProps> = (props) => {
const { t } = useTranslation();
function renderSummary() {
if (props.partial) {
return <Alert role="status">{t("investment.partial")}</Alert>;
}
if (analytics.totalsByCurrency.length > 0) {
return <IncomeSummary totals={analytics.totalsByCurrency} />;
}
return (
<StateView
text={t("income.emptyText")}
title={t("income.emptyTitle")}
/>
);
const { analytics } = props;
if (props.partial) {
return <Alert role="status">{t("investment.partial")}</Alert>;
}
return analytics.totalsByCurrency.length > 0
? (
<IncomeSummary totals={analytics.totalsByCurrency} />
)
: (
<StateView text={t("income.emptyText")} title={t("income.emptyTitle")} />
);
};
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
const { display, form, onReset, onSubmit, refreshError, refreshing, retry }
= props;
const { t } = useTranslation();
return (
<Stack className={classes.page} gap="lg">
{!!(props.lastSuccessfulAt) && ((
{!!props.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
))}
)}
<Title order={1}>{t("income.title")}</Title>
<Button disabled={refreshing} onClick={retry} w="fit-content">
{t("investment.refresh")}
</Button>
<FormProvider {...form}>
<IncomePeriodForm
key={props.formRevision}
@@ -71,13 +72,16 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
onSubmit={form.handleSubmit(onSubmit)}
/>
</FormProvider>
{!!(refreshing) && ((
{!!refreshing && (
<Group aria-label={t("income.refreshing")} gap="xs" role="status">
<Loader size="sm" />
<Text>{t("income.refreshing")}</Text>
</Group>
))}
{!!(refreshError) && ((
)}
{!!refreshError && (
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="sm">
<Text>
@@ -87,24 +91,30 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
: "income.refreshError",
)}
</Text>
<Button onClick={retry} variant="default">
{t("income.retry")}
</Button>
</Stack>
</Alert>
))}
{renderSummary()}
)}
<AccountIncomePageLoadedSummary {...props} />
<Alert color="blue">{t("income.taxBoundary")}</Alert>
<IncomeComparisons
comparison={display.previousComparison}
notice={t(`income.${display.previousNotice}`)}
title={t("income.previousPeriod")}
/>
<IncomeComparisons
comparison={display.comparison}
notice={t(`income.${display.yearNotice}`)}
title={t("income.yearAgo")}
/>
<IncomePayments entries={display.entries} />
</Stack>
);
@@ -8,24 +8,34 @@ import classes from "../AccountIncomePage.module.css";
const AccountIncomePageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Stack gap="lg">
<Skeleton height={32} width="40%" />
<Paper p="md" radius="md" withBorder>
<Stack gap="sm">
<Skeleton height={20} width="20%" />
<div className={classes.fields}>
<Skeleton height={60} />
<Skeleton height={60} />
</div>
<Skeleton height={44} width="45%" />
</Stack>
</Paper>
<Skeleton height={100} radius="md" />
<Skeleton height={180} radius="md" />
<Skeleton height={24} width="25%" />
<Skeleton height={80} radius="md" />
<Skeleton height={80} radius="md" />
</Stack>
</LoadingSkeleton>
@@ -12,12 +12,16 @@ import classes from "../AccountIncomePage.module.css";
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]> & {
title: string;
};
type ComparisonValuesProps = {
kind: "compared" | "difference" | "selected";
kind: "compared"
| "difference"
| "selected";
label: string;
percentages?: ComparisonProps["values"][number]["percentages"];
values: ComparisonProps["values"][number]["selected"];
};
type IncomeComparisonsProps = {
comparison: IncomeDisplayModel["comparison"];
notice: string;
@@ -28,6 +32,7 @@ const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
return (
<Stack>
<Title order={2}>{props.title}</Title>
{props.comparison
? (
<Comparison {...props.comparison} title={props.title} />
@@ -45,6 +50,7 @@ const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
const Comparison: FC<ComparisonProps> = (props) => {
const { t } = useTranslation();
return (
<section aria-label={props.title} className={classes.comparison}>
<Stack gap="md">
@@ -61,11 +67,13 @@ const Comparison: FC<ComparisonProps> = (props) => {
label={props.selectedPeriod}
values={value.selected}
/>
<ComparisonValues
kind="compared"
label={props.comparedPeriod}
values={value.compared}
/>
<ComparisonValues
kind="difference"
label={t("income.difference")}
@@ -75,11 +83,12 @@ const Comparison: FC<ComparisonProps> = (props) => {
</div>
</Stack>
))}
{props.values.length === 0 && ((
{props.values.length === 0 && (
<Text c="gray.7" size="sm">
{t("income.comparisonEmpty")}
</Text>
))}
)}
</Stack>
</section>
);
@@ -87,46 +96,59 @@ const Comparison: FC<ComparisonProps> = (props) => {
const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
const { t } = useTranslation();
const data_tone
= props.kind === "difference"
? differenceTone(props.values.total.value)
: undefined;
return (
<section
aria-label={props.label}
className={classes.comparisonPanel}
data-kind={props.kind}
data-tone={
(props.kind === "difference") ? (differenceTone(props.values.total.value)) : undefined
}
data-tone={data_tone}
>
<Text className={classes.comparisonLabel} fw={600} size="sm">
{props.label}
</Text>
<dl className={classes.comparisonValues}>
{(["total", "coupon", "dividend"] as const).map((key) => {
const money = /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ props.values[key];
const percentage = /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ props.percentages?.[key];
const money
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
= props.values[key];
const percentage
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
= props.percentages?.[key];
const tone
= props.kind === "difference"
? differenceTone(money.value)
: undefined;
const appearanceClass
= key === "total"
? classes.comparisonTotal
: classes.comparisonDetail;
return (
<div
className={
key === "total"
? classes.comparisonTotal
: classes.comparisonDetail
}
key={key}
>
<div className={appearanceClass} key={key}>
<dt>{t(`income.${key}`)}</dt>
<dd data-tone={tone}>
<span>
{tone === "positive" ? "+" : ""}
{formatMoney(money)}
</span>
{!!(percentage) && ((
{!!percentage && (
<span className={classes.comparisonPercent}>
{percentage}
</span>
))}
)}
</dd>
</div>
);
@@ -136,8 +158,11 @@ const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
);
};
function differenceTone(value: string): "negative" | "neutral" | "positive" {
function differenceTone(value: string): "negative"
| "neutral"
| "positive" {
if (!/[1-9]/.test(value)) return "neutral";
return value.startsWith("-") ? "negative" : "positive";
}
@@ -10,13 +10,16 @@ import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import classes from "../AccountIncomePage.module.css";
type IncomePaymentsProps = { entries: LoadedProps["display"]["entries"] };
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
const IncomePayments: FC<IncomePaymentsProps> = (props) => {
const { t } = useTranslation();
return (
<Stack aria-label={t("income.payments")} component="section">
<Title order={2}>{t("income.payments")}</Title>
<Stack className={classes.entries} component="ul" gap="sm">
{props.entries.map(({ key, operation }) => (
<li key={key}>
@@ -8,6 +8,7 @@ import type { IncomeForm } from "../../../../model/period";
import { IncomePeriodForm } from "./IncomePeriodForm.view";
const period = { from: "01.08.2026", to: "27.08.2026" };
const errors = {
to: { message: "Синтетическая ошибка периода", type: "manual" },
};
@@ -19,8 +20,13 @@ const PeriodExample: FC<PeriodExampleProps> = (props) => {
defaultValues: period,
errors: props.invalid ? errors : undefined,
});
const { isSubmitSuccessful } = useFormState({ control: methods.control });
const submitted = isSubmitSuccessful ? methods.getValues() : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null;
const submitted = isSubmitSuccessful
? methods.getValues()
: null; // eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
return (
<FormProvider {...methods}>
<IncomePeriodForm
@@ -29,6 +35,7 @@ const PeriodExample: FC<PeriodExampleProps> = (props) => {
}}
onSubmit={methods.handleSubmit(() => {})}
/>
<output aria-live="polite">
{submitted
? `Применено: ${submitted.from} – ${submitted.to}`
@@ -54,7 +61,9 @@ const meta = {
} satisfies Meta<typeof PeriodExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const ValidationError: Story = {
render: () => <PeriodExample invalid />,
@@ -2,17 +2,9 @@ import type { FC, SubmitEventHandler } from "react";
import { Button, Paper, Stack, Text } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useState } from "react";
import { useController, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
formatDraftDate,
type IncomeForm,
moscowDate,
parseDraftDate,
} from "../../../../model/period";
import classes from "../../AccountIncomePage.module.css";
import { useIncomePeriodFormVm } from "./IncomePeriodForm.vm";
type IncomePeriodFormProps = {
onReset: () => void;
@@ -20,23 +12,20 @@ type IncomePeriodFormProps = {
};
const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
const { t } = useTranslation();
const {
control,
formState: { isSubmitting },
} = useFormContext<IncomeForm>();
const {
field: { onBlur: onFromBlur, onChange: onFromChange, ref: fromRef },
fieldState: { error: fromError },
} = useController({ control, name: "from" });
const {
field: { onBlur: onToBlur, onChange: onToChange },
fieldState: { error: toError },
} = useController({ control, name: "to" });
const [from, to] = useWatch({ control, name: ["from", "to"] });
const [today, setToday] = useState(() => moscowDate(new Date()));
const start = parseDraftDate(from);
const end = parseDraftDate(to);
end,
fromError,
fromRef,
isSubmitting,
onBlur2,
onPeriodChange,
popoverProps,
start,
t,
today,
toError,
} = useIncomePeriodFormVm();
return (
<Paper
component="form"
@@ -47,6 +36,7 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
>
<Stack gap="sm">
<Text fw={700}>{t("income.period")}</Text>
<div className={classes.fields}>
<DatePickerInput
allowSingleDateInRange
@@ -56,29 +46,21 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
error={fromError?.message ?? toError?.message}
label={t("income.period")}
maxDate={today}
onBlur={() => {
onFromBlur();
onToBlur();
}}
onChange={([from, to]) => {
onFromChange(from ? formatDraftDate(from) : "");
onToChange(to ? formatDraftDate(to) : "");
}}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
onOpen: () => setToday(moscowDate(new Date())),
returnFocus: true,
}}
onBlur={onBlur2}
onChange={onPeriodChange}
popoverProps={popoverProps}
ref={fromRef}
type="range"
value={[start, end]}
valueFormat="DD.MM.YYYY"
/>
</div>
<div className={classes.actions}>
<Button loading={isSubmitting} type="submit">
{t("income.apply")}
</Button>
<Button
disabled={isSubmitting}
onClick={props.onReset}
@@ -0,0 +1,69 @@
import { useState } from "react";
import { useController, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
formatDraftDate,
type IncomeForm,
moscowDate,
parseDraftDate,
} from "../../../../model/period";
function useIncomePeriodFormVm() {
const { t } = useTranslation();
const {
control,
formState: { isSubmitting },
} = useFormContext<IncomeForm>();
const {
field: { onBlur: onFromBlur, onChange: onFromChange, ref: fromRef },
fieldState: { error: fromError },
} = useController({ control, name: "from" });
const {
field: { onBlur: onToBlur, onChange: onToChange },
fieldState: { error: toError },
} = useController({ control, name: "to" });
const [from, to] = useWatch({ control, name: ["from", "to"] });
const [today, setToday] = useState(() => moscowDate(new Date()));
const start = parseDraftDate(from);
const end = parseDraftDate(to);
const onBlur2 = () => {
onFromBlur();
onToBlur();
};
const onPeriodChange: (value: [null | string, null | string]) => void = ([
from,
to,
]) => {
onFromChange(from ? formatDraftDate(from) : "");
onToChange(to ? formatDraftDate(to) : "");
};
const popoverProps = {
middlewares: { shift: { crossAxis: true } },
onOpen: () => setToday(moscowDate(new Date())),
returnFocus: true,
};
return {
end,
fromError,
fromRef,
isSubmitting,
onBlur2,
onPeriodChange,
popoverProps,
start,
t,
today,
toError,
};
}
export { useIncomePeriodFormVm };
@@ -11,24 +11,18 @@ type IncomeSummaryProps = { totals: IncomeAnalytics["totalsByCurrency"] };
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<Title order={2}>{t("income.summaryTitle")}</Title>
{props.totals.map(total => (
<Paper key={total.currency} p="md" radius="md" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
<Text c="dimmed" size="sm">
{t("income.coupon")}
:
{" "}
{formatMoney(total.coupon)}
{" "}
·
{" "}
{t("income.dividend")}
:
{" "}
{formatMoney(total.dividend)}
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(total.dividend)}
</Text>
</Paper>
))}
@@ -24,8 +24,10 @@ import { server } from "@/test/server";
import { AccountOperationsPage } from "./AccountOperationsPage";
const operation: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const operation: BrokerageOperation
= {
accruedInterest: null,
category: "trade",
commission: { currency: "RUB", value: "1" },
@@ -41,18 +43,21 @@ const operation: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixt
quantity: "2",
ticker: "SBER",
type: "OPERATION_TYPE_BUY",
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
const Navigation: FC = () => {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output aria-label="URL">{location.search}</output>
<button onClick={() => navigate(-1)} type="button">
History back
</button>
<button onClick={() => navigate(1)} type="button">
History forward
</button>
@@ -225,6 +230,7 @@ describe("AccountOperationsPage", () => {
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
searches.push(new URL(request.url).searchParams);
return HttpResponse.json(
resource({
hasNext: true,
@@ -249,6 +255,7 @@ describe("AccountOperationsPage", () => {
initialIndex={0}
>
<Navigation />
<Routes>
<Route
element={<AccountOperationsPage />}
@@ -262,6 +269,7 @@ describe("AccountOperationsPage", () => {
await screen.findByLabelText("Период");
expect(searches[0].get("from")).toBeNull();
expect(searches[0].get("to")).toBeNull();
for (const expected of [
"?categories=trade",
"?categories=trade",
@@ -276,6 +284,7 @@ describe("AccountOperationsPage", () => {
);
await screen.findByLabelText("Период");
}
expect(searches.at(-1)?.get("from")).toBe("2026-02-01T00:00:00.000Z");
expect(searches.at(-1)?.get("to")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
@@ -313,9 +322,11 @@ describe("AccountOperationsPage", () => {
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ hasNext: false, items: [], nextCursor: null }
/* eslint-enable unicorn/no-null */)),
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{ hasNext: false, items: [], nextCursor: null },
/* eslint-enable unicorn/no-null */
),
),
),
);
@@ -328,6 +339,7 @@ describe("AccountOperationsPage", () => {
]}
>
<Navigation />
<Routes>
<Route
element={<AccountOperationsPage />}
@@ -354,16 +366,19 @@ describe("AccountOperationsPage", () => {
({ request }) => {
const url = new URL(request.url);
searches.push(url.search);
return HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{
accountId: "account-1",
hasNext: !url.searchParams.get("cursor"),
items: [operation],
nextCursor: url.searchParams.get("cursor") ? null : "next-1",
observedAt: "2026-08-14T00:00:00Z",
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
},
),
@@ -394,16 +409,19 @@ describe("AccountOperationsPage", () => {
({ request }) => {
const url = new URL(request.url);
searches.push(url.search);
return HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{
accountId: "account-1",
hasNext: false,
items: [operation],
nextCursor: null,
observedAt: "2026-08-14T00:00:00Z",
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
},
),
@@ -431,16 +449,19 @@ describe("AccountOperationsPage", () => {
({ request }) => {
const url = new URL(request.url);
searches.push(url.search);
return HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{
accountId: "account-1",
hasNext: false,
items: [operation],
nextCursor: null,
observedAt: "2026-08-14T00:00:00Z",
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
},
),
@@ -484,17 +505,26 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
requested.push(Object.fromEntries(new URL(request.url).searchParams));
return HttpResponse.json(
resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
null,
{
sync: { errorCode: "TBANK_UNAVAILABLE", id: null, state: "failed" },
}
/* eslint-enable unicorn/no-null */)),
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed",
},
},
/* eslint-enable unicorn/no-null */
),
);
},
),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
@@ -529,16 +559,15 @@ it("does not label incomplete empty operations as a confirmed empty period", asy
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accountId: "account-1",
hasNext: false,
items: [],
nextCursor: null,
}
/* eslint-enable unicorn/no-null */),
{ availability: "partial" },
{
accountId: "account-1",
hasNext: false,
items: [],
nextCursor: null,
},
/* eslint-enable unicorn/no-null */ { availability: "partial" },
),
),
),
@@ -562,6 +591,7 @@ it("recovers an invalid cursor at the first page without clearing applied filter
{ code: "INVALID_OPERATIONS_CURSOR" },
{ status: 400 },
);
return HttpResponse.json(
resource({
hasNext: true,
@@ -14,7 +14,13 @@ const AccountOperationsPage: FC<AccountOperationsPageProps> = (props) => {
return <AccountOperationsPageLoading />;
}
return viewModel.state === "error" ? <AccountOperationsPageError {...viewModel} /> : <AccountOperationsPageLoaded {...viewModel} />;
return viewModel.state === "error"
? (
<AccountOperationsPageError {...viewModel} />
)
: (
<AccountOperationsPageLoaded {...viewModel} />
);
};
export { AccountOperationsPage };
@@ -1,7 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import {
type BrokerageOperation,
@@ -12,6 +11,7 @@ import {
} from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { isValidPeriod } from "@/shared/lib";
type AccountOperationsPageViewModel
= | {
@@ -51,30 +51,44 @@ const useAccountOperationsPageVm = (
const urlFrom = searchParameters.get("from");
const urlTo = searchParameters.get("to");
const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = isValidUrlPeriod ? urlFrom : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const appliedTo = isValidUrlPeriod ? urlTo : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const appliedFrom = isValidUrlPeriod
? urlFrom
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
const appliedTo = isValidUrlPeriod
? urlTo
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
const appliedCategories = operationCategories.filter(category =>
(searchParameters.get("categories") ?? "").split(",").includes(category),
);
const [draftFrom, setDraftFrom] = useState<null | string>(appliedFrom);
const [draftTo, setDraftTo] = useState<null | string>(appliedTo);
const [draftCategories, setDraftCategories]
= useState<OperationCategory[]>(appliedCategories);
const scopeKey = JSON.stringify([
accountId,
urlFrom,
urlTo,
appliedCategories,
]);
const [pagination, setPagination] = useState<{
cursors: string[];
scopeKey: string;
}>({ cursors: [], scopeKey });
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
const cursor = cursors.at(-1);
if (pagination.scopeKey !== scopeKey) {
setPagination({ cursors: [], scopeKey });
}
useEffect(() => {
if (isValidUrlPeriod) return;
const normalized = new URLSearchParams(searchParameters);
@@ -83,38 +97,52 @@ const useAccountOperationsPageVm = (
setSearchParameters(normalized, { replace: true });
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
const [previousSearch, setPreviousSearch] = useState(searchParameters);
if (previousSearch !== searchParameters) {
setPreviousSearch(searchParameters);
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
setDraftCategories(
operationCategories.filter(category =>
(searchParameters.get("categories") ?? "").split(",").includes(category),
(searchParameters.get("categories") ?? "")
.split(",")
.includes(category),
),
);
}
const firstPageQuery = {
from: dateStart(appliedFrom),
to: dateEnd(appliedTo),
...((appliedCategories.length > 0) && { categories: appliedCategories }),
...(appliedCategories.length > 0 && { categories: appliedCategories }),
limit: 100,
};
const operations = useBrokerageOperations(accountId, {
...firstPageQuery,
...(cursor && { cursor }),
});
const sync = useScreenSync(
{ accountId, screen: "operations", ...firstPageQuery },
[operations],
);
if (operations.isLoading) return { state: "loading" };
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
limit: 100,
}).queryKey;
const resetCursors = () => setPagination({ cursors: [], scopeKey });
const retry = () => {
if (isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor)) {
if (
isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
)
) {
void queryClient.invalidateQueries({
exact: true,
queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery)
@@ -124,6 +152,7 @@ const useAccountOperationsPageVm = (
}
else void sync.retry();
};
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
@@ -136,7 +165,10 @@ const useAccountOperationsPageVm = (
if (
(operations.isError || operations.meta?.sync.state === "failed")
&& (!operations.data
|| isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor))
|| isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
))
)
return { onReset, retry, state: "error" };
if (!operations.data) return { state: "loading" };
@@ -164,7 +196,9 @@ const useAccountOperationsPageVm = (
setSearchParameters({
...(draftFrom && { from: draftFrom }),
...(draftTo && { to: draftTo }),
...((draftCategories.length > 0) && { categories: draftCategories.join(",") }),
...(draftCategories.length > 0 && {
categories: draftCategories.join(","),
}),
});
resetCursors();
},
@@ -187,7 +221,6 @@ const useAccountOperationsPageVm = (
};
};
const calendarDate = z.iso.date();
function dateEnd(value: null | string) {
return value ? `${value}T23:59:59.999Z` : undefined;
}
@@ -200,13 +233,4 @@ function isSameCategories(a: OperationCategory[], b: OperationCategory[]) {
return a.length === b.length && a.every(value => b.includes(value));
}
function isValidPeriod(from: null | string, to: null | string) {
return (
[from, to].every(
date => date === null || calendarDate.safeParse(date).success,
)
&& (!from || !to || from <= to)
);
}
export { type AccountOperationsPageViewModel, useAccountOperationsPageVm };
@@ -22,6 +22,7 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
action={(
<Group>
<Button onClick={retry}>{t("ledger.retry")}</Button>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
@@ -1,4 +1,4 @@
import type { FC, ReactNode } from "react";
import type { FC } from "react";
import {
Alert,
@@ -14,120 +14,114 @@ import { DatePickerInput } from "@mantine/dates";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import {
LedgerEntryRow,
operationCategories,
type OperationCategory,
} from "@/entities/broker-operation";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
import { useAccountOperationsPageLoadedVm } from "./AccountOperationsPageLoaded.vm";
type AccountOperationsPageLoadedProps = Extract<
AccountOperationsPageViewModel,
{ state: "loaded" }
>;
const operationCategorySet = new Set<string>(operationCategories);
const AccountOperationsPageLoadedOperations: FC<
AccountOperationsPageLoadedProps
> = (props) => { // eslint-disable-line sonarjs/function-return-type -- React renders elements and lists.
const { t } = useTranslation();
function fromMultiSelectValue(value: string[]) {
return value.filter((item): item is OperationCategory =>
operationCategorySet.has(item),
const { items } = props;
if (items.length > 0) {
return items.map(operation => (
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
));
}
if (props.partial) {
return;
}
return (
<StateView text={t("ledger.emptyText")} title={t("ledger.emptyTitle")} />
);
}
};
const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
props,
) => {
const {
canApply,
categoryData,
draftCategories,
draftFrom,
draftTo,
hasNext,
hasPrevious,
items,
onApply,
onDraftCategoriesChange,
onDraftFromChange,
onDraftToChange,
onCategoriesChange,
onNext,
onPeriodChange,
onPrevious,
onReset,
} = props;
const { t } = useTranslation();
t,
} = useAccountOperationsPageLoadedVm(props);
const categoryData = operationCategories.map(category => ({
label: t(`ledger.category.${category}`),
value: category,
}));
// eslint-disable-next-line sonarjs/function-return-type -- ReactNode deliberately includes lists, elements and empty output.
function renderOperations(): ReactNode {
if (items.length > 0) {
return items.map(operation => (
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
));
}
if (props.partial) {
return;
}
return (
<StateView
text={t("ledger.emptyText")}
title={t("ledger.emptyTitle")}
/>
);
}
return (
<Stack gap="lg">
{!!(props.lastSuccessfulAt) && ((
{!!props.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
))}
)}
<Title order={1}>{t("ledger.title")}</Title>
<Button disabled={props.refreshing} onClick={props.retry} w="fit-content">
{t("investment.refresh")}
</Button>
{!!(props.partial) && ((
{!!props.partial && (
<Alert role="status">{t("investment.partial")}</Alert>
))}
{!!(props.historyLoading) && (<Alert>{t("investment.history")}</Alert>)}
{!!(props.refreshing) && ((
)}
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
{!!props.refreshing && (
<Progress animated aria-label={t("accounts.refreshing")} value={100} />
))}
{!!(props.refreshError) && ((
)}
{!!props.refreshError && (
<Alert aria-live="polite" color="yellow" role="status">
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button onClick={props.retry} variant="subtle">
{t("accounts.retry")}
</Button>
</Alert>
))}
)}
<Group align="end">
<DatePickerInput
allowSingleDateInRange
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
onChange={([from, to]) => {
onDraftFromChange(from);
onDraftToChange(to);
}}
onChange={onPeriodChange}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
returnFocus: true,
@@ -137,35 +131,40 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
valueFormat="DD.MM.YYYY"
/>
</Group>
<MultiSelect
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
data={categoryData}
label={t("ledger.categories")}
onChange={value =>
onDraftCategoriesChange(fromMultiSelectValue(value))}
onChange={onCategoriesChange}
placeholder={t("ledger.categoriesPlaceholder")}
value={draftCategories}
/>
<Group>
<Button disabled={!canApply} onClick={onApply}>
{t("ledger.apply")}
</Button>
<Button onClick={onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
{renderOperations()}
{!!(hasNext || hasPrevious) && ((
<AccountOperationsPageLoadedOperations {...props} />
{!!(hasNext || hasPrevious) && (
<Group justify="space-between">
<Button disabled={!hasPrevious} onClick={onPrevious}>
{t("ledger.previous")}
</Button>
<Button disabled={!hasNext} onClick={onNext}>
{t("ledger.next")}
</Button>
</Group>
))}
)}
</Stack>
);
};
@@ -0,0 +1,78 @@
import { useTranslation } from "react-i18next";
import {
operationCategories,
type OperationCategory,
} from "@/entities/broker-operation";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageLoadedProps = Extract<
AccountOperationsPageViewModel,
{ state: "loaded" }
>;
const operationCategorySet = new Set<string>(operationCategories);
function fromMultiSelectValue(value: string[]) {
return value.filter((item): item is OperationCategory =>
operationCategorySet.has(item),
);
}
function useAccountOperationsPageLoadedVm(
props: AccountOperationsPageLoadedProps,
) {
const {
canApply,
draftCategories,
draftFrom,
draftTo,
hasNext,
hasPrevious,
onApply,
onDraftCategoriesChange,
onDraftFromChange,
onDraftToChange,
onNext,
onPrevious,
onReset,
} = props;
const { t } = useTranslation();
const categoryData = operationCategories.map(category => ({
label: t(`ledger.category.${category}`),
value: category,
}));
const onPeriodChange: (value: [null | string, null | string]) => void = ([
from,
to,
]) => {
onDraftFromChange(from);
onDraftToChange(to);
};
const onCategoriesChange: (value: string[]) => void = value =>
onDraftCategoriesChange(fromMultiSelectValue(value));
return {
canApply,
categoryData,
draftCategories,
draftFrom,
draftTo,
hasNext,
hasPrevious,
onApply,
onCategoriesChange,
onNext,
onPeriodChange,
onPrevious,
onReset,
t,
};
}
export { useAccountOperationsPageLoadedVm };
@@ -12,15 +12,21 @@ const AccountOperationsPageLoading: FC = () => {
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="40%" />
<Group grow>
<Skeleton height={48} radius="sm" />
<Skeleton height={48} radius="sm" />
</Group>
<Skeleton height={48} radius="sm" />
<Group>
<Skeleton height={36} radius="sm" width={110} />
<Skeleton height={36} radius="sm" width={110} />
</Group>
<Stack gap="xs">
{skeletonRowIds.map(id => (
<Skeleton height={64} key={id} radius="md" />
@@ -41,6 +41,7 @@ function configureTestAuth() {
function renderAccountRoute(queryClient: QueryClient) {
configureTestAuth();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
@@ -56,6 +57,7 @@ function renderAccountRoute(queryClient: QueryClient) {
function renderPage(ui: React.ReactNode) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
@@ -65,8 +67,10 @@ function renderPage(ui: React.ReactNode) {
);
}
const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const portfolio: Portfolio
= {
cashBalances: [{ currency: "USD", value: "0.5" }],
isStale: true,
lastSuccessfulAt: "2026-08-13T10:00:00Z",
@@ -98,8 +102,8 @@ const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture prese
],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
const emptyRecentOperations: AccountPageRecentOperations = {
state: "empty",
@@ -141,16 +145,20 @@ describe("AccountPage states", () => {
const slug = type === "STOCK" ? "SBERP" : "SU26238RMFS4";
const uid = "c190ff1f-1447-4227-b543-316332699ca5";
const requests = vi.fn();
const position = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const position
= {
...portfolio.positions[0],
displayName: hasMetadata ? "Сбербанк привилегированные" : "FIGI-2",
instrumentUid: uid,
logoName: hasMetadata ? "vtoraya.png" : null,
ticker: hasMetadata ? slug : null,
type,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(
@@ -168,6 +176,7 @@ describe("AccountPage states", () => {
),
http.get(`*/api/v1/${collection}/:uid`, () => {
requests();
return HttpResponse.json(
resource({
instrumentUid: uid,
@@ -186,6 +195,7 @@ describe("AccountPage states", () => {
const links = screen.getAllByRole("link", {
name: position.displayName,
});
expect(links).toHaveLength(2);
for (const link of links)
expect(link).toHaveAttribute(
@@ -234,6 +244,7 @@ describe("AccountPage states", () => {
),
http.get("*/api/v1/stocks/:uid", () => {
requests();
return scenario === "unavailable"
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource({ instrumentUid: uid, slug: uid }));
@@ -286,15 +297,12 @@ describe("AccountPage states", () => {
},
);
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])(
"allows retry for %s",
(code) => {
const retry = vi.fn();
renderPage(<AccountPageError code={code} retry={retry} />);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(retry).toHaveBeenCalledOnce();
},
);
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])("allows retry for %s", (code) => {
const retry = vi.fn();
renderPage(<AccountPageError code={code} retry={retry} />);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(retry).toHaveBeenCalledOnce();
});
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
renderPage(
@@ -320,9 +328,11 @@ describe("AccountPage states", () => {
})}
/>,
);
const balances = within(
screen.getByRole("region", { name: "Денежные остатки" }),
);
expect(
balances.getAllByRole("definition").map(node => node.textContent),
).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]);
@@ -376,6 +386,7 @@ describe("AccountPage states", () => {
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency,
};
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(snapshot)),
@@ -447,9 +458,11 @@ describe("AccountPage states", () => {
expect(
screen.getByRole("button", { name: "Облигации" }),
).toBeInTheDocument();
const positions = screen
.getAllByText(/Вторая|Первая/)
.map(element => element.textContent);
expect(positions).toEqual(["Вторая", "Первая"]);
expect(screen.getByText("10 ₽")).toBeInTheDocument();
expect(screen.getByText("-1 ₽")).toBeInTheDocument();
@@ -470,7 +483,8 @@ describe("AccountPage states", () => {
it("renders stock cards with logo fallback and signed unlabeled return", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -499,8 +513,9 @@ describe("AccountPage states", () => {
type: "STOCK",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
expect(screen.getByText("А")).toBeInTheDocument();
@@ -513,7 +528,8 @@ describe("AccountPage states", () => {
it("groups stock supporting metrics into quiet single lines", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -530,8 +546,9 @@ describe("AccountPage states", () => {
type: "STOCK",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
@@ -546,7 +563,8 @@ describe("AccountPage states", () => {
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -587,8 +605,9 @@ describe("AccountPage states", () => {
type: "BOND",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
@@ -604,7 +623,8 @@ describe("AccountPage states", () => {
it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -633,8 +653,9 @@ describe("AccountPage states", () => {
type: "BOND",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute(
@@ -653,7 +674,8 @@ describe("AccountPage states", () => {
it("renders bond cards in expanded layout with bond-specific classes", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -670,8 +692,9 @@ describe("AccountPage states", () => {
type: "BOND",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
expect(screen.getByText("О")).toBeInTheDocument();
@@ -684,7 +707,8 @@ describe("AccountPage states", () => {
it("keeps other-position cards compact with labeled return and no logo", () => {
renderPage(
<AccountPageLoaded
{...loadedProps((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{...loadedProps(
{
...portfolio,
positions: [
@@ -701,8 +725,9 @@ describe("AccountPage states", () => {
type: "FUND",
},
],
}
/* eslint-enable unicorn/no-null */))}
},
/* eslint-enable unicorn/no-null */
)}
/>,
);
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
@@ -713,7 +738,8 @@ describe("AccountPage states", () => {
renderPage(
<AccountPageLoaded
{...loadedProps()}
account={(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
account={
{
id: "account-1",
name: "Основной",
@@ -721,7 +747,8 @@ describe("AccountPage states", () => {
portfolioSummary: null,
type: "BROKERAGE",
}
/* eslint-enable unicorn/no-null */)}
/* eslint-enable unicorn/no-null */
}
/>,
);
expect(
@@ -750,7 +777,8 @@ describe("AccountPage states", () => {
renderPage(
<AccountPageLoaded
{...loadedProps()}
recentOperations={(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
recentOperations={
{
items: [
{
@@ -774,7 +802,8 @@ describe("AccountPage states", () => {
state: "loaded",
to: "/accounts/account-1/operations",
}
/* eslint-enable unicorn/no-null */)}
/* eslint-enable unicorn/no-null */
}
/>,
);
@@ -783,8 +812,9 @@ describe("AccountPage states", () => {
});
it("keeps future events ordered with calendar dates and projected rather than posted amounts", () => {
const event: BrokerageEvent = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const event: BrokerageEvent
= {
date: "2026-09-10",
dateKind: "event",
displayName: "Облигация",
@@ -795,12 +825,14 @@ describe("AccountPage states", () => {
projectedAmount: { currency: "RUB", value: "850.50" },
ticker: "DEMO",
type: "coupon",
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
renderPage(
<AccountPageLoaded
{...loadedProps()}
recentEvents={(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
recentEvents={
{
items: [
event,
@@ -828,19 +860,22 @@ describe("AccountPage states", () => {
state: "loaded",
to: "/accounts/account-1/events",
}
/* eslint-enable unicorn/no-null */)}
/* eslint-enable unicorn/no-null */
}
/>,
);
const section = screen.getByRole("region", { name: "Будущие события" });
const content = within(section);
const text = section.textContent ?? "";
const titles = [
"Купон · Облигация",
"Дивиденд · Акция",
"Погашение · Облигация",
"Оферта · Облигация",
];
for (const title of titles) expect(content.getByText(title)).toBeVisible();
expect(titles.map(title => text.indexOf(title))).toEqual(
titles.map(title => text.indexOf(title)).toSorted((a, b) => a - b),
@@ -870,13 +905,16 @@ describe("AccountPage states", () => {
recentEvents={{ state, to: "/accounts/account-1/events" }}
/>,
);
const content = within(
screen.getByRole("region", { name: "Будущие события" }),
);
if (state === "loading") {
const skeleton = content.getByRole("status", {
name: "Загрузка будущих событий",
});
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(skeleton).toHaveAttribute("aria-busy", "true");
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
@@ -888,6 +926,7 @@ describe("AccountPage states", () => {
content.getByText("На ближайшие 30 дней событий не найдено."),
).toBeVisible();
}
expect(
content.getByRole("link", { name: "Открыть будущие события" }),
).toHaveAttribute("href", "/accounts/account-1/events");
@@ -914,8 +953,9 @@ describe("AccountPage states", () => {
it.each([503])(
"preserves totals and UTC snapshot through refresh error %i, retry and success",
async (status) => {
const initialPortfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const initialPortfolio: Portfolio
= {
...portfolio,
structure: {
currencies: [
@@ -928,8 +968,9 @@ describe("AccountPage states", () => {
],
unvaluedCategories: [{ count: 1, type: "STOCK" }],
},
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined;
server.use(
@@ -944,7 +985,8 @@ describe("AccountPage states", () => {
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
HttpResponse.json(
{
errorCode: null,
id: "overview",
@@ -956,8 +998,9 @@ describe("AccountPage states", () => {
],
scope: { accountId: "account-1", screen: "account" },
state: "succeeded",
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
@@ -965,11 +1008,14 @@ describe("AccountPage states", () => {
return HttpResponse.json(
resource(initialPortfolio, { resourceKey: "portfolio" }),
);
if (requestCount === 3) {
const deferred = Promise.withResolvers<Response>();
resolveRetry = deferred.resolve;
return deferred.promise;
}
return HttpResponse.json(
{ code: status === 404 ? "NOT_FOUND" : "TBANK_UNAVAILABLE" },
{ status },
@@ -980,9 +1026,11 @@ describe("AccountPage states", () => {
renderAccountRoute(queryClient);
await screen.findByText("Вторая");
const structure = screen.getByRole("region", {
name: "Структура портфеля",
});
expect(structure).toHaveTextContent("10 ₽");
const snapshotText = screen.getByText(/Данные получены/).textContent;
expect(snapshotText).toContain("10:00");
@@ -1057,9 +1105,11 @@ describe("AccountPage states", () => {
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(resource(portfolio));
{
const deferred = Promise.withResolvers<Response>();
resolveRefresh = deferred.resolve;
return deferred.promise;
}
}),
@@ -26,6 +26,7 @@ const AccountPage: FC<AccountPageProps> = (props) => {
sourceError={viewModel.sourceError}
/>
);
return (
<AccountPageLoaded
account={viewModel.account}
@@ -18,8 +18,10 @@ import {
useAccountPageViewModel,
} from "./AccountPage.vm";
const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const portfolio: Portfolio
= {
cashBalances: [{ currency: "USD", value: "1" }],
isStale: true,
lastSuccessfulAt: "2026-08-13T10:00:00Z",
@@ -63,8 +65,8 @@ const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture prese
],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "150" }],
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
function configureTestAuth() {
configureAuthClient({
@@ -76,6 +78,7 @@ function configureTestAuth() {
function createWrapper() {
const { wrapper: QueryWrapper } = createQueryWrapper();
const Wrapper: FC<PropsWithChildren> = props => (
<QueryWrapper>
<MantineProvider>
@@ -87,6 +90,7 @@ function createWrapper() {
</MantineProvider>
</QueryWrapper>
);
return Wrapper;
}
@@ -97,7 +101,8 @@ function mockLoadedAccountPage() {
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{
accountId: "account-1",
hasNext: false,
@@ -121,8 +126,9 @@ function mockLoadedAccountPage() {
],
nextCursor: null,
observedAt: "2026-08-13T10:00:00Z",
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
@@ -130,18 +136,22 @@ function mockLoadedAccountPage() {
resource({
accountId: "account-1",
calculatedAt: "2026-08-13T10:00:00Z",
events: Array.from({ length: 6 }, (_, index) => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
dateKind: "event",
displayName: `Купон ${index}`,
figi: `FIGI-EVENT-${index}`,
instrumentUid: null,
projectedAmount: { currency: "RUB", value: String(index + 1) },
ticker: `BND${index}`,
type: "coupon",
}
/* eslint-enable unicorn/no-null */))),
events: Array.from(
{ length: 6 },
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
(_, index) =>
({
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
dateKind: "event",
displayName: `Купон ${index}`,
figi: `FIGI-EVENT-${index}`,
instrumentUid: null,
projectedAmount: { currency: "RUB", value: String(index + 1) },
ticker: `BND${index}`,
type: "coupon",
}),
/* eslint-enable unicorn/no-null */
),
}),
),
),
@@ -158,6 +168,7 @@ describe("useAccountPageViewModel", () => {
"DIGITAL_FINANCIAL_ASSET",
"FUND",
] as const;
const snapshot: Portfolio = {
...portfolio,
cashBalances: [{ currency: "RUB", value: "1000" }],
@@ -169,19 +180,23 @@ describe("useAccountPageViewModel", () => {
figi: "FIGI-CURRENCY",
type: "CURRENCY",
},
...otherTypes.map(type => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...portfolio.positions[2],
currentValue:
type === "OPTION" ? { currency: "RUB", value: "50" } : null,
figi: `FIGI-${type}-NEXT`,
type,
}
/* eslint-enable unicorn/no-null */))),
...otherTypes.map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
type =>
({
...portfolio.positions[2],
currentValue:
type === "OPTION" ? { currency: "RUB", value: "50" } : null,
figi: `FIGI-${type}-NEXT`,
type,
}),
/* eslint-enable unicorn/no-null */
),
],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1250" }],
};
const original = structuredClone(snapshot);
const sections = toAccountPagePositionSections(snapshot);
@@ -210,10 +225,12 @@ describe("useAccountPageViewModel", () => {
});
await waitFor(() => expect(result.current.state).toBe("loaded"));
const loaded = result.current as Extract<
typeof result.current,
{ state: "loaded" }
>;
expect(
loaded.positionSections.map(({ groups, kind }) => ({
kind,
@@ -231,35 +248,32 @@ describe("useAccountPageViewModel", () => {
shareText: "66,7 %",
},
],
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
},
{
kind: "bonds",
positions: [
{
displayName: "Облигация",
logoSrc: null,
returnText: "-5 ₽",
returnTone: "negative",
shareText: "33,3 %",
},
],
},
/* eslint-enable unicorn/no-null */ {
kind: "other",
positions: [
{
displayName: "Фонд",
logoSrc: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
returnText: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
returnTone: undefined,
shareText: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
},
],
},
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
kind: "bonds",
positions: [
{
displayName: "Облигация",
logoSrc: null,
returnText: "-5 ₽",
returnTone: "negative",
shareText: "33,3 %",
},
],
}
/* eslint-enable unicorn/no-null */),
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
kind: "other",
positions: [
{
displayName: "Фонд",
logoSrc: null,
returnText: null,
returnTone: undefined,
shareText: null,
},
],
}
/* eslint-enable unicorn/no-null */),
]);
});
@@ -276,10 +290,12 @@ describe("useAccountPageViewModel", () => {
(result.current as { recentEvents?: { state: string } }).recentEvents,
)?.toMatchObject({ state: "loaded" }),
);
const loaded = result.current as Extract<
typeof result.current,
{ state: "loaded" }
>;
expect(loaded.recentOperations).toMatchObject({
items: [{ description: "Покупка акции", id: "operation-1" }],
state: "loaded",
@@ -335,11 +351,10 @@ it("keeps overview sections independent when their source data is incomplete", a
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ hasNext: false, items: [], nextCursor: null }
/* eslint-enable unicorn/no-null */),
{ availability: "partial" },
{ hasNext: false, items: [], nextCursor: null },
/* eslint-enable unicorn/no-null */ { availability: "partial" },
),
),
),
@@ -352,9 +367,11 @@ it("keeps overview sections independent when their source data is incomplete", a
),
),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.state).toBe("loaded"));
await waitFor(() =>
expect(result.current).toMatchObject({
@@ -367,14 +384,18 @@ it("keeps overview sections independent when their source data is incomplete", a
it("refreshes every overview section through one account descriptor and observational reads", async () => {
configureTestAuth();
const sections: Record<string, unknown> = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const sections: Record<string, unknown>
= {
"events": { events: [], incomeTotalsByCurrency: [] },
"income-summary": { totalsByCurrency: [] },
"operations": { hasNext: false, items: [], nextCursor: null },
portfolio,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
const observed: string[] = [];
const scopes: unknown[] = [];
let version = "1";
@@ -385,6 +406,7 @@ it("refreshes every overview section through one account descriptor and observat
({ request }) => {
if (new URL(request.url).searchParams.get("sync") === "observe")
observed.push(path);
return HttpResponse.json(
resource(data, { resourceKey: path, stateVersion: version }),
);
@@ -394,6 +416,7 @@ it("refreshes every overview section through one account descriptor and observat
http.post("*/api/v1/tbank-sync", async ({ request }) => {
scopes.push(await request.json());
version = "2";
return HttpResponse.json(
{
id: "overview",
@@ -404,7 +427,8 @@ it("refreshes every overview section through one account descriptor and observat
);
}),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
HttpResponse.json(
{
errorCode: null,
id: "overview",
@@ -416,13 +440,16 @@ it("refreshes every overview section through one account descriptor and observat
),
scope: null,
state: "succeeded",
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() =>
expect(result.current).toMatchObject({
incomeSummary: { state: "empty" },
@@ -33,6 +33,7 @@ type AccountPageContent = {
retry: () => Promise<unknown> | undefined;
sourceError?: boolean;
};
type AccountPageEmptyViewModel = AccountPageContent & {
state: "empty";
};
@@ -47,7 +48,9 @@ type AccountPageIncomeSummary
to: string;
}
| { retry: () => Promise<unknown> | undefined; state: "error"; to: string }
| { state: "empty" | "loading" | "partial"; to: string };
| { state: "empty"
| "loading"
| "partial"; to: string; };
type AccountPageLoadedViewModel = AccountPageContent & {
portfolio: Portfolio;
@@ -85,7 +88,9 @@ type AccountPageRecentOperations
to: string;
}
| {
state: "empty" | "error" | "loading";
state: "empty"
| "error"
| "loading";
to: string;
};
@@ -97,23 +102,31 @@ type AccountPageViewModel
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
| { state: "loading" };
type PositionSectionKind = "bonds" | "other" | "stocks";
type PositionSectionKind = "bonds"
| "other"
| "stocks";
function groupPositions(
positions: AccountPagePosition[],
): AccountPagePositionGroup[] {
const groups = new Map<string, AccountPagePositionGroup>();
for (const [index, position] of positions.entries()) {
const identity = positionIdentity(position);
const rowKey = JSON.stringify(["row", position.account?.id, index]);
const key
= identity && position.account
? JSON.stringify([position.type, ...identity])
: rowKey;
const group = groups.get(key);
if (
group
&& group.positions.every(item => item.account?.id !== position.account?.id)
&& group.positions.every(
item => item.account?.id !== position.account?.id,
)
) {
group.positions.push(position);
}
@@ -122,21 +135,26 @@ function groupPositions(
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
}
}
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline.
return [...groups.values()];
}
function positionIdentity(position: AccountPagePosition) {
if (position.instrumentUid?.trim()) return ["uid", position.instrumentUid.trim()];
if (position.instrumentUid?.trim())
return ["uid", position.instrumentUid.trim()];
if (position.figi?.trim()) return ["figi", position.figi.trim()];
return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined;
}
function toAccountPagePositionSections(
portfolio: Portfolio,
instrumentDetails: Map<string, undefined | { slug: string }> = new Map(),
instrumentDetails: Map<string, undefined
| { slug: string }> = new Map(),
): AccountPagePositionSection[] {
const denominators = new Map<string, number>();
for (const position of portfolio.positions) {
if (!position.currentValue) continue;
const currency = position.currentValue.currency;
@@ -145,14 +163,17 @@ function toAccountPagePositionSections(
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
);
}
const stocks = toSection(
"stocks",
portfolio,
denominators,
instrumentDetails,
);
const bonds = toSection("bonds", portfolio, denominators, instrumentDetails);
const other = toSection("other", portfolio, denominators, instrumentDetails);
return [stocks, bonds, other].filter(section => section.groups.length > 0);
}
@@ -167,6 +188,7 @@ function toIncomeSummary(
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",
@@ -181,32 +203,40 @@ function toPosition(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
kind: PositionSectionKind,
instrumentSlug: string | undefined,
instrumentSlug?: string,
): AccountPagePosition {
const isExpanded = kind !== "other";
const returnValue = position.absoluteReturn
? Number(position.absoluteReturn.value)
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
let returnTone: AccountPageReturnTone | undefined;
if (returnValue !== null && returnValue !== 0) returnTone = returnValue > 0 ? "positive" : "negative";
const returnPrefix = isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
if (returnValue !== null && returnValue !== 0)
returnTone = returnValue > 0 ? "positive" : "negative";
const returnPrefix
= isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
return {
...position,
instrumentTo:
(kind === "stocks" || kind === "bonds") && position.instrumentUid
? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}`
: undefined,
logoSrc: isExpanded ? instrumentLogoUrl(position.logoName) : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
logoSrc: isExpanded
? instrumentLogoUrl(position.logoName)
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
returnText: position.absoluteReturn
? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
returnTone,
shareText: position.currentValue?.currency
? formatShare(position.currentValue, {
currency: position.currentValue.currency,
value: String(denominators.get(position.currentValue.currency) ?? 0),
})
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
};
}
@@ -227,6 +257,7 @@ function toRecentEvents(
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,
@@ -256,6 +287,7 @@ function toRecentOperations(
)
return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to };
return {
items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
@@ -271,20 +303,20 @@ function toSection(
kind: PositionSectionKind,
portfolio: Portfolio,
denominators: Map<string, number>,
instrumentDetails: Map<string, undefined | { slug: string }>,
instrumentDetails: Map<string, undefined
| { slug: string }>,
): AccountPagePositionSection {
return {
groups: groupPositions(
portfolio.positions
.filter((position) => {
if (kind === "stocks") return position.type === "STOCK";
return kind === "bonds"
? position.type === "BOND"
: (
position.type !== "STOCK"
&& position.type !== "BOND"
&& position.type !== "CURRENCY"
);
: position.type !== "STOCK"
&& position.type !== "BOND"
&& position.type !== "CURRENCY";
})
.map(position =>
toPosition(
@@ -303,6 +335,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const portfolio = usePortfolio(accountId);
const stocks = useStocks(
portfolio.data?.positions.flatMap(position =>
position.type === "STOCK" && position.instrumentUid
@@ -310,13 +343,17 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
: [],
) ?? [],
);
const instrumentDetails = stocks;
const operations = useBrokerageOperations(accountId, {
limit: 5,
});
const events = useBrokerageEvents(accountId);
const income = useIncomeSummary(accountId);
const accounts = useBrokerageAccounts();
const sync = useScreenSync({ accountId, screen: "account" }, [
accounts,
portfolio,
@@ -332,12 +369,15 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
retry: sync.retry,
state: "error" as const,
};
return { state: "loading" as const };
}
const account = accounts.data?.find(item => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations);
const recentEvents = toRecentEvents(accountId, events);
const incomeSummary = toIncomeSummary(accountId, income);
const refresh = {
partial: portfolio.meta?.availability === "partial",
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
@@ -345,6 +385,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
retry: sync.retry,
sourceError: sync.sourceError,
};
if (
portfolio.meta?.availability === "complete"
&& portfolio.data.positions.length === 0
@@ -359,6 +400,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
state: "empty" as const,
...refresh,
};
return {
account,
accountId,
@@ -27,7 +27,12 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => {
? t("accounts.all")
: (account?.name ?? t("portfolio.title"))}
</Title>
{description && <Text c="dimmed" size="sm">{description}</Text>}
{description && (
<Text c="dimmed" size="sm">
{description}
</Text>
)}
</Stack>
);
};
@@ -34,29 +34,35 @@ type AccountPageEmptyProps = {
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
const { t } = useTranslation();
const text = t(
props.accountId === "all"
? "portfolio.allEmptyText"
: "portfolio.emptyText",
);
return (
<Stack>
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false}
retry={props.retry}
sourceError={props.sourceError}
/>
<StateView
text={t(
props.accountId === "all"
? "portfolio.allEmptyText"
: "portfolio.emptyText",
)}
title={t("portfolio.emptyTitle")}
/>
<StateView text={text} title={t("portfolio.emptyTitle")} />
<PortfolioStructure
structure={{ currencies: [], unvaluedCategories: [] }}
/>
<RecentOperations operations={props.recentOperations} />
<BrokerageEventsPreview events={props.recentEvents} />
{!!(props.incomeSummary) && (<IncomeSummary {...props.incomeSummary} />)}
{!!props.incomeSummary && <IncomeSummary {...props.incomeSummary} />}
</Stack>
);
};
@@ -25,7 +25,10 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
title={t("portfolio.accountUnavailableTitle")}
/>
);
if (isApiErrorCode(code, ApiErrorCode.TbankNotConnected) || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken))
if (
isApiErrorCode(code, ApiErrorCode.TbankNotConnected)
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
)
return (
<StateView
action={(
@@ -47,6 +50,7 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
title={t("accounts.unavailableTitle")}
/>
);
return (
<StateView
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
@@ -42,18 +42,30 @@ type AccountPageLoadedProps = {
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation();
const defaultValue = [
...props.positionSections.map(section => section.kind),
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
];
return (
<Stack gap="lg">
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false}
retry={props.retry}
sourceError={props.sourceError}
/>
{!!(props.portfolio.isStale) && ((
{!!props.portfolio.isStale && (
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
))}
)}
<Text c="dimmed" size="sm">
{t(
props.accountId === "all"
@@ -64,6 +76,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
},
)}
</Text>
{props.partial
? (
<Alert role="status">{t("investment.partial")}</Alert>
@@ -71,12 +84,14 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
: (
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
)}
{!(props.partial) && ((
{!props.partial && (
<PortfolioStructure
key={props.accountId}
structure={props.portfolio.structure}
/>
))}
)}
<Accordion
classNames={{
chevron: classes.accordionChevron,
@@ -84,13 +99,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
label: classes.accordionLabel,
panel: classes.accordionPanel,
}}
defaultValue={[
...props.positionSections.map(section => section.kind),
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
]}
defaultValue={defaultValue}
multiple
>
{props.positionSections.map(section => (
@@ -98,35 +107,44 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Accordion.Control>
{t(positionSectionTitleKey(section.kind))}
</Accordion.Control>
<Accordion.Panel>
<PositionSection section={section} />
</Accordion.Panel>
</Accordion.Item>
))}
<Accordion.Item value="cash">
<Accordion.Control>{t("portfolio.cashBalances")}</Accordion.Control>
<Accordion.Panel>
<CurrencyCards amounts={props.portfolio.cashBalances} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentOperations">
<Accordion.Control>{t("ledger.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentOperations operations={props.recentOperations} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentEvents">
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<BrokerageEventsPreview events={props.recentEvents} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="incomeSummary">
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
<Accordion.Panel>
{!!(props.incomeSummary) && ((
{!!props.incomeSummary && (
<IncomeSummary {...props.incomeSummary} />
))}
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
@@ -136,6 +154,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks";
return kind === "bonds" ? "portfolio.bonds" : "portfolio.otherPositions";
}
@@ -19,4 +19,5 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
export const InitialLoading: Story = { name: "Начальная загрузка счёта" };
@@ -8,9 +8,11 @@ import classes from "../../AccountPage.module.css";
const AccountPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="35%" />
<SimpleGrid
aria-hidden="true"
cols={{ base: 1, md: 3, sm: 2 }}
@@ -19,6 +21,7 @@ const AccountPageLoading: FC = () => {
{["first", "second", "third"].map(placeholder => (
<Paper className={classes.total} key={placeholder} withBorder>
<Skeleton height={24} radius={4} width={40} />
<Skeleton
className={classes.totalAmountSkeleton}
height={21}
@@ -28,7 +31,9 @@ const AccountPageLoading: FC = () => {
</Paper>
))}
</SimpleGrid>
<Skeleton height={260} radius="sm" />
{["first", "second", "third"].map(placeholder => (
<Skeleton height={52} key={placeholder} radius="sm" />
))}
@@ -15,11 +15,13 @@ describe("CurrencyCards currency presentation", () => {
{ currency: "CAD", value: "4.00" },
{ currency: "XYZ", value: "5.00" },
].slice(0, count);
const { container } = render(
<MantineProvider>
<CurrencyCards amounts={totals} />
</MantineProvider>,
);
expect(
screen.queryAllByRole("definition").map(node => node.textContent),
).toEqual(
@@ -16,8 +16,9 @@ type CurrencyCardsProps = {
};
const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
const { amounts } = props;
const { t } = useTranslation();
const { amounts } = props;
const currencies: Record<string, { name: string; symbol: string }> = {
EUR: { name: t("portfolio.currencies.EUR"), symbol: "€" },
RUB: { name: t("portfolio.currencies.RUB"), symbol: "₽" },
@@ -28,6 +29,7 @@ const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
<SimpleGrid cols={{ base: 1, md: 3, sm: 2 }} spacing={10}>
{amounts.map((money) => {
const amount = formatDecimal(money.value);
return (
<Paper
className={classes.total}
@@ -40,10 +42,12 @@ const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
<span aria-hidden="true">
{currencies[money.currency]?.symbol ?? money.currency}
</span>
<VisuallyHidden>
{currencies[money.currency]?.name ?? money.currency}
</VisuallyHidden>
</Text>
<Text className={classes.totalAmount} component="dd" m={0}>
{amount.startsWith("-")
? (
@@ -51,13 +55,13 @@ const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
<span className={classes.totalLeadingDigits}>
{amount.slice(0, SIGN_PREFIX_LENGTH)}
</span>
{amount.slice(SIGN_PREFIX_LENGTH)}
</>
)
: (
amount
)}
{" "}
)}{" "}
{currencies[money.currency]?.symbol ?? money.currency}
</Text>
</Paper>
@@ -50,42 +50,40 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
return (
<Stack gap="xs">
<Text c="dimmed">{t("income.summaryEmpty")}</Text>
<Button component={Link} to={props.to} variant="subtle">
{t("income.open")}
</Button>
</Stack>
);
return (
<Stack gap="xs">
{!!(props.lastSuccessfulAt) && ((
{!!props.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
))}
{!!(props.stale) && (<Text size="xs">{t("investment.stale")}</Text>)}
{!!(props.refreshError) && ((
)}
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!props.refreshError && (
<Text role="status">{t("income.refreshError")}</Text>
))}
)}
{props.summary.totalsByCurrency.map(total => (
<Paper key={total.currency} p="sm" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
<Text c="dimmed" size="sm">
{t("income.coupon")}
:
{" "}
{formatMoney(total.coupon)}
{" "}
·
{" "}
{t("income.dividend")}
:
{" "}
{formatMoney(total.dividend)}
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(total.dividend)}
</Text>
</Paper>
))}
<Button component={Link} to={props.to} variant="subtle">
{t("income.open")}
</Button>
@@ -30,6 +30,7 @@ const meta = {
{ currency: "USD", value: "320" },
]}
/>
<PortfolioRefreshState {...arguments_} />
</Stack>
),
@@ -40,6 +41,7 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
export const Idle: Story = { name: "Сохранённые данные" };
export const Refreshing: Story = {
args: { refreshing: true },
@@ -24,15 +24,18 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
>
{t(refreshError ? "accounts.retry" : "investment.refresh")}
</Button>
{!!(refreshing) && ((
{!!refreshing && (
<Group aria-label={t("portfolio.refreshing")} gap="xs" role="status">
<Loader size="sm" />
<Text c="dimmed" size="sm">
{t("portfolio.refreshing")}
</Text>
</Group>
))}
{!!(refreshError) && ((
)}
{!!refreshError && (
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="sm">
<Text>
@@ -44,7 +47,7 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
</Text>
</Stack>
</Alert>
))}
)}
</>
);
};
@@ -15,6 +15,7 @@ const rub: Portfolio["structure"]["currencies"][number] = {
hasNegativeValues: false,
totalValue: "1250000",
};
const meta = {
args: { structure: { currencies: [rub], unvaluedCategories: [] } },
component: PortfolioStructure,
@@ -31,7 +32,9 @@ const meta = {
} satisfies Meta<typeof PortfolioStructure>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Regular: Story = {};
export const MultipleCurrencies: Story = {
args: {
@@ -77,12 +80,15 @@ export const Incomplete: Story = {
currencies: [
{
...rub,
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...row,
sharePercent: null,
}
/* eslint-enable unicorn/no-null */))),
categories: rub.categories.map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
row =>
({
...row,
sharePercent: null,
}),
/* eslint-enable unicorn/no-null */
),
},
],
unvaluedCategories: [{ count: 2, type: "STOCK" }],
@@ -103,13 +109,16 @@ export const Negative: Story = {
currencies: [
{
...rub,
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...row,
sharePercent: null,
value: row.type === "OTHER" ? "25000" : row.value,
}
/* eslint-enable unicorn/no-null */))),
categories: rub.categories.map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
row =>
({
...row,
sharePercent: null,
value: row.type === "OTHER" ? "25000" : row.value,
}),
/* eslint-enable unicorn/no-null */
),
hasNegativeValues: true,
totalValue: "1150000",
},
@@ -17,6 +17,7 @@ const rub: Portfolio["structure"]["currencies"][number] = {
hasNegativeValues: false,
totalValue: "100",
};
const ui = (structure: null | Portfolio["structure"] | undefined) => (
<MantineProvider>
<PortfolioStructure structure={structure} />
@@ -24,7 +25,11 @@ const ui = (structure: null | Portfolio["structure"] | undefined) => (
);
describe("portfolio structure", () => {
it.each([undefined, /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null])(
it.each([
undefined,
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
])(
"keeps an older snapshot without structure available (%s) and recovers on refresh",
(structure) => {
const view = render(ui(structure));
@@ -68,12 +73,15 @@ describe("portfolio structure", () => {
currencies: [
{
...rub,
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...row,
sharePercent: null,
}
/* eslint-enable unicorn/no-null */))),
categories: rub.categories.map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
row =>
({
...row,
sharePercent: null,
}),
/* eslint-enable unicorn/no-null */
),
},
],
unvaluedCategories: [
@@ -97,13 +105,16 @@ describe("portfolio structure", () => {
currencies: [
{
...rub,
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...row,
sharePercent: null,
value: row.type === "STOCK" ? "-60" : row.value,
}
/* eslint-enable unicorn/no-null */))),
categories: rub.categories.map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
row =>
({
...row,
sharePercent: null,
value: row.type === "STOCK" ? "-60" : row.value,
}),
/* eslint-enable unicorn/no-null */
),
hasNegativeValues: true,
},
],
@@ -123,6 +134,7 @@ describe("portfolio structure", () => {
const view = render(
ui({ currencies: [], unvaluedCategories: [{ count: 1, type: "BOND" }] }),
);
expect(screen.getByText("Облигации: без оценки — 1")).toBeInTheDocument();
expect(screen.queryByRole("list")).not.toBeInTheDocument();
view.rerender(ui({ currencies: [], unvaluedCategories: [] }));
@@ -1,80 +1,15 @@
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import type { FC } from "react";
import { PortfolioStructureView } from "./PortfolioStructure.view";
const MIN_VISIBLE_SHARE_PERCENT = 0.01;
// A cached snapshot or an older running server can predate this response field.
type PortfolioStructureProps = { structure?: null | Portfolio["structure"] };
const categoryPresentation = {
BOND: { color: "grape.6", label: "portfolio.bonds" },
CASH: { color: "green.6", label: "portfolio.structure.cash" },
OTHER: { color: "orange.6", label: "portfolio.structure.other" },
STOCK: { color: "blue.6", label: "portfolio.stocks" },
} as const;
import {
type PortfolioStructureProps,
usePortfolioStructureVm,
} from "./PortfolioStructure.vm";
const PortfolioStructure: FC<PortfolioStructureProps> = (props) => {
const { structure } = props;
const { t } = useTranslation();
const [currency, setCurrency] = useState<null | string>(null);
const selected
= structure?.currencies.find(item => item.currency === currency)
?? structure?.currencies[0];
const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
const rows = selected
? selected.categories.map((category) => {
const presentation = categoryPresentation[category.type];
const percentage
= category.sharePercent === null ? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null : Number(category.sharePercent);
const unknown = structure?.unvaluedCategories.some(
item => item.type === category.type,
);
const amount = formatMoney({
currency: selected.currency,
value: category.value,
});
let displayedAmount = amount;
if (unknown) displayedAmount = category.value === "0" ? t("portfolio.structure.unvalued") : t("portfolio.structure.knownAmount", { amount });
let share = t("portfolio.structure.shareUnavailable");
if (percentage !== null) share = percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT
? t("portfolio.structure.smallShare")
: `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`;
return {
amount: displayedAmount,
color: presentation.color,
name: t(presentation.label),
share,
value: percentage ?? 0,
};
})
: [];
const chartData = rows.filter(row => row.value > 0);
return (
<PortfolioStructureView
chartData={chartData}
currencies={structure?.currencies.map(item => item.currency) ?? []}
currency={selected?.currency ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null}
incomplete={isIncomplete}
negative={selected?.hasNegativeValues ?? false}
onCurrencyChange={setCurrency}
rows={rows}
unavailable={!structure}
unvalued={
structure?.unvaluedCategories.map(category =>
t("portfolio.structure.unvaluedCategory", {
category: t(categoryPresentation[category.type].label),
count: category.count,
}),
) ?? []
}
/>
);
const vm = usePortfolioStructureVm(props);
return <PortfolioStructureView {...vm} />;
};
export { PortfolioStructure };
@@ -16,6 +16,13 @@ type PortfolioStructureViewProps = {
unavailable: boolean;
unvalued: string[];
};
type PortfolioTooltipProps = {
active?: boolean;
payload?: { name?: number | string }[];
rows: StructureRow[];
};
type StructureRow = {
amount: string;
color: string;
@@ -24,9 +31,28 @@ type StructureRow = {
value: number;
};
const PortfolioTooltip: FC<PortfolioTooltipProps> = (props) => {
const row = props.rows.find(item => item.name === props.payload?.[0]?.name);
if (!row || !props.active) return;
return (
<Paper p="sm" radius="md" shadow="sm">
<Text fw={600} size="sm">{row.name}</Text>
<Text size="sm">{row.amount} · {row.share}</Text>
</Paper>
);
};
const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
const { t } = useTranslation();
const titleId = useId();
const appearanceClass = `${classes.content} ${props.chartData.length > 0 ? classes.layout : ""}`;
const tooltipProps = {
content: <PortfolioTooltip rows={props.chartData} />,
};
return (
<Stack
aria-labelledby={titleId}
@@ -38,7 +64,8 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
<Title id={titleId} order={2} size="h4">
{t("portfolio.structure.title")}
</Title>
{(props.currencies.length > 1) && ((
{props.currencies.length > 1 && (
<Select
allowDeselect={false}
aria-label={t("portfolio.structure.currency")}
@@ -47,8 +74,9 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
value={props.currency}
w={112}
/>
))}
)}
</Group>
<Text c="dimmed" size="sm">
{t(
props.unavailable
@@ -56,32 +84,36 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
: "portfolio.structure.basis",
)}
</Text>
{!!(props.incomplete) && ((
{!!props.incomplete && (
<Stack gap={4}>
<Text size="sm">{t("portfolio.structure.incomplete")}</Text>
{props.unvalued.map(message => (
<Text c="dimmed" key={message} size="sm">
{message}
</Text>
))}
</Stack>
))}
{!!(props.negative) && ((
)}
{!!props.negative && (
<Text size="sm">{t("portfolio.structure.negative")}</Text>
))}
{!!(!props.unavailable
)}
{!!(
!props.unavailable
&& props.chartData.length === 0
&& !props.incomplete
&& !props.negative) && ((
&& !props.negative
) && (
<Text c="dimmed" size="sm">
{t("portfolio.structure.empty")}
</Text>
))}
<div
className={`${classes.content} ${props.chartData.length > 0 ? classes.layout : ""}`}
key={props.currency}
>
{props.chartData.length > 0 && ((
)}
<div className={appearanceClass} key={props.currency}>
{props.chartData.length > 0 && (
<DonutChart
aria-label={t("portfolio.structure.chartLabel")}
chartLabel={props.currency ?? undefined}
@@ -94,32 +126,11 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
strokeWidth={2}
thickness={32}
tooltipDataSource="segment"
tooltipProps={{
content: ({ active, payload }) => {
const row = props.chartData.find(
item => item.name === payload?.[0]?.name,
);
return active && row
? (
<Paper p="sm" radius="md" shadow="sm">
<Text fw={600} size="sm">
{row.name}
</Text>
<Text size="sm">
{row.amount}
{" "}
·
{" "}
{row.share}
</Text>
</Paper>
)
: undefined;
},
}}
tooltipProps={tooltipProps}
/>
))}
{props.rows.length > 0 && ((
)}
{props.rows.length > 0 && (
<ul
aria-label={t("portfolio.structure.categories")}
className={classes.list}
@@ -132,12 +143,15 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
bg={row.color}
className={classes.marker}
/>
<Text size="sm">{row.name}</Text>
</Group>
<div className={classes.values}>
<Text fw={600} size="sm">
{row.amount}
</Text>
<Text c="dimmed" size="sm">
{row.share}
</Text>
@@ -145,7 +159,7 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
</li>
))}
</ul>
))}
)}
</div>
</Stack>
);
@@ -0,0 +1,108 @@
import type { TFunction } from "i18next";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
const MIN_VISIBLE_SHARE_PERCENT = 0.01;
// A cached snapshot or an older running server can predate this response field.
type PortfolioStructureProps = { structure?: null | Portfolio["structure"] };
const categoryPresentation = {
BOND: { color: "grape.6", label: "portfolio.bonds" },
CASH: { color: "green.6", label: "portfolio.structure.cash" },
OTHER: { color: "orange.6", label: "portfolio.structure.other" },
STOCK: { color: "blue.6", label: "portfolio.stocks" },
} as const;
const usePortfolioStructureVm = (props: PortfolioStructureProps) => {
const { structure } = props;
const { t } = useTranslation();
const [currency, setCurrency] = useState<null | string>(null);
const selected
= structure?.currencies.find(item => item.currency === currency)
?? structure?.currencies[0];
const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
const rows = portfolioRows(selected, structure, t);
const chartData = rows.filter(row => row.value > 0);
return {
chartData,
currencies: structure?.currencies.map(item => item.currency) ?? [],
// eslint-disable-next-line unicorn/no-null -- Mantine clears the controlled currency with null.
currency: selected?.currency ?? null,
incomplete: isIncomplete,
negative: selected?.hasNegativeValues ?? false,
onCurrencyChange: setCurrency,
rows,
unavailable: !structure,
unvalued:
structure?.unvaluedCategories.map(category =>
t("portfolio.structure.unvaluedCategory", {
category: t(categoryPresentation[category.type].label),
count: category.count,
}),
) ?? [],
};
};
function portfolioRows(
selected:
NonNullable<Portfolio["structure"]>["currencies"][number] | undefined,
structure: PortfolioStructureProps["structure"],
t: TFunction,
) {
if (!selected) return [];
return selected.categories.map((category) => {
const presentation = categoryPresentation[category.type];
const percentage
= category.sharePercent === null
? undefined
: Number(category.sharePercent);
const unknown = structure?.unvaluedCategories.some(
item => item.type === category.type,
);
let amount = formatMoney({
currency: selected.currency,
value: category.value,
});
if (unknown) {
amount
= category.value === "0"
? t("portfolio.structure.unvalued")
: t("portfolio.structure.knownAmount", { amount });
}
let share = t("portfolio.structure.shareUnavailable");
if (percentage !== undefined) {
if (percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT)
share = t("portfolio.structure.smallShare");
else
share = `${new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
}).format(percentage)} %`;
}
return {
amount,
color: presentation.color,
name: t(presentation.label),
share,
value: percentage ?? 0,
};
});
}
export { type PortfolioStructureProps, usePortfolioStructureVm };
@@ -4,24 +4,25 @@ import type { AccountPagePosition } from "../../AccountPage.vm";
import { PositionCard } from "./PositionCard.view";
const syntheticPosition = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
absoluteReturn: { currency: "RUB", value: "5000" },
averagePurchasePrice: { currency: "RUB", value: "200" },
currentValue: { currency: "RUB", value: "25000" },
displayName: "Учебные технологии",
figi: null,
isin: null,
logoName: null,
logoSrc: null,
quantity: "100",
returnText: "+5 000 ₽",
returnTone: "positive",
shareText: "20%",
ticker: "DEMO",
type: "STOCK",
}
/* eslint-enable unicorn/no-null */) satisfies AccountPagePosition;
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const syntheticPosition
= {
absoluteReturn: { currency: "RUB", value: "5000" },
averagePurchasePrice: { currency: "RUB", value: "200" },
currentValue: { currency: "RUB", value: "25000" },
displayName: "Учебные технологии",
figi: null,
isin: null,
logoName: null,
logoSrc: null,
quantity: "100",
returnText: "+5 000 ₽",
returnTone: "positive",
shareText: "20%",
ticker: "DEMO",
type: "STOCK",
} satisfies /* eslint-enable unicorn/no-null */ AccountPagePosition;
const meta = {
args: { kind: "stocks", position: syntheticPosition },
@@ -61,8 +62,10 @@ export const StockGain: Story = {
},
};
export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const BondLoss: Story
= {
args: {
kind: "bonds",
position: {
@@ -70,7 +73,8 @@ export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture pre
absoluteReturn: { currency: "RUB", value: "-500" },
averagePurchasePrice: { currency: "RUB", value: "1000" },
currentValue: { currency: "RUB", value: "9500" },
displayName: "Учебные облигации для долгосрочных накоплений, выпуск 2030",
displayName:
"Учебные облигации для долгосрочных накоплений, выпуск 2030",
isin: "DEMO-BOND-2030",
quantity: "10",
returnText: "−500 ₽",
@@ -85,15 +89,17 @@ export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture pre
docs: {
description: {
story:
"Для облигации используется идентификатор выпуска. Длинное имя и отрицательная сумма позволяют проверить компактное представление на узком экране.",
"Для облигации используется идентификатор выпуска. Длинное имя и отрицательная сумма позволяют проверить компактное представление на узком экране.",
},
},
},
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
export const Other: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const Other: Story
= {
args: {
kind: "other",
position: {
@@ -109,15 +115,17 @@ export const Other: Story = (/* eslint-disable unicorn/no-null -- Fixture preser
docs: {
description: {
story:
"Компактный вариант для остальных инструментов сохраняет количество, стоимость и пояснение результата без оформления акции или облигации.",
"Компактный вариант для остальных инструментов сохраняет количество, стоимость и пояснение результата без оформления акции или облигации.",
},
},
},
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
export const MissingValuation: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
export const MissingValuation: Story
= {
args: {
position: {
...syntheticPosition,
@@ -136,9 +144,9 @@ export const MissingValuation: Story = (/* eslint-disable unicorn/no-null -- Fix
docs: {
description: {
story:
"При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.",
"При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.",
},
},
},
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
@@ -23,20 +23,26 @@ const PositionCard: FC<PositionCardProps> = (props) => {
const { kind, position } = props;
const { t } = useTranslation();
const isExpanded = kind !== "other";
const returnClass = position.returnTone
? classes[position.returnTone]
: undefined;
let returnText = position.returnText;
if (returnText && !isExpanded) returnText = t("portfolio.return", { value: returnText });
if (returnText && !isExpanded)
returnText = t("portfolio.return", { value: returnText });
const instrumentTo = position.instrumentTo;
const identifier
= kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
const identifierText = [
t("portfolio.quantity", { value: position.quantity }),
identifier,
]
.filter(Boolean)
.join(" · ");
const accountLink = position.account
? (
<Anchor
@@ -48,15 +54,16 @@ const PositionCard: FC<PositionCardProps> = (props) => {
</Anchor>
)
: undefined;
const secondaryText = [
position.averagePurchasePrice
? t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null,
: null, // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null.
position.shareText
? t("portfolio.share", { value: position.shareText })
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null,
: null, // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null.
]
.filter(Boolean)
.join(" · ");
@@ -71,6 +78,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
src={position.logoSrc}
to={instrumentTo}
/>
<Stack className={classes.expandedIdentity} gap={2} miw={0}>
{instrumentTo
? (
@@ -83,31 +91,37 @@ const PositionCard: FC<PositionCardProps> = (props) => {
{position.displayName}
</Text>
)}
{accountLink}
<Text c="dimmed" className={classes.expandedIdentifiers} size="xs">
{identifierText}
</Text>
</Stack>
<Stack align="flex-end" className={classes.expandedValue} gap={2}>
{!!(position.currentValue) && ((
{!!position.currentValue && (
<Text fw={700} size="lg">
{formatMoney(position.currentValue)}
</Text>
))}
{!!(returnText) && ((
)}
{!!returnText && (
<Text className={returnClass} fw={600} size="sm">
{returnText}
</Text>
))}
)}
</Stack>
</div>
{!!(secondaryText) && ((
{!!secondaryText && (
<Text c="dimmed" className={classes.expandedMeta} size="xs">
{secondaryText}
</Text>
))}
)}
</Card>
);
return (
<Card className={classes.tile} withBorder>
<Group align="flex-start" justify="space-between" wrap="nowrap">
@@ -115,45 +129,52 @@ const PositionCard: FC<PositionCardProps> = (props) => {
<Text fw={700} lineClamp={1}>
{position.displayName}
</Text>
{accountLink}
{!!(position.isin) && ((
{!!position.isin && (
<Text c="dimmed" className={classes.mono} size="xs">
{position.isin}
</Text>
))}
)}
<Text c="dimmed" size="xs">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Stack>
<Stack align="flex-end" gap={2}>
{!!(position.currentValue) && ((
{!!position.currentValue && (
<Text fw={800} size="lg">
{formatMoney(position.currentValue)}
</Text>
))}
{!!(returnText) && ((
)}
{!!returnText && (
<Text className={returnClass} fw={600} size="sm">
{returnText}
</Text>
))}
)}
</Stack>
</Group>
{!!(position.averagePurchasePrice || position.shareText) && ((
{!!(position.averagePurchasePrice || position.shareText) && (
<Group justify="space-between" mt="xs">
{!!(position.averagePurchasePrice) && ((
{!!position.averagePurchasePrice && (
<Text c="dimmed" size="xs">
{t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})}
</Text>
))}
{!!(position.shareText) && ((
)}
{!!position.shareText && (
<Text c="dimmed" size="xs">
{t("portfolio.share", { value: position.shareText })}
</Text>
))}
)}
</Group>
))}
)}
</Card>
);
};
@@ -24,17 +24,19 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
const { t } = useTranslation();
const identity = group.positions[0];
const instrumentTo = identity.instrumentTo;
return (
<Card className={classes.tile} withBorder>
<Group gap="sm" wrap="nowrap">
{kind !== "other" && ((
{kind !== "other" && (
<PositionLogo
key={identity.logoSrc ?? "no-logo"}
label={identity.displayName}
src={identity.logoSrc}
to={instrumentTo}
/>
))}
)}
<Stack gap={2} miw={0}>
{instrumentTo
? (
@@ -45,72 +47,80 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
: (
<Text fw={600}>{identity.displayName}</Text>
)}
<Text c="dimmed" size="xs">
{identity.ticker ?? identity.isin ?? identity.figi}
</Text>
</Stack>
</Group>
<Stack
aria-label={t("portfolio.byAccount")}
className={classes.accountPositions}
component="ul"
gap={0}
>
{group.positions.map(position => (
<Stack
className={classes.accountPosition}
component="li"
gap={4}
key={position.account?.id}
>
<Group align="flex-start" gap="xs" justify="space-between">
{!!(position.account) && ((
<Anchor
className={classes.accountPositionName}
component={Link}
size="sm"
to={`/accounts/${encodeURIComponent(position.account.id)}`}
>
{position.account.name}
</Anchor>
))}
<Text fw={600} size="sm">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Group>
<Group gap="xs" justify="space-between">
{!!(position.currentValue) && ((
<Text size="sm">{formatMoney(position.currentValue)}</Text>
))}
{!!(position.returnText) && ((
<Text
className={
(position.returnTone) ? (classes[position.returnTone]) : undefined
}
size="sm"
>
{kind === "other"
? t("portfolio.return", { value: position.returnText })
: position.returnText}
{group.positions.map((position) => {
const appearanceClass = position.returnTone
? classes[position.returnTone]
: undefined;
return (
<Stack
className={classes.accountPosition}
component="li"
gap={4}
key={position.account?.id}
>
<Group align="flex-start" gap="xs" justify="space-between">
{!!position.account && (
<Anchor
className={classes.accountPositionName}
component={Link}
size="sm"
to={`/accounts/${encodeURIComponent(position.account.id)}`}
>
{position.account.name}
</Anchor>
)}
<Text fw={600} size="sm">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
))}
</Group>
<Group gap="xs">
{!!(position.averagePurchasePrice) && ((
<Text c="dimmed" size="xs">
{t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})}
</Text>
))}
{!!(position.shareText) && ((
<Text c="dimmed" size="xs">
{t("portfolio.share", { value: position.shareText })}
</Text>
))}
</Group>
</Stack>
))}
</Group>
<Group gap="xs" justify="space-between">
{!!position.currentValue && (
<Text size="sm">{formatMoney(position.currentValue)}</Text>
)}
{!!position.returnText && (
<Text className={appearanceClass} size="sm">
{kind === "other"
? t("portfolio.return", { value: position.returnText })
: position.returnText}
</Text>
)}
</Group>
<Group gap="xs">
{!!position.averagePurchasePrice && (
<Text c="dimmed" size="xs">
{t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})}
</Text>
)}
{!!position.shareText && (
<Text c="dimmed" size="xs">
{t("portfolio.share", { value: position.shareText })}
</Text>
)}
</Group>
</Stack>
);
})}
</Stack>
</Card>
);
@@ -1,9 +1,10 @@
import type { FC } from "react";
import { Anchor, Avatar } from "@mantine/core";
import { useState } from "react";
import { Link } from "react-router";
import { useImageFallback } from "@/shared/lib";
type PositionLogoProps = {
label: string;
src: null | string;
@@ -12,41 +13,36 @@ type PositionLogoProps = {
const PositionLogo: FC<PositionLogoProps> = (props) => {
const { label, src } = props;
const [failed, setFailed] = useState(false);
const { failed, onError } = useImageFallback();
const logo
= src && !failed
? (
<Avatar
alt={label}
onError={() => setFailed(true)}
radius="md"
size={40}
src={src}
/>
<Avatar alt={label} onError={onError} radius="md" size={40} src={src} />
)
: (
<Avatar radius="md" size={40}>
{(label.trim()[0] ?? "?").toUpperCase()}
</Avatar>
);
return props.to
? (
<Anchor
aria-label={label}
c="inherit"
component={Link}
display="block"
style={{ borderRadius: "var(--mantine-radius-md)" }}
to={props.to}
underline="never"
>
{logo}
</Anchor>
)
: (
logo
);
if (props.to) return (
<Anchor
aria-label={label}
c="inherit"
component={Link}
display="block"
style={{ borderRadius: "var(--mantine-radius-md)" }}
to={props.to}
underline="never"
>
{logo}
</Anchor>
);
return (
logo
);
};
export { PositionLogo };
@@ -15,24 +15,27 @@ const PositionSection: FC<PositionSectionProps> = (props) => {
const { section } = props;
const isExpanded = section.kind !== "other";
const cols = isExpanded ? 1 : { base: 1, md: 3, sm: 2 };
return (
<SimpleGrid cols={isExpanded ? 1 : { base: 1, md: 3, sm: 2 }}>
{section.groups.map(group =>
group.positions.length > 1
? (
<PositionGroupCard
group={group}
key={group.key}
kind={section.kind}
/>
)
: (
<PositionCard
key={group.key}
kind={section.kind}
position={group.positions[0]}
/>
),
<SimpleGrid cols={cols}>
{section.groups.map((group) => {
if (group.positions.length > 1) return (
<PositionGroupCard
group={group}
key={group.key}
kind={section.kind}
/>
);
return (
<PositionCard
key={group.key}
kind={section.kind}
position={group.positions[0]}
/>
);
},
)}
</SimpleGrid>
);
@@ -15,54 +15,61 @@ type RecentOperationsProps = {
};
const RecentOperations: FC<RecentOperationsProps> = (props) => {
const { operations } = props;
const { t } = useTranslation();
const { operations } = props;
return (
<Stack gap="sm">
{(operations.state === "loaded") && ((
{operations.state === "loaded" && (
<>
{!!(operations.lastSuccessfulAt) && ((
{!!operations.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(operations.lastSuccessfulAt),
})}
</Text>
))}
{!!(operations.stale) && ((
<Text size="xs">{t("investment.stale")}</Text>
))}
{!!(operations.refreshError) && ((
)}
{!!operations.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!operations.refreshError && (
<Text role="status">{t("accounts.refreshError")}</Text>
))}
)}
</>
))}
{(operations.state === "loading") && ((
)}
{operations.state === "loading" && (
<LoadingSkeleton label={t("ledger.recentLoading")}>
{["first", "second", "third"].map(placeholder => (
<Skeleton height={48} key={placeholder} radius="sm" />
))}
</LoadingSkeleton>
))}
{(operations.state === "error") && ((
)}
{operations.state === "error" && (
<Alert color="red">{t("ledger.recentError")}</Alert>
))}
{(operations.state === "empty") && ((
)}
{operations.state === "empty" && (
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
))}
{(operations.state === "partial") && ((
)}
{operations.state === "partial" && (
<Text role="status">{t("investment.partial")}</Text>
))}
{(operations.state === "loaded" || operations.state === "partial") && (operations.items.map(operation => (
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
)))}
)}
{(operations.state === "loaded" || operations.state === "partial")
&& operations.items.map(operation => (
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
))}
<Button component={Link} to={operations.to} variant="subtle">
{t("ledger.open")}
</Button>
@@ -8,8 +8,10 @@ import type { Portfolio } from "@/entities/portfolio";
import { toAccountPagePositionSections } from "./AccountPage.vm";
import { PositionSection } from "./components/PositionSection.view";
const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const position: Portfolio["positions"][number]
= {
absoluteReturn: { currency: "RUB", value: "25" },
account: { id: "a", name: "Основной" },
averagePurchasePrice: { currency: "RUB", value: "280" },
@@ -21,18 +23,21 @@ const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-n
quantity: "1.25",
ticker: "SBER",
type: "STOCK",
}
/* eslint-enable unicorn/no-null */);
const second = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
};
/* eslint-enable unicorn/no-null */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const second
= {
...position,
absoluteReturn: null,
account: { id: "b", name: "ИИС" },
averagePurchasePrice: null,
currentValue: null,
quantity: "-2",
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
function sections(positions: Portfolio["positions"]) {
return toAccountPagePositionSections({
cashBalances: [],
@@ -55,9 +60,11 @@ describe("Positions grouped by security", () => {
</MantineProvider>,
);
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
const rows = within(
screen.getByRole("list", { name: "Позиции по счетам" }),
).getAllByRole("listitem");
expect(rows).toHaveLength(2);
expect(
within(rows[0]).getByRole("link", { name: "Основной" }),
@@ -77,26 +84,31 @@ describe("Positions grouped by security", () => {
it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => {
expect(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
sections([
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...position, figi: null }
/* eslint-enable unicorn/no-null */),
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...second, figi: null }
/* eslint-enable unicorn/no-null */),
{
...position,
figi: null,
},
/* eslint-enable unicorn/no-null */ { ...second, figi: null }, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
])[0].groups,
).toHaveLength(1);
expect(
sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups,
).toHaveLength(2);
expect(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
sections([
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...position, figi: null, isin: null }
/* eslint-enable unicorn/no-null */),
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ ...second, figi: null, isin: null }
/* eslint-enable unicorn/no-null */),
{
...position,
figi: null,
isin: null,
},
/* eslint-enable unicorn/no-null */ {
...second,
figi: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
isin: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
},
])[0].groups,
).toHaveLength(2);
});
@@ -122,6 +134,7 @@ describe("Positions grouped by security", () => {
currentValue: { currency: "USD", value: "1.01" },
},
];
const before = structuredClone(source);
const [group] = sections(source)[0].groups;
expect(
@@ -23,27 +23,34 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
"bonds",
"other",
];
const width = window.innerWidth;
expect([375, 1280]).toContain(width);
for (const kind of kinds) {
const otherType = kind === "bonds" ? "BOND" : "FUND";
const positions: AccountPagePosition[] = [1, 2, 3].map(index => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
absoluteReturn: null,
averagePurchasePrice: null,
currentValue: { currency: "RUB", value: "1000" },
displayName: `Инструмент ${index}`,
figi: null,
isin: null,
logoName: null,
logoSrc: null,
quantity: "10",
returnText: null,
shareText: null,
ticker: null,
type: kind === "stocks" ? "STOCK" : otherType,
}
/* eslint-enable unicorn/no-null */)));
const positions: AccountPagePosition[] = [1, 2, 3].map(
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
index =>
({
absoluteReturn: null,
averagePurchasePrice: null,
currentValue: { currency: "RUB", value: "1000" },
displayName: `Инструмент ${index}`,
figi: null,
isin: null,
logoName: null,
logoSrc: null,
quantity: "10",
returnText: null,
shareText: null,
ticker: null,
type: kind === "stocks" ? "STOCK" : otherType,
}),
/* eslint-enable unicorn/no-null */
);
const { unmount } = render(
<MantineProvider
cssVariablesResolver={cssVariablesResolver}
@@ -61,15 +68,19 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
/>
</MantineProvider>,
);
const bounds = positions.map(position =>
screen.getByText(position.displayName).getBoundingClientRect(),
);
const context = `${kind} at ${width}px`;
for (const [index, current] of bounds.entries()) {
expect(current.width, context).toBeGreaterThan(0);
expect(current.height, context).toBeGreaterThan(0);
if (index === 0) continue;
const previous = bounds[index - 1];
if (kind === "other" && width === 1280) {
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(current.top, context).toBeCloseTo(previous.top, 0);
@@ -83,6 +94,7 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
expect(current.top, context).toBeGreaterThan(previous.bottom);
}
}
unmount();
}
});
@@ -33,6 +33,7 @@ function configureTestAuth() {
function renderPage(queryClient: QueryClient) {
configureTestAuth();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
@@ -50,6 +51,7 @@ describe("AccountsPage", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return modes.length === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([account]));
@@ -102,9 +104,11 @@ describe("AccountsPage", () => {
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(resource([account]));
{
const deferred = Promise.withResolvers<Response>();
resolveRefresh = deferred.resolve;
return deferred.promise;
}
}),
@@ -131,6 +135,7 @@ describe("AccountsPage", () => {
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
requests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
@@ -142,11 +147,9 @@ describe("AccountsPage", () => {
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(resource([account]));
return requestCount === 2
? HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
)
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([account]));
}),
);
@@ -161,6 +164,7 @@ describe("AccountsPage", () => {
const warning = await screen.findByText(
"Не удалось обновить данные. Показаны последние полученные.",
);
expect(warning).toBeInTheDocument();
expect(screen.getByText(account.name)).toBeInTheDocument();
@@ -176,12 +180,10 @@ describe("AccountsPage", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
return requestCount === 1
? HttpResponse.json(resource([]))
: HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
const queryClient = createTestQueryClient();
@@ -210,11 +212,13 @@ it("keeps unknown membership loading instead of declaring no accounts", async ()
const client = createTestQueryClient();
renderPage(client);
await waitFor(() =>
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject(
{
data: null,
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
expect(
screen.queryByText("Нет инвестиционных счетов"),
@@ -20,6 +20,7 @@ const AccountsPage: FC = () => {
sourceError={viewModel.sourceError}
/>
);
return (
<AccountsPageLoaded
accounts={viewModel.accounts}
@@ -8,6 +8,7 @@ import { apiErrorCode } from "@/shared/api";
function useAccountsPageViewModel() {
const accounts = useBrokerageAccounts();
const sync = useScreenSync({ screen: "accounts" }, [accounts]);
if (
!accounts.data
|| (accounts.meta?.availability === "partial" && accounts.data.length === 0)
@@ -21,6 +22,7 @@ function useAccountsPageViewModel() {
state: "error" as const,
};
}
if (!accounts.data?.length)
return {
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
@@ -30,6 +32,7 @@ function useAccountsPageViewModel() {
sourceError: sync.sourceError,
state: "empty" as const,
};
return {
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
@@ -11,9 +11,11 @@ import {
const AccountsPageEmpty: FC<AccountsRefreshStateProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<AccountsRefreshState {...props} />
<StateView
text={t("accounts.emptyText")}
title={t("accounts.emptyTitle")}
@@ -12,11 +12,13 @@ type AccountsPageErrorProps = { code?: string; retry?: () => void };
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
const { code, retry } = props;
const { t } = useTranslation();
const settingsAction = (
<Button component={Link} to="/settings" variant="light">
{t("accounts.openSettings")}
</Button>
);
if (isApiErrorCode(code, ApiErrorCode.TbankNotConnected))
return (
<StateView
@@ -41,6 +43,7 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
action={(
<Stack align="flex-start">
<Button onClick={retry}>{t("accounts.retry")}</Button>
{settingsAction}
</Stack>
)}
@@ -49,6 +52,7 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
title={t("accounts.unavailableTitle")}
/>
);
return (
<StateView
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
@@ -21,15 +21,19 @@ type AccountsPageLoadedProps = AccountsRefreshStateProps & {
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { accounts, ...refresh } = props;
const { t } = useTranslation();
return (
<Stack gap="lg">
<Stack gap={4}>
<Title order={1}>{t("accounts.title")}</Title>
<Text c="dimmed" size="sm">
{t("accounts.subtitle")}
</Text>
</Stack>
<AccountsRefreshState {...refresh} />
<Button
component={Link}
to="/accounts/all"
@@ -38,6 +42,7 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
>
{t("accounts.openAll")}
</Button>
<BrokerageAccountList accounts={accounts} />
</Stack>
);
@@ -6,26 +6,35 @@ import { useTranslation } from "react-i18next";
const AccountsPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="30%" />
<Skeleton height={20} width="45%" />
<SimpleGrid aria-hidden="true" cols={1} spacing="lg">
{[0, 1].map(index => (
<Card key={index} padding="xl" radius="lg" withBorder>
<Stack gap="xl">
<Stack gap="sm">
<Skeleton height={24} width="35%" />
<Skeleton height={28} width="65%" />
</Stack>
<Stack gap="xs">
<Skeleton height={16} width="30%" />
<Skeleton height={40} width="60%" />
</Stack>
<Group grow>
<Skeleton height={56} />
<Skeleton height={56} />
</Group>
<Skeleton height={20} />
</Stack>
</Card>
@@ -19,13 +19,14 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
return (
<>
{!!(props.lastSuccessfulAt) && ((
{!!props.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
))}
)}
<Button
disabled={refreshing}
onClick={retry}
@@ -34,15 +35,18 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
>
{t(refreshError ? "accounts.retry" : "investment.refresh")}
</Button>
{!!(refreshing) && ((
{!!refreshing && (
<Group aria-label={t("accounts.refreshing")} gap="xs" role="status">
<Loader size="sm" />
<Text c="dimmed" size="sm">
{t("accounts.refreshing")}
</Text>
</Group>
))}
{!!(refreshError) && ((
)}
{!!refreshError && (
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="sm">
<Text>
@@ -54,7 +58,7 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
</Text>
</Stack>
</Alert>
))}
)}
</>
);
};
+48 -26
View File
@@ -14,9 +14,11 @@ import { BondPage } from "./BondPage";
const LocationProbe = () => {
const location = useLocation();
return (
<output data-testid="location">
{location.pathname}
{location.search}
</output>
);
@@ -24,8 +26,11 @@ const LocationProbe = () => {
const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13";
const money = (value: string) => ({ currency: "RUB", value });
const bond = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const bond
= {
aci: money("12"),
amortization: true,
callDate: null,
@@ -45,8 +50,9 @@ const bond = (/* eslint-disable unicorn/no-null -- Fixture preserves the product
sector: "financial",
subordinated: false,
ticker: "TEST",
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
const position = {
absoluteReturn: money("1000"),
accounts: [
@@ -67,8 +73,11 @@ const position = {
quantity: "10",
returnPercent: "50",
};
const quote = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const quote
= {
aci: money("12"),
changePercentagePoints: "-0.875",
cleanPrice: money("490.625"),
@@ -79,8 +88,9 @@ const quote = (/* eslint-disable unicorn/no-null -- Fixture preserves the produc
pricePercent: "98.125",
tradingStatus: null,
unavailable: [],
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
function setup(
options: {
ambiguousTicker?: boolean;
@@ -103,14 +113,17 @@ function setup(
),
http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => {
requests.push(new URL(request.url));
return options.ambiguousTicker
? HttpResponse.json({ code: "BOND_TICKER_AMBIGUOUS" }, { status: 409 })
: HttpResponse.json(resource(resolved));
}),
http.get("*/api/v1/bonds/:uid/metrics", ({ request }) => {
requests.push(new URL(request.url));
return HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{
coupon: money("32.88"),
couponDate: "2026-10-27",
@@ -144,8 +157,9 @@ function setup(
observedAt: quote.observedAt,
unavailable:
options.ratingState === "unavailable" ? ["rating"] : [],
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
}),
http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => {
@@ -165,7 +179,8 @@ function setup(
);
if (params.section === "candles")
return HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{
candles: [
{
@@ -177,16 +192,19 @@ function setup(
currency: null,
historyFrom: null,
observedAt: quote.observedAt,
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
if (options.failCoupons)
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
return HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{
coupons: [
{
@@ -216,16 +234,19 @@ function setup(
positionAt: null,
positionIsStale: false,
to: "2027-09-10",
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
}),
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
const url = new URL(request.url);
requests.push(url);
const isNext = url.searchParams.has("cursor");
return HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
{
accountId: "all",
hasNext: !isNext,
@@ -246,8 +267,9 @@ function setup(
],
nextCursor: isNext ? null : "next",
observedAt: quote.observedAt,
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
}),
);
@@ -262,6 +284,7 @@ function setup(
<MantineProvider>
<MemoryRouter initialEntries={[options.path ?? `/bonds/${uid}`]}>
<LocationProbe />
<Routes>
<Route element={<BondPage />} path="/bonds/:bondId" />
</Routes>
@@ -269,6 +292,7 @@ function setup(
</MantineProvider>
</QueryClientProvider>,
);
return {
failQuote: () => {
isFailQuote = true;
@@ -276,6 +300,7 @@ function setup(
requests,
};
}
describe("bond page", () => {
it("opens a direct ticker, keeps percentages distinct from money, and filters personal history by UID", async () => {
const { requests } = setup({ path: "/bonds/TEST", slug: "TEST" });
@@ -339,9 +364,7 @@ describe("bond page", () => {
const message = await screen.findByText(/Не удалось загрузить этот раздел/);
expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest(
"[role=\"status\"]",
),
message.closest("[role=\"status\"]"),
).toHaveAttribute("aria-live", "polite");
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
@@ -355,6 +378,7 @@ describe("bond page", () => {
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
scopes.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
@@ -368,9 +392,7 @@ describe("bond page", () => {
const message = await screen.findByText(/последние полученные/);
expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest(
"[role=\"status\"]",
),
message.closest("[role=\"status\"]"),
).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("98,125 %")).toBeVisible();
expect(scopes).toEqual([
+15 -1
View File
@@ -19,16 +19,26 @@ const BondPage: FC = () => {
<LoadingSkeleton label={t("bond.loading")}>
<Stack>
<Skeleton h={110} />
<Skeleton h={350} />
<Skeleton h={170} />
</Stack>
</LoadingSkeleton>
);
const unavailableMessage = isApiErrorCode(vm.code, ApiErrorCode.BondTickerAmbiguous) ? "bond.ambiguousTicker" : "bond.unavailableText";
const unavailableMessage = isApiErrorCode(
vm.code,
ApiErrorCode.BondTickerAmbiguous,
)
? "bond.ambiguousTicker"
: "bond.unavailableText";
if (!vm.bond)
return (
<Stack role="alert">
<Title order={1}>{t("bond.unavailable")}</Title>
<Text>
{t(
isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected)
@@ -37,17 +47,21 @@ const BondPage: FC = () => {
: unavailableMessage,
)}
</Text>
<Anchor component={Link} to="/bonds">
{t("bond.backToSearch")}
</Anchor>
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button onClick={vm.bondState.retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
);
return <BondPageView vm={vm} />;
};
+254 -191
View File
@@ -35,12 +35,113 @@ import type { BondPageVm } from "./BondPage.vm";
import classes from "./BondPage.module.css";
type BondPageViewProps = { vm: BondPageVm };
const BondPageView: FC<BondPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const bond = vm.bond;
if (!bond) return;
const maturity = bond.maturityDate ? formatDate(bond.maturityDate) : t("bond.unknown");
const maturity = bond.maturityDate
? formatDate(bond.maturityDate)
: t("bond.unknown");
const maturityDate = bond.perpetual ? t("bond.perpetual") : maturity;
const callDate = bond.callDate
? formatDate(bond.callDate)
: t("bond.unknown");
const couponFrequency
= bond.couponQuantityPerYear == undefined
? t("bond.unknown")
: String(bond.couponQuantityPerYear);
const lotSize = bond.lot == undefined ? t("bond.unknown") : String(bond.lot);
let quoteContent;
if (vm.quoteState.loading) {
quoteContent = (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton h={55} mt="lg" w="50%" />
</LoadingSkeleton>
);
}
else {
quoteContent = (
<Stack aria-live="polite" gap="xs" mt="lg">
<Text c="dimmed" size="sm">
{t("bond.percentOfNominal")}
</Text>
<Text className={classes.price}>
{vm.quote?.pricePercent == undefined
? t("bond.unknown")
: `${formatDecimal(vm.quote.pricePercent)} %`}
</Text>
{vm.quote?.changePercentagePoints != undefined && (
<Text fw={600}>
{formatDecimal(vm.quote.changePercentagePoints)}{" "}
{t("bond.percentagePoints")}
</Text>
)}
<Text c="dimmed" size="xs">
{vm.quote?.previousCloseDate
? t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})
: t("stock.noComparison")}
</Text>
<Group gap="xl" my="sm">
<Metric
label={t("bond.cleanPrice")}
value={formatMoney(vm.quote?.cleanPrice)}
/>
<Metric
label={t("bond.aci")}
value={formatMoney(vm.quote?.aci)}
/>
<Metric
label={t("bond.dirtyPrice")}
value={formatMoney(vm.quote?.dirtyPrice)}
/>
</Group>
<Text c="dimmed" size="xs">
{t("bond.indicativePrice")}
</Text>
<Group>
<Badge color="gray" variant="light">
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{!!vm.quote?.priceAt && (
<Text c="dimmed" size="xs">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
</Text>
)}
</Group>
{!!vm.quote?.unavailable.length && (
<Text c="dimmed" size="xs">
{t("stock.partialQuote")}
</Text>
)}
</Stack>
);
}
return (
<Stack className={classes.page} gap="lg">
<Paper className={classes.section} component="header" withBorder>
@@ -49,19 +150,18 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
<Avatar alt="" radius="md" size={56} src={vm.logoSrc}>
{bond.name[0]}
</Avatar>
<div>
<Title order={1} size="h2">
{bond.name}
</Title>
<Text c="dimmed" size="sm">
{bond.ticker}
{" "}
·
{" "}
{bond.isin}
{bond.ticker} · {bond.isin}
</Text>
</div>
</div>
<Button
disabled={vm.refreshing}
onClick={vm.refresh}
@@ -70,150 +170,80 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
{t("investment.refresh")}
</Button>
</Group>
<Group gap="xs" mt="md">
{!!(bond.floatingCoupon) && ((
{!!bond.floatingCoupon && (
<Badge variant="light">{t("bond.floatingCoupon")}</Badge>
))}
{!!(bond.amortization) && ((
)}
{!!bond.amortization && (
<Badge variant="light">{t("bond.amortization")}</Badge>
))}
{!!(bond.perpetual) && ((
)}
{!!bond.perpetual && (
<Badge variant="light">{t("bond.perpetual")}</Badge>
))}
{!!(bond.subordinated) && ((
)}
{!!bond.subordinated && (
<Badge color="orange" variant="light">
{t("bond.subordinated")}
</Badge>
))}
)}
</Group>
{!!(vm.refreshError || vm.bondState.error || vm.quoteState.error) && ((
{!!(vm.refreshError || vm.bondState.error || vm.quoteState.error) && (
<Alert aria-live="polite" color="orange" mt="md" role="status">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
))}
{vm.quoteState.loading
? (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton h={55} mt="lg" w="50%" />
</LoadingSkeleton>
)
: (
<Stack aria-live="polite" gap="xs" mt="lg">
<Text c="dimmed" size="sm">
{t("bond.percentOfNominal")}
</Text>
<Text className={classes.price}>
{vm.quote?.pricePercent == undefined
? t("bond.unknown")
: `${formatDecimal(vm.quote.pricePercent)} %`}
</Text>
{vm.quote?.changePercentagePoints != undefined && ((
<Text fw={600}>
{formatDecimal(vm.quote.changePercentagePoints)}
{" "}
{t("bond.percentagePoints")}
</Text>
))}
<Text c="dimmed" size="xs">
{vm.quote?.previousCloseDate
? t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})
: t("stock.noComparison")}
</Text>
<Group gap="xl" my="sm">
<Metric
label={t("bond.cleanPrice")}
value={formatMoney(vm.quote?.cleanPrice)}
/>
<Metric
label={t("bond.aci")}
value={formatMoney(vm.quote?.aci)}
/>
<Metric
label={t("bond.dirtyPrice")}
value={formatMoney(vm.quote?.dirtyPrice)}
/>
</Group>
<Text c="dimmed" size="xs">
{t("bond.indicativePrice")}
</Text>
<Group>
<Badge color="gray" variant="light">
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{!!(vm.quote?.priceAt) && ((
<Text c="dimmed" size="xs">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
</Text>
))}
</Group>
{!!(vm.quote?.unavailable.length) && ((
<Text c="dimmed" size="xs">
{t("stock.partialQuote")}
</Text>
))}
</Stack>
)}
{!!(vm.quoteState.refreshing || vm.bondState.refreshing) && ((
)}
{quoteContent}
{!!(vm.quoteState.refreshing || vm.bondState.refreshing) && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
))}
)}
<div className={classes.embeddedSection}>
<Title mb="md" order={2} size="h3">
{t("bond.terms")}
</Title>
<div className={classes.companyMetrics}>
<Metric
label={t("bond.nominal")}
value={formatMoney(bond.nominal)}
/>
<Metric
label={t("bond.initialNominal")}
value={formatMoney(bond.initialNominal)}
/>
<Metric
label={t("bond.maturity")}
value={
bond.perpetual
? t("bond.perpetual")
: maturity
}
/>
<Metric
label={t("bond.callDate")}
value={
bond.callDate ? formatDate(bond.callDate) : t("bond.unknown")
}
/>
<Metric
label={t("bond.couponFrequency")}
value={
bond.couponQuantityPerYear == undefined
? t("bond.unknown")
: String(bond.couponQuantityPerYear)
}
/>
<Metric
label={t("bond.lot")}
value={bond.lot == undefined ? t("bond.unknown") : String(bond.lot)}
/>
<Metric label={t("bond.maturity")} value={maturityDate} />
<Metric label={t("bond.callDate")} value={callDate} />
<Metric label={t("bond.couponFrequency")} value={couponFrequency} />
<Metric label={t("bond.lot")} value={lotSize} />
</div>
</div>
</Paper>
<BondMetrics vm={vm} />
<InstrumentChart priceUnit="percent" vm={vm} />
<InstrumentPosition
quantityLabel={t("bond.quantity")}
returnNote={t("bond.returnNote")}
vm={vm}
/>
<BondCoupons vm={vm} />
<InstrumentEvents
historyNote={t("bond.historyNote")}
incomeFilter="coupons"
@@ -228,55 +258,87 @@ const BondMetrics: FC<BondPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const creditRating = vm.metrics?.creditRating;
let rating = t(vm.metrics?.unavailable.includes("rating") ? "bond.ratingUnavailable" : "bond.ratingMissing");
if (creditRating) rating = creditRating.status === "withdrawn" ? t("bond.ratingWithdrawn") : (creditRating.value ?? t("bond.unknown"));
let rating = t(
vm.metrics?.unavailable.includes("rating")
? "bond.ratingUnavailable"
: "bond.ratingMissing",
);
if (creditRating) {
rating
= creditRating.status === "withdrawn"
? t("bond.ratingWithdrawn")
: (creditRating.value ?? t("bond.unknown"));
}
const couponDate = vm.metrics?.couponDate
? formatDate(vm.metrics?.couponDate)
: t("bond.unknown");
const effectiveYield = vm.metrics?.effectiveYield
? `${formatDecimal(vm.metrics?.effectiveYield.value)} %`
: t("bond.unknown");
const couponYield
= vm.metrics?.couponYieldPercent == undefined
? t("bond.unknown")
: `${formatDecimal(vm.metrics?.couponYieldPercent)} %`;
let yieldContent;
if (vm.metrics?.effectiveYield) {
yieldContent = (
<Text c="dimmed" size="xs">
{t("bond.yieldSource", {
at: formatSnapshotTime(vm.metrics?.effectiveYield.tradeAt),
date: formatDate(vm.metrics?.effectiveYield.date),
price: formatDecimal(
vm.metrics?.effectiveYield.pricePercent,
),
target: t(
`bond.yieldTargets.${vm.metrics?.effectiveYield.dateType}`,
{ defaultValue: t("bond.yieldTargets.unknown") },
),
})}
</Text>
);
}
else {
yieldContent = (
<Text c="dimmed" size="xs">
{t("bond.yieldUnavailable")}
</Text>
);
}
return (
<InstrumentSection state={vm.metricsState} title={t("bond.metrics")}>
{!!(vm.metrics) && ((
{!!vm.metrics && (
<Stack gap="md">
<div className={classes.companyMetrics}>
<Metric
label={t("bond.creditRating")}
value={
rating
}
/>
<Metric label={t("bond.creditRating")} value={rating} />
<Metric
label={t("bond.couponAmount")}
value={formatMoney(vm.metrics.coupon)}
/>
<Metric
label={t("bond.couponType")}
value={t(`bond.couponTypes.${vm.metrics.couponType}`, {
defaultValue: t("bond.unknown"),
})}
/>
<Metric
label={t("bond.couponDate")}
value={
vm.metrics.couponDate
? formatDate(vm.metrics.couponDate)
: t("bond.unknown")
}
/>
<Metric
label={t("bond.effectiveYield")}
value={
vm.metrics.effectiveYield
? `${formatDecimal(vm.metrics.effectiveYield.value)} %`
: t("bond.unknown")
}
/>
<Metric
label={t("bond.couponYield")}
value={
vm.metrics.couponYieldPercent === null
? t("bond.unknown")
: `${formatDecimal(vm.metrics.couponYieldPercent)} %`
}
/>
<Metric label={t("bond.couponDate")} value={couponDate} />
<Metric label={t("bond.effectiveYield")} value={effectiveYield} />
<Metric label={t("bond.couponYield")} value={couponYield} />
</div>
{!!(vm.metrics.creditRating) && ((
{!!vm.metrics.creditRating && (
<Group gap="xs">
<Anchor
href={vm.metrics.creditRating.sourceUrl}
@@ -288,54 +350,40 @@ const BondMetrics: FC<BondPageViewProps> = (props) => {
date: formatDate(vm.metrics.creditRating.date),
})}
</Anchor>
<Text c="dimmed" size="xs">
{t("bond.ratingChecked", {
date: formatSnapshotTime(
vm.metrics.creditRating.observedAt,
),
date: formatSnapshotTime(vm.metrics.creditRating.observedAt),
})}
</Text>
</Group>
))}
)}
<Text c="dimmed" size="xs">
{t("bond.ratingCoverage")}
</Text>
{vm.metrics.effectiveYield
? (
<Text c="dimmed" size="xs">
{t("bond.yieldSource", {
at: formatSnapshotTime(vm.metrics.effectiveYield.tradeAt),
date: formatDate(vm.metrics.effectiveYield.date),
price: formatDecimal(vm.metrics.effectiveYield.pricePercent),
target: t(
`bond.yieldTargets.${vm.metrics.effectiveYield.dateType}`,
{ defaultValue: t("bond.yieldTargets.unknown") },
),
})}
</Text>
)
: (
<Text c="dimmed" size="xs">
{t("bond.yieldUnavailable")}
</Text>
)}
{yieldContent}
<Text c="dimmed" size="xs">
{t("bond.couponYieldNote")}
</Text>
{!!(vm.metrics.couponYieldPriceAt) && ((
{!!vm.metrics?.couponYieldPriceAt && (
<Text c="dimmed" size="xs">
{t("bond.couponYieldPriceAt", {
date: formatSnapshotTime(vm.metrics.couponYieldPriceAt),
date: formatSnapshotTime(vm.metrics?.couponYieldPriceAt),
})}
</Text>
))}
{vm.metrics.unavailable.length > 0 && ((
)}
{vm.metrics.unavailable.length > 0 && (
<Text c="dimmed" size="xs">
{t("bond.metricsPartial")}
</Text>
))}
)}
</Stack>
))}
)}
</InstrumentSection>
);
};
@@ -343,18 +391,20 @@ const BondMetrics: FC<BondPageViewProps> = (props) => {
const BondCoupons: FC<BondPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<InstrumentSection
state={vm.couponsState}
title={t("bond.upcomingCoupons")}
>
{!!(vm.coupons) && ((
{!!vm.coupons && (
<Stack gap="md">
{!!(vm.coupons.estimateUnavailable || vm.coupons.positionIsStale) && ((
{!!(vm.coupons.estimateUnavailable || vm.coupons.positionIsStale) && (
<Alert aria-live="polite" color="orange" role="status">
{t("stock.estimateUnavailable")}
</Alert>
))}
)}
{vm.upcoming.length > 0
? (
vm.upcoming.map(coupon => (
@@ -367,25 +417,30 @@ const BondCoupons: FC<BondPageViewProps> = (props) => {
: (
<Text c="dimmed">{t("bond.noCoupons")}</Text>
)}
<Text c="dimmed" size="xs">
{t("bond.couponWindow", {
from: formatDate(vm.coupons.from),
to: formatDate(vm.coupons.to),
})}
</Text>
<Text c="dimmed" size="xs">
{t("bond.couponNote")}
</Text>
{!!(vm.coupons.positionAt) && ((
{!!vm.coupons.positionAt && (
<Text c="dimmed" size="xs">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.coupons.positionAt),
})}
</Text>
))}
)}
<Accordion radius="md" variant="contained">
<Accordion.Item value="history">
<Accordion.Control>{t("bond.couponHistory")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{vm.pastCoupons.length > 0
@@ -405,28 +460,33 @@ const BondCoupons: FC<BondPageViewProps> = (props) => {
</Accordion.Item>
</Accordion>
</Stack>
))}
)}
</InstrumentSection>
);
};
type MetricProps = { label: string; value: string };
const Metric: FC<MetricProps> = props => (
<div>
<Text c="dimmed" size="xs">
{props.label}
</Text>
<Text fw={600} size="lg">
{props.value}
</Text>
</div>
);
type CouponProps = {
coupon: NonNullable<BondPageVm["coupons"]>["coupons"][number];
};
const Coupon: FC<CouponProps> = (props) => {
const { coupon } = props;
const { t } = useTranslation();
return (
<Paper p="md" radius="md" withBorder>
<Group align="start" justify="space-between">
@@ -436,39 +496,42 @@ const Coupon: FC<CouponProps> = (props) => {
? formatDate(coupon.paymentDate)
: t("stock.dateUnknown")}
</Text>
<Text c="dimmed" size="xs">
{t("bond.couponNumber", {
number: coupon.number ?? t("bond.unknown"),
})}
</Text>
</div>
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
{!!(coupon.estimatedAmount) && ((
{!!coupon.estimatedAmount && (
<Metric
label={t("stock.yourEstimate")}
value={formatMoney(coupon.estimatedAmount)}
/>
))}
)}
</Group>
<Group gap="md" mt="sm">
<Badge color="gray" variant="light">
{t(`bond.couponTypes.${coupon.couponType}`, {
defaultValue: t("bond.unknown"),
})}
</Badge>
{coupon.periodDays != undefined && ((
{coupon.periodDays != undefined && (
<Text c="dimmed" size="xs">
{t("bond.couponDays", { count: coupon.periodDays })}
</Text>
))}
{!!(coupon.recordDate) && ((
)}
{!!coupon.recordDate && (
<Text c="dimmed" size="xs">
{t("stock.record")}
:
{" "}
{formatDate(coupon.recordDate)}
{t("stock.record")}: {formatDate(coupon.recordDate)}
</Text>
))}
)}
</Group>
</Paper>
);
+37 -6
View File
@@ -21,55 +21,78 @@ import { formatDate, instrumentLogoUrl } from "@/shared/lib";
const periods: BondPeriod[] = ["month", "halfYear", "year", "all"];
const filters = ["all", "trades", "coupons", "other"] as const;
type BondEventFilter = (typeof filters)[number];
const categories: Record<BondEventFilter, OperationCategory[]> = {
all: [],
coupons: ["income"],
other: ["tax", "fee", "transfer", "other"],
trades: ["trade"],
};
type BondPageVm = ReturnType<typeof useBondPageVm>;
function useBondPageVm() {
const { bondId = "" } = useParams();
const bond = useBond(bondId);
const uid = bond.data?.instrumentUid ?? "";
const [parameters, setParameters] = useSearchParams();
const period
= periods.find(value => value === parameters.get("period")) ?? "month";
const filter
= filters.find(value => value === parameters.get("events")) ?? "all";
const quote = useBondQuote(uid);
const metrics = useBondMetrics(uid);
const candles = useBondCandles(uid, period);
const position = useBondPosition(uid);
const coupons = useBondCoupons(uid);
const history = useInstrumentOperations(uid, filter, /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter]);
const history = useInstrumentOperations(
uid,
filter,
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
categories[filter],
);
const { operations } = history;
const sync = useScreenSync(
{
accountId: "all",
candlePeriod: period,
categories: /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter],
categories:
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
categories[filter],
instrumentUid: uid,
limit: 20,
screen: "bond",
},
[bond, quote, candles, position, metrics, coupons, operations],
);
const setParameter = (key: string, value: string) => {
const next = new URLSearchParams(parameters);
next.set(key, value);
setParameters(next, { replace: true });
};
const chartData
= candles.data?.candles.map(candle => ({
...candle,
date: formatDate(candle.time),
price: Number(candle.close),
})) ?? [];
const search = parameters.size > 0 ? `?${parameters.toString()}` : "";
return {
averageLine: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
averageLine:
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
null,
bond: bond.data,
bondState: section(bond, uid ? sync.retry : () => bond.refetch()),
candles: candles.data,
@@ -77,7 +100,7 @@ function useBondPageVm() {
canonicalTo:
bond.data?.slug && bond.data.slug !== bondId
? `/bonds/${encodeURIComponent(bond.data.slug)}${search}`
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
chartData,
code: apiErrorCode(bond.error),
coupons: coupons.data,
@@ -85,8 +108,16 @@ function useBondPageVm() {
eventRows:
operations.data?.items.map(item => ({
...item,
logoName: item.logoName ?? bond.data?.logoName ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
ticker: item.ticker ?? bond.data?.ticker ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
logoName:
item.logoName
?? bond.data?.logoName
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
?? null,
ticker:
item.ticker
?? bond.data?.ticker
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
?? null,
})) ?? [],
filter,
hasIncompleteCandle:
@@ -18,6 +18,7 @@ describe("bond search", () => {
server.use(
http.get("*/api/v1/bonds", ({ request }) => {
query(new URL(request.url).searchParams.get("query"));
return HttpResponse.json(
resource([
{
+148 -130
View File
@@ -29,140 +29,155 @@ import {
import type { BondsPageVm } from "./BondsPage.vm";
import classes from "./BondsPage.module.css";
import { useBondsPageViewVm } from "./BondsPageView.vm";
type BondsPageViewProps = { vm: BondsPageVm };
const BondsPageView: FC<BondsPageViewProps> = (props) => {
const { vm } = props;
const BondsPageViewResults: FC<BondsPageViewProps> = (props) => {
const { t } = useTranslation();
function renderResults() {
if (vm.loading) {
return (
<LoadingSkeleton label={t("bond.searching")}>
<Stack>
<Skeleton h={78} />
<Skeleton h={78} />
<Skeleton h={78} />
</Stack>
</LoadingSkeleton>
);
}
if (!vm.ready) return;
if (vm.items.length > 0) {
return (
<Stack gap="sm">
<Text c="dimmed" size="xs">
{t("bond.searchVariants")}
</Text>
{vm.items.map(bond => (
<Paper
className={classes.card}
component={Link}
key={bond.instrumentUid}
to={bond.to}
withBorder
>
<Avatar alt="" radius="md" size={48} src={bond.logoSrc}>
{bond.name[0]}
</Avatar>
<div className={classes.identity}>
<Text fw={600} size="lg">
{bond.name}
</Text>
<Text c="dimmed" size="sm">
{bond.ticker}
{" "}
·
{" "}
{bond.perpetual
? t("bond.perpetual")
: t("bond.matures", {
date: bond.maturityDate
? formatDate(bond.maturityDate)
: t("bond.unknown"),
})}
</Text>
<Group gap="xs" mt="sm">
<Badge color="gray" variant="light">
{t(`stock.exchanges.${bond.realExchange}`, {
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{!!(bond.classCode) && ((
<Text c="dimmed" size="xs">
{t("stock.classCode", { code: bond.classCode })}
</Text>
))}
</Group>
{!!(bond.isin) && ((
<Text c="dimmed" mt={4} size="xs">
{t("stock.isin", { value: bond.isin })}
</Text>
))}
<Text c="dimmed" mt={4} size="xs">
{t("bond.nominal")}
:
{" "}
{formatMoney(bond.nominal)}
{" "}
·
{" "}
{t("bond.couponFrequency")}
:
{" "}
{bond.couponQuantityPerYear ?? t("bond.unknown")}
</Text>
{!!(bond.floatingCoupon) && ((
<Badge mt="xs" variant="light">
{t("bond.floatingCoupon")}
</Badge>
))}
{(bond.apiTradeAvailable === false) && ((
<Text c="dimmed" mt={4} size="xs">
{t("stock.apiUnavailable")}
</Text>
))}
</div>
<div className={classes.quote}>
<Text c="dimmed" size="xs">
{t("bond.percentOfNominal")}
</Text>
<Text fw={650} size="xl">
{bond.pricePercent === null
? t("bond.unknown")
: `${formatDecimal(bond.pricePercent)} %`}
</Text>
<Text c="dimmed" size="xs">
{bond.pricePercent !== null && bond.priceAt
? t("stock.priceAt", {
date: formatSnapshotTime(bond.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
{(bond.apiTradeAvailable === true) && ((
<Text c="green.8" mt="sm" size="xs">
{t("stock.apiAvailable")}
</Text>
))}
</div>
</Paper>
))}
</Stack>
);
}
const { vm } = props;
if (vm.loading) {
return (
<Text c="dimmed">
{t(vm.partial ? "investment.partial" : "bond.noSearchResults")}
</Text>
<LoadingSkeleton label={t("bond.searching")}>
<Stack>
<Skeleton h={78} />
<Skeleton h={78} />
<Skeleton h={78} />
</Stack>
</LoadingSkeleton>
);
}
if (!vm.ready) return;
if (vm.items.length > 0) {
return (
<Stack gap="sm">
<Text c="dimmed" size="xs">
{t("bond.searchVariants")}
</Text>
{vm.items.map(bond => (
<Paper
className={classes.card}
component={Link}
key={bond.instrumentUid}
to={bond.to}
withBorder
>
<Avatar alt="" radius="md" size={48} src={bond.logoSrc}>
{bond.name[0]}
</Avatar>
<div className={classes.identity}>
<Text fw={600} size="lg">
{bond.name}
</Text>
<Text c="dimmed" size="sm">
{bond.ticker} ·{" "}
{bond.perpetual
? t("bond.perpetual")
: t("bond.matures", {
date: bond.maturityDate
? formatDate(bond.maturityDate)
: t("bond.unknown"),
})}
</Text>
<Group gap="xs" mt="sm">
<Badge color="gray" variant="light">
{t(`stock.exchanges.${bond.realExchange}`, {
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{!!bond.classCode && (
<Text c="dimmed" size="xs">
{t("stock.classCode", { code: bond.classCode })}
</Text>
)}
</Group>
{!!bond.isin && (
<Text c="dimmed" mt={4} size="xs">
{t("stock.isin", { value: bond.isin })}
</Text>
)}
<Text c="dimmed" mt={4} size="xs">
{t("bond.nominal")}: {formatMoney(bond.nominal)} ·{" "}
{t("bond.couponFrequency")}:{" "}
{bond.couponQuantityPerYear ?? t("bond.unknown")}
</Text>
{!!bond.floatingCoupon && (
<Badge mt="xs" variant="light">
{t("bond.floatingCoupon")}
</Badge>
)}
{bond.apiTradeAvailable === false && (
<Text c="dimmed" mt={4} size="xs">
{t("stock.apiUnavailable")}
</Text>
)}
</div>
<div className={classes.quote}>
<Text c="dimmed" size="xs">
{t("bond.percentOfNominal")}
</Text>
<Text fw={650} size="xl">
{bond.pricePercent === null
? t("bond.unknown")
: `${formatDecimal(bond.pricePercent)} %`}
</Text>
<Text c="dimmed" size="xs">
{bond.pricePercent !== null && bond.priceAt
? t("stock.priceAt", {
date: formatSnapshotTime(bond.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
{bond.apiTradeAvailable === true && (
<Text c="green.8" mt="sm" size="xs">
{t("stock.apiAvailable")}
</Text>
)}
</div>
</Paper>
))}
</Stack>
);
}
return (
<Text c="dimmed">
{t(vm.partial ? "investment.partial" : "bond.noSearchResults")}
</Text>
);
};
const BondsPageView: FC<BondsPageViewProps> = (props) => {
const { onChange, t, vm } = useBondsPageViewVm(props);
return (
<Stack gap="lg">
<div>
<Title order={1}>{t("bond.searchTitle")}</Title>
<Text c="dimmed" mt="xs">
{t("bond.searchSubtitle")}
</Text>
</div>
<Button
disabled={vm.refreshing || !vm.canSearch}
onClick={vm.retry}
@@ -170,21 +185,21 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
>
{t("investment.refresh")}
</Button>
{!!(vm.partial) && ((
<Alert role="status">{t("investment.partial")}</Alert>
))}
{!!vm.partial && <Alert role="status">{t("investment.partial")}</Alert>}
<TextInput
label={t("bond.searchLabel")}
maxLength={80}
onChange={event => vm.setQuery(event.currentTarget.value)}
onChange={onChange}
placeholder={t("bond.searchPlaceholder")}
size="md"
value={vm.query}
/>
{!(vm.canSearch) && ((
<Text c="dimmed">{t("stock.searchHint")}</Text>
))}
{!!(vm.error) && ((
{!vm.canSearch && <Text c="dimmed">{t("stock.searchHint")}</Text>}
{!!vm.error && (
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="xs">
<Text>
@@ -195,16 +210,19 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
: "stock.sectionError",
)}
</Text>
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button onClick={vm.retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
</Alert>
))}
{renderResults()}
)}
<BondsPageViewResults {...props} />
</Stack>
);
};
+6 -1
View File
@@ -9,6 +9,7 @@ import { instrumentLogoUrl } from "@/shared/lib";
const SEARCH_DEBOUNCE_MS = 300;
type BondsPageVm = ReturnType<typeof useBondsPageVm>;
function useBondsPageVm() {
const [parameters, setParameters] = useSearchParams();
const query = parameters.get("q") ?? "";
@@ -17,6 +18,7 @@ function useBondsPageVm() {
const sync = useScreenSync({ query: debounced, screen: "bonds" }, [result]);
const canSearch = query.trim().length >= MIN_BOND_SEARCH_LENGTH;
const isWaiting = query.trim() !== debounced;
return {
canSearch,
code: apiErrorCode(result.error),
@@ -35,7 +37,10 @@ function useBondsPageVm() {
&& result.meta?.sync.state !== "failed")),
partial: result.meta?.availability === "partial",
query,
ready: debounced.length >= MIN_BOND_SEARCH_LENGTH && !isWaiting && Boolean(result.data),
ready:
debounced.length >= MIN_BOND_SEARCH_LENGTH
&& !isWaiting
&& Boolean(result.data),
refreshing: sync.refreshing,
retry: () => {
void sync.retry();
@@ -0,0 +1,20 @@
import type { ComponentProps } from "react";
import { TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { BondsPageVm } from "./BondsPage.vm";
type BondsPageViewProps = { vm: BondsPageVm };
function useBondsPageViewVm(props: BondsPageViewProps) {
const { vm } = props;
const { t } = useTranslation();
const onChange: ComponentProps<typeof TextInput>["onChange"] = event =>
vm.setQuery(event.currentTarget.value);
return { onChange, t, vm };
}
export { useBondsPageViewVm };
+36 -15
View File
@@ -18,8 +18,11 @@ import { server } from "@/test/server";
import { CardPage } from "./CardPage";
const money = (value: string) => ({ currency: "RUB", value });
const card = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const card
= {
archivedAt: null,
balance: money("10000"),
bankName: null,
@@ -29,10 +32,12 @@ const card = (/* eslint-disable unicorn/no-null -- Fixture preserves the product
openingBalance: money("10000"),
openingDate: "2026-01-01",
version: 1,
}
/* eslint-enable unicorn/no-null */);
const operation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
};
/* eslint-enable unicorn/no-null */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const operation
= {
amount: money("50.01"),
availableRefund: null,
cardId: "a",
@@ -48,8 +53,9 @@ const operation = (/* eslint-disable unicorn/no-null -- Fixture preserves the pr
targetCardId: "b",
targetCardName: "Запасная",
version: 1,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
function setup(
options: {
cardsError?: boolean;
@@ -78,6 +84,7 @@ function setup(
),
http.get("*/api/v1/bank-cards/a/operations", ({ request }) => {
requests.push(new URL(request.url).search);
return HttpResponse.json({
hasNext: Number(new URL(request.url).searchParams.get("page")) < 3,
items: [
@@ -92,6 +99,7 @@ function setup(
}),
http.get("*/api/v1/bank-cards/summary", ({ request }) => {
summaryRequests.push(new URL(request.url).search);
return options.summaryError
? HttpResponse.error()
: HttpResponse.json({
@@ -118,8 +126,10 @@ function setup(
</MantineProvider>
</QueryClientProvider>,
);
return { client, requests, summaryRequests };
}
describe("CardPage", () => {
it.each([
["0001-01", "0001-01-31"],
@@ -132,6 +142,7 @@ describe("CardPage", () => {
const { requests, summaryRequests } = setup({ month });
await screen.findByText("Перевод на «Запасная»");
await waitFor(() => expect(summaryRequests).toHaveLength(1));
for (const request of [requests[0], summaryRequests[0]]) {
const query = new URLSearchParams(request);
expect(query.get("from")).toBe(`${month}-01`);
@@ -193,20 +204,28 @@ describe("CardPage", () => {
expect(screen.getByText(/10\s000 ₽/)).toBeInTheDocument();
});
it("keeps the journal in place while the next page loads", async () => {
const scroll = vi.spyOn(globalThis, "scrollBy").mockImplementation(() => {});
const scroll = vi
.spyOn(globalThis, "scrollBy")
.mockImplementation(() => {});
setup();
await screen.findByText("Перевод на «Запасная»");
const navigation = screen.getByRole("navigation", {
name: "Страницы операций",
});
const measure = vi
.spyOn(navigation, "getBoundingClientRect")
.mockReturnValue({ top: 600 } as DOMRect);
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
const { promise: pending, resolve: release }
= Promise.withResolvers<void>();
server.use(
http.get("*/api/v1/bank-cards/a/operations", async () => {
await pending;
return HttpResponse.json({
hasNext: true,
items: [{ ...operation, description: "Новая страница", id: "next" }],
@@ -256,7 +275,7 @@ describe("CardPage", () => {
await waitFor(() => {
expect(requests.at(-1)).toContain("limit=20");
});
expect(requests.at(-1)).toContain("page=1"); ;
expect(requests.at(-1)).toContain("page=1");
await waitFor(() =>
expect(screen.getByRole("button", { name: "Страница 1" })).toBeEnabled(),
);
@@ -321,14 +340,16 @@ describe("CardPage", () => {
archived: boolean;
version: number;
};
bodies.push(body);
current = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
current
= {
...current,
archivedAt: body.archived ? "2026-09-01" : null,
version: current.version + 1,
}
/* eslint-enable unicorn/no-null */) as typeof card;
} as /* eslint-enable unicorn/no-null */ typeof card;
return HttpResponse.json(current);
}),
);
+8 -10
View File
@@ -9,28 +9,26 @@ import { useCardPageVm } from "./CardPage.vm";
const CardPage: FC = () => {
const vm = useCardPageVm();
return (
<>
<CardPageView vm={vm} />
{vm.editingCard && vm.card.data && (
<BankCardForm
card={vm.card.data}
onClose={() => vm.setEditingCard(false)}
/>
<BankCardForm card={vm.card.data} onClose={vm.closeEditing} />
)}
{vm.operation && vm.card.data && vm.cards.data && (
<OperationForm
card={vm.card.data}
cards={vm.cards.data}
onClose={() => vm.setOperation(null)}
operation={(vm.operation === "new") ? undefined : (vm.operation)}
onClose={vm.closeOperation}
operation={vm.editableOperation}
/>
)}
{vm.deleting && (
<DeleteOperation
onClose={() => vm.setDeleting(null)}
operation={vm.deleting}
/>
<DeleteOperation onClose={vm.closeDeletion} operation={vm.deleting} />
)}
</>
);
+141 -144
View File
@@ -10,16 +10,22 @@ import {
Text,
Title,
} from "@mantine/core";
import { AddIcon, DeleteIcon, EditIcon } from "@wealthledger/icons";
import {
AddIcon,
DeleteIcon,
EditIcon,
OperationIcon,
RestoreIcon,
} from "@wealthledger/icons";
import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui";
import { type FC, type ReactNode, useLayoutEffect, useRef } from "react";
import { type FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
BankCardFace,
BankFeedback,
BankOperationIcon,
bankOperationAppearance,
BankPeriodPicker,
BankSummaryView,
} from "@/entities/bank-card";
@@ -27,80 +33,33 @@ import {
import type { CardPageVm } from "./CardPage.vm";
import classes from "./CardPage.module.css";
import { useCardPagePresentationVm } from "./CardPagePresentation.vm";
type CardPageViewProps = { vm: CardPageVm };
const CardPageView: FC<CardPageViewProps> = (props) => {
// 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.data;
const paginationRef = useRef<HTMLDivElement>(null);
const pendingPageRef = useRef<null | {
page: number;
scope: string;
top: number;
}>(null);
const scope = `${card?.id}-${vm.period.from}-${vm.period.to}-${vm.pageSize}`;
useLayoutEffect(() => {
const pending = pendingPageRef.current;
if (!pending) return;
if (pending.scope !== scope || vm.history.isError) {
pendingPageRef.current = /* eslint-disable-line unicorn/no-null -- Reset the pending-pagination ref to its initial null sentinel. */ null;
return;
}
if (vm.history.isPlaceholderData || vm.history.data?.page !== pending.page)
return;
const pagination = paginationRef.current;
if (pagination) {
const delta = pagination.getBoundingClientRect().top - pending.top;
if (delta) window.scrollBy({ behavior: "instant", top: delta });
}
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
pendingPageRef.current = null;
}, [
scope,
vm.history.data,
vm.history.isPlaceholderData,
vm.history.isError,
]);
if (!card) {
if (vm.card.isLoading)
return (
<LoadingSkeleton label={t("bank.loading")}>
<Stack>
<Skeleton h={130} />
<Skeleton h={160} />
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
);
return (
<StateView
action={(
<Group>
<Button component={Link} to="/cards">
{t("bank.back")}
</Button>
{!vm.notFound && (
<Button onClick={() => void vm.card.refetch()}>
{t("bank.retry")}
</Button>
)}
</Group>
)}
error
text={t("bank.back")}
title={t(vm.notFound ? "bank.notFound" : "bank.error")}
/>
);
}
// eslint-disable-next-line sonarjs/function-return-type -- ReactNode deliberately includes lists, elements and empty output.
function renderHistory(): ReactNode {
if (!card) return;
if (vm.rows) {
if (vm.rows.length > 0) {
return vm.rows.map(row => (
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}
@@ -116,13 +75,14 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|| !row.editable
|| Boolean(card.archivedAt)
}
onClick={() => vm.setOperation(row.item)}
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"
@@ -131,7 +91,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|| !row.editable
|| Boolean(card.archivedAt)
}
onClick={() => vm.setDeleting(row.item)}
onClick={row.onDelete}
size="sm"
title={t("bank.delete")}
variant="subtle"
@@ -141,22 +101,13 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</Group>
)}
color={row.color}
details={
(row.otherCard)
? ((
<Anchor
component={Link}
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
>
{t("bank.card")}
</Anchor>
))
: undefined
}
details={details}
icon={(
<BankOperationIcon
categoryName={row.item.categoryName}
kind={row.item.kind}
<OperationIcon
{...bankOperationAppearance(
row.item.kind,
row.item.categoryName,
)}
/>
)}
initial={row.title[0]}
@@ -167,33 +118,96 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
valueLabel={t(`bank.kinds.${row.item.kind}`)}
/>
</section>
));
}
return <Text>{t("bank.noOperations")}</Text>;
);
});
}
if (vm.history.isLoading) {
return <Text>{t("bank.noOperations")}</Text>;
}
if (vm.history.isLoading) {
return (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<Stack>
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
);
}
};
const CardPageView: FC<CardPageViewProps> = (props) => {
const { vm } = props;
const {
archiveActionLabel,
card,
getControlProps,
getItemProps,
onAddOperation,
onEditCard,
onPageChange,
onToggleArchive,
paginationRef,
retryCard,
retryCards,
retryHistory,
retrySummary,
t,
} = useCardPagePresentationVm(vm);
if (!card) {
if (vm.card.isLoading)
return (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<LoadingSkeleton label={t("bank.loading")}>
<Stack>
<Skeleton h={130} />
<Skeleton h={160} />
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
);
}
const errorTitle = t(vm.notFound ? "bank.notFound" : "bank.error");
return (
<StateView
action={(
<Group>
<Button component={Link} to="/cards">
{t("bank.back")}
</Button>
{!vm.notFound && <Button onClick={retryCard}>{t("bank.retry")}</Button>}
</Group>
)}
error
text={t("bank.back")}
title={errorTitle}
/>
);
}
return (
<Stack className={classes.page} gap="lg">
<Stack>
<Title className={classes.wrap} order={1}>
{card.name}
</Title>
<div className={classes.card}>
<BankCardFace card={card} />
<Group className={classes.cardActions} gap={4} wrap="nowrap">
<ActionIcon
aria-label={t("bank.editCard")}
onClick={() => vm.setEditingCard(true)}
onClick={onEditCard}
radius="md"
size="md"
title={t("bank.editCard")}
@@ -201,31 +215,20 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
>
<EditIcon size={18} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
aria-label={t(card.archivedAt ? "bank.restore" : "bank.archive")}
aria-label={archiveActionLabel}
color="red"
loading={vm.archivePending}
onClick={() => void vm.toggleArchive()}
onClick={onToggleArchive}
radius="md"
size="md"
title={t(card.archivedAt ? "bank.restore" : "bank.archive")}
title={archiveActionLabel}
variant="subtle"
>
{card.archivedAt
? (
<svg
aria-hidden="true"
fill="none"
height="18"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width="18"
>
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
</svg>
<RestoreIcon size={18} />
)
: (
<DeleteIcon size={18} strokeWidth={1.5} />
@@ -233,46 +236,51 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</ActionIcon>
</Group>
</div>
{!!(vm.archiveError) && ((
<BankFeedback
error={vm.archiveError}
retry={() => void vm.card.refetch()}
/>
))}
{!!vm.archiveError && (
<BankFeedback error={vm.archiveError} retry={retryCard} />
)}
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
</Stack>
{vm.card.isError && (
<Group role="alert">
<Text>{t("bank.refreshError")}</Text>
<Button onClick={() => void vm.card.refetch()}>
{t("bank.retry")}
</Button>
<Button onClick={retryCard}>{t("bank.retry")}</Button>
</Group>
)}
{vm.card.isFetching && <Text role="status">{t("bank.refreshing")}</Text>}
{vm.cards.isError && (
<BankFeedback
cached={Boolean(vm.cards.data)}
error={vm.cards.error}
loadingError
retry={() => void vm.cards.refetch()}
retry={retryCards}
/>
)}
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
<BankSummaryView
data={vm.summary.data}
error={vm.summary.isError}
fetching={vm.summary.isFetching}
loading={vm.summary.isLoading}
retry={() => void vm.summary.refetch()}
retry={retrySummary}
/>
<Group align="end" justify="space-between">
<Group gap="xs">
<Title order={2}>{t("bank.operations")}</Title>
<ActionIcon
aria-label={t("bank.addOperation")}
disabled={Boolean(card.archivedAt) || !vm.cards.data}
onClick={() => vm.setOperation("new")}
onClick={onAddOperation}
size="lg"
title={t("bank.addOperation")}
variant="subtle"
@@ -280,6 +288,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
<AddIcon size={20} strokeWidth={1.5} />
</ActionIcon>
</Group>
<Select
allowDeselect={false}
data={[
@@ -296,6 +305,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
w={180}
/>
</Group>
<Stack
aria-busy={vm.history.isFetching}
className={classes.operations}
@@ -303,16 +313,17 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
gap="lg"
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`}
>
{renderHistory()}
<CardPageViewHistory {...props} />
</Stack>
{vm.history.isError && (
<Group role="alert">
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={() => void vm.history.refetch()}>
{t("bank.retry")}
</Button>
<Button onClick={retryHistory}>{t("bank.retry")}</Button>
</Group>
)}
{vm.history.data && vm.pageSize !== "all" && (
<Group
aria-label={t("bank.pagination")}
@@ -323,23 +334,9 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
>
<Pagination
disabled={vm.history.isFetching}
getControlProps={control => ({
"aria-label": t(
control === "previous" ? "bank.previous" : "bank.next",
),
})}
getItemProps={page => ({
"aria-label": t("bank.page", { page }),
})}
onChange={(page) => {
if (paginationRef.current)
pendingPageRef.current = {
page,
scope,
top: paginationRef.current.getBoundingClientRect().top,
};
vm.setPage(page);
}}
getControlProps={getControlProps}
getItemProps={getItemProps}
onChange={onPageChange}
siblings={0}
total={vm.totalPages}
value={vm.page}
+30 -4
View File
@@ -17,6 +17,7 @@ import { formatDate, formatMoney } from "@/shared/lib";
const PAGE_SIZES = new Set(["5", "10", "20", "50", "100", "all"]);
type CardPageVm = ReturnType<typeof useCardPageVm>;
function useCardPageVm() {
const { cardId } = useParams();
const [parameters, setParameters] = useSearchParams();
@@ -25,32 +26,46 @@ function useCardPageVm() {
const requestedSize = parameters.get("limit") ?? "10";
const pageSize = PAGE_SIZES.has(requestedSize) ? requestedSize : "10";
const rawPage = Number(parameters.get("page") ?? 1);
const page
= pageSize !== "all" && Number.isSafeInteger(rawPage) && rawPage > 0
? rawPage
: 1;
const card = useBankCard(cardId);
const { updateCard } = useBankMutations();
const [archiveError, setArchiveError] = useState<unknown>();
const cards = useBankCards(true);
const history = useBankOperations(cardId, {
...period,
limit: pageSize === "all" ? "all" : Number(pageSize),
page,
});
const summary = useBankSummary(period, cardId);
const [editingCard, setEditingCard] = useState(false);
const [operation, setOperation] = useState<"new" | BankOperation | null>(
const [operation, setOperation] = useState<"new"
| BankOperation
| null>(
null,
);
const [deleting, setDeleting] = useState<BankOperation | null>(null);
const rows = history.data?.items.map((item) => {
const isOutgoing
= item.kind === "expense"
|| (item.kind === "transfer" && item.cardId === cardId);
const otherCardId = isOutgoing ? item.targetCardId : item.cardId;
let title = t(`bank.kinds.${item.kind}`);
if (item.kind === "transfer") title = t(isOutgoing ? "bank.transferTo" : "bank.transferFrom", { name: isOutgoing ? item.targetCardName : item.cardName });
if (item.kind === "transfer")
title = t(isOutgoing ? "bank.transferTo" : "bank.transferFrom", {
name: isOutgoing ? item.targetCardName : item.cardName,
});
return {
color: isOutgoing ? "red" : "green",
editable:
@@ -64,24 +79,34 @@ function useCardPageVm() {
metadata: [formatDate(item.date), item.categoryName, item.description]
.filter(Boolean)
.join(" · "),
onDelete: () => setDeleting(item),
onEdit: () => setOperation(item),
otherCard:
item.kind === "transfer"
? otherCardId
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
title,
tone: isOutgoing ? ("negative" as const) : ("positive" as const),
value: `${isOutgoing ? "−" : "+"}${formatMoney(item.amount)}`,
};
});
return {
archiveError,
archivePending: updateCard.isPending,
card,
cards,
closeDeletion: () => setDeleting(null),
closeEditing: () => setEditingCard(false),
closeOperation: () => setOperation(null),
deleting,
editableOperation: operation === "new" ? undefined : (operation ?? undefined),
editingCard,
history,
notFound: isApiErrorCode(apiErrorCode(card.error), ApiErrorCode.BankNotFound),
notFound: isApiErrorCode(
apiErrorCode(card.error),
ApiErrorCode.BankNotFound,
),
operation,
page,
pageSize,
@@ -104,6 +129,7 @@ function useCardPageVm() {
toggleArchive: async () => {
if (!card.data) return;
setArchiveError(undefined);
try {
await updateCard.mutateAsync({
body: { archived: !card.data.archivedAt, version: card.data.version },
@@ -0,0 +1,98 @@
import type { ComponentProps } from "react";
import { Pagination } from "@mantine/core";
import { useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { CardPageVm } from "./CardPage.vm";
function useCardPagePresentationVm(vm: CardPageVm) {
const { t } = useTranslation();
const card = vm.card.data;
const paginationRef = useRef<HTMLDivElement>(null);
const pendingPageRef = useRef<null
| {
page: number;
scope: string;
top: number;
}>(null);
const scope = `${card?.id}-${vm.period.from}-${vm.period.to}-${vm.pageSize}`;
useLayoutEffect(() => {
const pending = pendingPageRef.current;
if (!pending) return;
if (pending.scope !== scope || vm.history.isError) {
pendingPageRef.current
// eslint-disable-next-line unicorn/no-null -- Reset the pending-pagination ref to its initial null sentinel.
= null;
return;
}
if (vm.history.isPlaceholderData || vm.history.data?.page !== pending.page)
return;
const pagination = paginationRef.current;
if (pagination) {
const delta = pagination.getBoundingClientRect().top - pending.top;
if (delta) window.scrollBy({ behavior: "instant", top: delta });
}
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
pendingPageRef.current = null;
}, [
scope,
vm.history.data,
vm.history.isPlaceholderData,
vm.history.isError,
]);
const onEditCard = () => vm.setEditingCard(true);
const archiveActionLabel = t(card?.archivedAt ? "bank.restore" : "bank.archive");
const onToggleArchive = () => void vm.toggleArchive();
const retryCard = () => void vm.card.refetch();
const retryCards = () => void vm.cards.refetch();
const retrySummary = () => void vm.summary.refetch();
const onAddOperation = () => vm.setOperation("new");
const retryHistory = () => void vm.history.refetch();
const getControlProps: ComponentProps<
typeof Pagination
>["getControlProps"] = control => ({
"aria-label": t(control === "previous" ? "bank.previous" : "bank.next"),
});
const getItemProps: ComponentProps<typeof Pagination>["getItemProps"] = page => ({
"aria-label": t("bank.page", { page }),
});
const onPageChange: ComponentProps<typeof Pagination>["onChange"] = (page) => {
if (paginationRef.current)
pendingPageRef.current = {
page,
scope,
top: paginationRef.current.getBoundingClientRect().top,
};
vm.setPage(page);
};
return {
archiveActionLabel,
card,
getControlProps,
getItemProps,
onAddOperation,
onEditCard,
onPageChange,
onToggleArchive,
paginationRef,
retryCard,
retryCards,
retryHistory,
retrySummary,
t,
};
}
export { useCardPagePresentationVm };
@@ -12,8 +12,10 @@ import { server } from "@/test/server";
import { DeleteOperation } from "./DeleteOperation";
const transfer: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const transfer: BankOperation
= {
amount: { currency: "RUB", value: "100" },
availableRefund: null,
cardId: "a",
@@ -29,8 +31,9 @@ const transfer: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture pr
targetCardId: "b",
targetCardName: "Запасная",
version: 1,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
describe("DeleteOperation", () => {
it("names both sides and requires a deliberate fresh version after conflict", async () => {
configureAuthClient({
@@ -46,6 +49,7 @@ describe("DeleteOperation", () => {
http.delete("*/api/v1/bank-cards/operations/transfer", ({ request }) => {
const version = new URL(request.url).searchParams.get("version") ?? "";
versions.push(version);
return version === "1"
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
@@ -1,36 +1,34 @@
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import { type FC } from "react";
import {
BankFeedback,
type BankOperation,
useBankFormFocus,
useBankMutations,
useBankOperation,
} from "@/entities/bank-card";
import { BankFeedback, type BankOperation } from "@/entities/bank-card";
import { formatDate, formatMoney } from "@/shared/lib";
import { useDeleteOperationVm } from "./DeleteOperation.vm";
type DeleteOperationProps = { onClose: () => void; operation: BankOperation };
const DeleteOperation: FC<DeleteOperationProps> = (props) => {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const [record, setRecord] = useState(props.operation);
const [latest, setLatest] = useState<BankOperation>();
const [error, setError] = useState<unknown>();
const query = useBankOperation(record.id);
const { deleteOperation: operationDeletion } = useBankMutations();
const isPending
= !opened
|| operationDeletion.isPending
|| (query.isFetching && Boolean(error));
const {
error,
isPending,
latest,
onAdoptVersion,
onCancel,
onDelete,
onReloadRecord,
onRequestClose,
opened,
record,
t,
} = useDeleteOperationVm(props);
return (
<Modal
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
closeOnClickOutside={!isPending}
closeOnEscape={!isPending}
onClose={() => !isPending && setOpened(false)}
onClose={onRequestClose}
onExitTransitionEnd={props.onClose}
opened={opened}
title={t("bank.deleteTitle")}
@@ -49,22 +47,21 @@ const DeleteOperation: FC<DeleteOperationProps> = (props) => {
kind: t(`bank.kinds.${record.kind}`),
})}
</Text>
{!!(error) && ((
{!!error && (
<>
<BankFeedback error={error} />
<Button
disabled={isPending}
onClick={async () => {
const result = await query.refetch();
if (result.data && !result.error) setLatest(result.data);
else setError(result.error);
}}
onClick={onReloadRecord}
variant="default"
>
{t("bank.reloadRecord")}
</Button>
</>
))}
)}
{latest && (
<>
<Text>
@@ -72,44 +69,23 @@ const DeleteOperation: FC<DeleteOperationProps> = (props) => {
details: `${formatDate(latest.date)} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
})}
</Text>
<Button
disabled={isPending}
onClick={() => {
setRecord(latest);
setLatest(undefined);
setError(undefined);
}}
onClick={onAdoptVersion}
variant="default"
>
{t("bank.adoptVersion", { version: latest.version })}
</Button>
</>
)}
<Group>
<Button
color="red"
loading={isPending}
onClick={async () => {
setError(undefined);
try {
await operationDeletion.mutateAsync({
id: record.id,
version: record.version,
});
setOpened(false);
}
catch (error_) {
setError(error_);
}
}}
>
<Button color="red" loading={isPending} onClick={onDelete}>
{t("bank.delete")}
</Button>
<Button
disabled={isPending}
onClick={() => setOpened(false)}
variant="default"
>
<Button disabled={isPending} onClick={onCancel} variant="default">
{t("bank.cancel")}
</Button>
</Group>
@@ -0,0 +1,75 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
type BankOperation,
useBankFormFocus,
useBankMutations,
useBankOperation,
} from "@/entities/bank-card";
type DeleteOperationProps = { onClose: () => void; operation: BankOperation };
function useDeleteOperationVm(props: DeleteOperationProps) {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const [record, setRecord] = useState(props.operation);
const [latest, setLatest] = useState<BankOperation>();
const [error, setError] = useState<unknown>();
const query = useBankOperation(record.id);
const { deleteOperation: operationDeletion } = useBankMutations();
const isPending
= !opened
|| operationDeletion.isPending
|| (query.isFetching && Boolean(error));
const onRequestClose = () => !isPending && setOpened(false);
const onReloadRecord = async () => {
const result = await query.refetch();
if (result.data && !result.error) setLatest(result.data);
else setError(result.error);
};
const onAdoptVersion = () => {
if (!latest) return;
setRecord(latest);
setLatest(undefined);
setError(undefined);
};
const onDelete = async () => {
setError(undefined);
try {
await operationDeletion.mutateAsync({
id: record.id,
version: record.version,
});
setOpened(false);
}
catch (error_) {
setError(error_);
}
};
const onCancel = () => setOpened(false);
return {
error,
isPending,
latest,
onAdoptVersion,
onCancel,
onDelete,
onReloadRecord,
onRequestClose,
opened,
record,
t,
};
}
export { useDeleteOperationVm };
@@ -0,0 +1,99 @@
import type { ComponentProps } from "react";
import { DateInput } from "@mantine/dates";
import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { OperationValues } from "./OperationForm.types";
type Choice = { label: string; value: string };
type OperationFieldsProps = {
cards: Choice[];
categories: Choice[];
dateInputValue: (value: string) => string;
defaultCategory: string;
editing: boolean;
kind: OperationValues["kind"];
kinds: Choice[];
maxDate: () => string;
refundCardId: string;
targets: Choice[];
validDate: (value: string) => boolean;
};
function useOperationFieldsVm(props: OperationFieldsProps) {
const { t } = useTranslation();
const {
control,
formState: { errors, isSubmitting },
register,
setValue,
} = useFormContext<OperationValues>();
const { field: kindField } = useController({ control, name: "kind" });
const { field: cardIdField } = useController({ control, name: "cardId" });
const { field: targetCardIdField } = useController({
control,
name: "targetCardId",
});
const { field: dateField } = useController({ control, name: "date" });
const { field: categoryIdField } = useController({
control,
name: "categoryId",
});
const onKindChange: (value: null | string) => void = (value) => {
kindField.onChange(value ?? "expense");
if (value === "refund") setValue("cardId", props.refundCardId);
};
const sourceCardLabel = t(
props.kind === "transfer" ? "bank.source" : "bank.operationCard",
);
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
props.validDate(props.dateInputValue(value))
? props.dateInputValue(value)
: null; // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled dateField as 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 {
cardIdField,
categoryIdField,
dateField,
dateParser,
dateValue,
errors,
isSubmitting,
kindField,
onDateBlur,
onDateChange,
onDateInput,
onKindChange,
register,
sourceCardLabel,
t,
targetCardIdField,
};
}
export { useOperationFieldsVm };
@@ -0,0 +1,131 @@
import { z } from "zod";
import {
type BankCard,
bankDateSchema,
type BankOperation,
bankOperationKinds,
moneyCents,
positiveMoneySchema,
} from "@/entities/bank-card";
import { validationMessage } from "@/shared/i18n";
import type { OperationValues } from "./OperationForm.types";
// ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod.
enum FormIssueCode {
Custom = "custom",
}
type SchemaInputs = {
cards: BankCard[];
categories?: { id: string; isDefault: boolean }[];
expense?: BankOperation;
limit?: BankOperation["availableRefund"];
submittedValues?: OperationValues;
};
const MAX_DESCRIPTION_LENGTH = 500;
const operationSchema = z.object({
amount: positiveMoneySchema,
cardId: z.string(),
categoryId: z.string(),
date: bankDateSchema,
description: z
.string()
.max(
MAX_DESCRIPTION_LENGTH,
validationMessage("bank.validation.description")(undefined),
),
kind: z.enum(bankOperationKinds),
originalExpenseId: z.string(),
targetCardId: z.string(),
});
function createOperationSchema({
cards,
categories,
expense,
limit,
submittedValues,
}: SchemaInputs) {
return operationSchema.superRefine((values, context) => {
// Repeated intent reaches server idempotency even after its source changed.
if (
submittedValues
&& JSON.stringify(values) === JSON.stringify(submittedValues)
)
return;
const source = cards.find(c => c.id === values.cardId);
const target = cards.find(c => c.id === values.targetCardId);
if (!source || source.archivedAt)
context.addIssue({
code: FormIssueCode.Custom,
path: ["cardId"],
...validationMessage("bank.validation.required")(undefined),
});
if (
values.kind === "transfer"
&& (!target || target.archivedAt || target.id === values.cardId)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["targetCardId"],
...validationMessage("bank.validation.destination")(undefined),
});
if (
(source && values.date < source.openingDate)
|| (target && values.kind === "transfer" && values.date < target.openingDate)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
if (values.kind === "refund")
validateRefund(values, context, expense, limit);
if (
values.kind === "expense"
&& !categories?.some(
c =>
c.id
=== (values.categoryId || categories?.find(c => c.isDefault)?.id),
)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["categoryId"],
...validationMessage("bank.validation.required")(undefined),
});
});
}
function validateRefund(
values: OperationValues,
context: z.RefinementCtx,
expense: BankOperation | undefined,
limit?: BankOperation["availableRefund"],
) {
if (!expense || expense.cardId !== values.cardId)
context.addIssue({
code: FormIssueCode.Custom,
path: ["originalExpenseId"],
...validationMessage("bank.validation.required")(undefined),
});
if (expense && values.date < expense.date)
context.addIssue({
code: FormIssueCode.Custom,
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
const amount = moneyCents(values.amount);
if (limit && amount !== null && amount > (moneyCents(limit.value) ?? 0n))
context.addIssue({
code: FormIssueCode.Custom,
path: ["amount"],
...validationMessage("bank.validation.refund")(undefined),
});
}
export { createOperationSchema };
@@ -9,9 +9,11 @@ import type { OperationValues } from "./OperationForm.types";
import { OperationFormView } from "./OperationForm.view";
type ExampleProps = { conflict?: boolean; pending?: boolean };
const OperationExample: FC<ExampleProps> = (props) => {
const [opened, setOpened] = useState(true);
const [action, setAction] = useState("");
const methods = useForm<OperationValues>({
defaultValues: {
amount: "500",
@@ -24,7 +26,9 @@ const OperationExample: FC<ExampleProps> = (props) => {
targetCardId: "",
},
});
const kind = useWatch({ control: methods.control, name: "kind" });
return (
<FormProvider {...methods}>
<OperationFormView
@@ -52,12 +56,12 @@ const OperationExample: FC<ExampleProps> = (props) => {
validDate: value => value === "2026-08-01",
}}
latest={
(props.conflict)
? ({
props.conflict
? {
details: "Учебная версия",
onAdopt: () => setAction("Локальное принятие версии"),
version: 2,
})
}
: undefined
}
loadingExpenses={false}
@@ -69,7 +73,7 @@ const OperationExample: FC<ExampleProps> = (props) => {
onNext={() => {}}
onPrevious={() => {}}
onReload={
(props.conflict) ? () => setAction("Локальный reload") : undefined
props.conflict ? () => setAction("Локальный reload") : undefined
}
onSubmit={(event) => {
event.preventDefault();
@@ -92,10 +96,12 @@ const OperationExample: FC<ExampleProps> = (props) => {
saveDisabled={false}
sourcePage={1}
/>
<output aria-live="polite">{action}</output>
</FormProvider>
);
};
const meta = {
component: OperationExample,
parameters: {
@@ -109,8 +115,11 @@ const meta = {
tags: ["autodocs"],
title: "Product/OperationForm",
} satisfies Meta<typeof OperationExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Refund: Story = {};
export const Pending: Story = { args: { pending: true } };
export const Conflict: Story = { args: { conflict: true } };
@@ -19,8 +19,11 @@ import { server } from "@/test/server";
import { OperationForm } from "./OperationForm";
const money = (value: string) => ({ currency: "RUB", value });
const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const card: BankCard
= {
archivedAt: null,
balance: money("0"),
bankName: null,
@@ -31,12 +34,14 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t
openingDate: "2026-01-01",
paymentSystem: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
async function select(label: RegExp, value: RegExp | string) {
fireEvent.click(screen.getByRole("combobox", { name: label }));
fireEvent.click(await screen.findByRole("option", { name: value }));
}
function setup(operation?: BankOperation) {
configureAuthClient({
accessToken: () => "test",
@@ -72,16 +77,21 @@ function setup(operation?: BankOperation) {
</MantineProvider>
</QueryClientProvider>,
);
return close;
}
describe("OperationForm", () => {
it("blocks closing and editing while save is pending, then closes after success", async () => {
const { promise: response, resolve: finish } = Promise.withResolvers<void>();
const { promise: response, resolve: finish }
= Promise.withResolvers<void>();
const received = vi.fn();
server.use(
http.post("*/api/v1/bank-cards/operations", async () => {
received();
await response;
return HttpResponse.json({}, { status: 201 });
}),
);
@@ -96,6 +106,7 @@ describe("OperationForm", () => {
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(received).toHaveBeenCalledOnce());
try {
expect(screen.getByLabelText(/Сумма/)).toBeDisabled();
expect(screen.getByRole("button", { name: "Закрыть" })).toBeDisabled();
@@ -107,6 +118,7 @@ describe("OperationForm", () => {
finally {
finish();
}
await waitFor(() => expect(close).toHaveBeenCalledOnce());
});
@@ -126,6 +138,7 @@ describe("OperationForm", () => {
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
body = await request.json();
return HttpResponse.json({}, { status: 201 });
}),
);
@@ -155,6 +168,7 @@ describe("OperationForm", () => {
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1
? HttpResponse.error()
: HttpResponse.json({}, { status: 201 });
@@ -195,6 +209,7 @@ describe("OperationForm", () => {
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
body = await request.json();
return HttpResponse.json({}, { status: 201 });
}),
);
@@ -214,8 +229,9 @@ describe("OperationForm", () => {
expect(body).not.toHaveProperty("categoryId");
});
it("finds a previous-month purchase across pages, checks remaining refund and retains the complete-refund intent", async () => {
const purchase: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const purchase: BankOperation
= {
amount: money("500"),
availableRefund: money("500"),
cardId: "a",
@@ -231,8 +247,9 @@ describe("OperationForm", () => {
targetCardId: null,
targetCardName: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
server.use(
http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => {
return HttpResponse.json({
@@ -270,6 +287,7 @@ describe("OperationForm", () => {
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json());
purchase.availableRefund = money("0");
return bodies.length === 1
? HttpResponse.error()
: HttpResponse.json({}, { status: 201 });
@@ -295,8 +313,9 @@ describe("OperationForm", () => {
});
});
it("edits a full refund with its own amount excluded from the limit and immutable purchase", async () => {
const refund: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const refund: BankOperation
= {
amount: money("500"),
availableRefund: money("500"),
cardId: "a",
@@ -312,8 +331,9 @@ describe("OperationForm", () => {
targetCardId: null,
targetCardName: null,
version: 2,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
const purchase = {
...refund,
availableRefund: money("0"),
@@ -321,6 +341,7 @@ describe("OperationForm", () => {
id: "purchase",
kind: "expense",
};
server.use(
http.get("*/api/v1/bank-cards/a/expenses", () =>
HttpResponse.json({
@@ -354,6 +375,7 @@ describe("OperationForm", () => {
"*/api/v1/bank-cards/operations/refund",
async ({ request }) => {
body = await request.json();
return HttpResponse.json(refund);
},
),
@@ -372,8 +394,9 @@ describe("OperationForm", () => {
});
it("keeps edit draft and stale version until explicit adoption after conflict", async () => {
const operation: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const operation: BankOperation
= {
amount: money("100"),
availableRefund: money("100"),
cardId: "a",
@@ -389,8 +412,9 @@ describe("OperationForm", () => {
targetCardId: null,
targetCardName: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
let readVersion = 1;
const bodies: Record<string, unknown>[] = [];
server.use(
@@ -407,6 +431,7 @@ describe("OperationForm", () => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
readVersion = 2;
return body.version === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
@@ -465,6 +490,7 @@ describe("OperationForm", () => {
server.use(
http.post("*/api/v1/bank-cards/categories", () => {
isCreated = true;
return HttpResponse.json(
{ id: "new", isDefault: false, name: "кафе", version: 1 },
{ status: 201 },
@@ -498,6 +524,7 @@ describe("OperationForm", () => {
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
body = await request.json();
return HttpResponse.json({}, { status: 201 });
}),
);
@@ -11,6 +11,8 @@ import { useOperationFormVm } from "./OperationForm.vm";
const OperationForm: FC<OperationFormProps> = (props) => {
const vm = useOperationFormVm(props);
const feedback = vm.error ? <BankFeedback error={vm.error} /> : undefined;
return (
<>
<FormProvider {...vm.methods}>
@@ -25,7 +27,7 @@ const OperationForm: FC<OperationFormProps> = (props) => {
/>
)
}
feedback={(vm.error) ? (<BankFeedback error={vm.error} />) : undefined}
feedback={feedback}
refundFeedback={(
<>
{vm.expensesError && (
@@ -34,8 +36,7 @@ const OperationForm: FC<OperationFormProps> = (props) => {
loadingError
retry={vm.retryExpenses}
/>
)}
{" "}
)}{" "}
{vm.sourceError && (
<BankFeedback
error={vm.sourceError}
@@ -47,6 +48,7 @@ const OperationForm: FC<OperationFormProps> = (props) => {
)}
/>
</FormProvider>
{vm.categoriesOpen && (
<CategoryManager
onClose={vm.closeCategories}
@@ -11,7 +11,6 @@ import {
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { BankOperation } from "@/entities/bank-card";
@@ -20,9 +19,12 @@ import { formatMoney } from "@/shared/lib";
import type { OperationValues } from "./OperationForm.types";
import { useOperationFieldsVm } from "./OperationFields.vm";
import classes from "./OperationForm.module.css";
import { useRefundFieldsVm } from "./RefundFields.vm";
type Choice = { label: string; value: string };
type OperationFieldsProps = {
cards: Choice[];
categories: Choice[];
@@ -36,65 +38,58 @@ type OperationFieldsProps = {
targets: Choice[];
validDate: (value: string) => boolean;
};
const OperationFields: FC<OperationFieldsProps> = (props) => {
const { t } = useTranslation();
const {
control,
formState: { errors, isSubmitting },
cardIdField,
categoryIdField,
dateField,
dateParser,
dateValue,
errors,
isSubmitting,
kindField,
onDateBlur,
onDateChange,
onDateInput,
onKindChange,
register,
setValue,
} = useFormContext<OperationValues>();
sourceCardLabel,
t,
targetCardIdField,
} = useOperationFieldsVm(props);
return (
<Stack>
<Controller
control={control}
name="kind"
render={({ field }) => (
<Select
data={props.kinds}
label={t("bank.kind")}
required
{...field}
disabled={isSubmitting || props.editing}
onChange={(value) => {
field.onChange(value ?? "expense");
if (value === "refund") setValue("cardId", props.refundCardId);
}}
/>
)}
<Select
data={props.kinds}
label={t("bank.kind")}
required
{...kindField}
disabled={isSubmitting || props.editing}
onChange={onKindChange}
/>
<Controller
control={control}
name="cardId"
render={({ field }) => (
<Select
data={props.cards}
label={t(
props.kind === "transfer" ? "bank.source" : "bank.operationCard",
)}
required
{...field}
disabled={isSubmitting || props.kind === "refund"}
error={errors.cardId?.message}
/>
)}
<Select
data={props.cards}
label={sourceCardLabel}
required
{...cardIdField}
disabled={isSubmitting || props.kind === "refund"}
error={errors.cardId?.message}
/>
{props.kind === "transfer" && (
<Controller
control={control}
name="targetCardId"
render={({ field }) => (
<Select
data={props.targets}
label={t("bank.destination")}
required
{...field}
disabled={isSubmitting}
error={errors.targetCardId?.message}
/>
)}
<Select
data={props.targets}
label={t("bank.destination")}
required
{...targetCardIdField}
disabled={isSubmitting}
error={errors.targetCardId?.message}
/>
)}
<TextInput
inputMode="decimal"
label={t("bank.amount")}
@@ -102,54 +97,38 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
{...register("amount")}
error={errors.amount?.message}
/>
<Controller
control={control}
name="date"
render={({ field }) => (
<DateInput
{...field}
dateParser={value =>
props.validDate(props.dateInputValue(value))
? props.dateInputValue(value)
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null}
error={errors.date?.message}
fixOnBlur={false}
label={t("bank.date")}
maxDate={props.maxDate()}
onBlur={(event) => {
field.onChange(props.dateInputValue(event.currentTarget.value));
field.onBlur();
}}
onChange={value => field.onChange(value ?? "")}
onInput={event =>
field.onChange(props.dateInputValue(event.currentTarget.value))}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
withinPortal: false,
}}
required
value={(props.validDate(field.value)) ? (field.value) : undefined}
valueFormat="DD.MM.YYYY"
/>
)}
<DateInput
{...dateField}
dateParser={dateParser}
error={errors.date?.message}
fixOnBlur={false}
label={t("bank.date")}
maxDate={props.maxDate()}
onBlur={onDateBlur}
onChange={onDateChange}
onInput={onDateInput}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
withinPortal: false,
}}
required
value={dateValue}
valueFormat="DD.MM.YYYY"
/>
{props.kind === "expense" && (
<Controller
control={control}
name="categoryId"
render={({ field }) => (
<Select
data={props.categories}
label={t("bank.category")}
required
{...field}
disabled={isSubmitting}
error={errors.categoryId?.message}
value={field.value || props.defaultCategory}
/>
)}
<Select
data={props.categories}
label={t("bank.category")}
required
{...categoryIdField}
disabled={isSubmitting}
error={errors.categoryId?.message}
value={categoryIdField.value || props.defaultCategory}
/>
)}
<Textarea
label={t("bank.description")}
{...register("description")}
@@ -158,43 +137,36 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
</Stack>
);
};
type RefundFieldsProps = {
editing: boolean;
expenses: Choice[];
limit: BankOperation["availableRefund"] | undefined;
onSelect: (id: string) => void;
};
const RefundFields: FC<RefundFieldsProps> = (props) => {
const { t } = useTranslation();
const {
control,
formState: { errors, isSubmitting },
} = useFormContext<OperationValues>();
const { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t }
= useRefundFieldsVm(props);
return (
<Stack>
<Controller
control={control}
name="originalExpenseId"
render={({ field }) => (
<Select
data={props.expenses}
label={t("bank.expense")}
required
{...field}
disabled={isSubmitting || props.editing}
error={errors.originalExpenseId?.message}
onChange={(value) => {
field.onChange(value ?? "");
props.onSelect(value ?? "");
}}
/>
)}
<Select
data={props.expenses}
label={t("bank.expense")}
required
{...originalExpenseIdField}
disabled={isSubmitting || props.editing}
error={errors.originalExpenseId?.message}
onChange={onExpenseChange}
/>
{props.limit && (
<Text>
{t("bank.availableRefund", { amount: formatMoney(props.limit) })}
</Text>
)}
{props.editing && <Text>{t("bank.purchaseImmutable")}</Text>}
</Stack>
);
@@ -226,8 +198,14 @@ type OperationFormViewProps = {
saveDisabled: boolean;
sourcePage: number;
};
const OperationFormView: FC<OperationFormViewProps> = (props) => {
const { t } = useTranslation();
const displayTitle = t(
props.editing ? "bank.editOperation" : "bank.addOperation",
);
return (
<Modal
closeButtonProps={{
@@ -239,19 +217,23 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
onClose={props.onClose}
onExitTransitionEnd={props.onExitTransitionEnd}
opened={props.opened}
title={t(props.editing ? "bank.editOperation" : "bank.addOperation")}
title={displayTitle}
>
<form onSubmit={props.onSubmit}>
<fieldset className={classes.fieldset} disabled={props.pending}>
<Stack>
<OperationFields {...props.fields} />
{props.fields.kind === "refund" && (
<>
<RefundFields {...props.refund} />
{props.loadingExpenses && (
<Text role="status">{t("bank.loadingHistory")}</Text>
)}
{props.emptyExpenses && <Text>{t("bank.noPurchases")}</Text>}
{!props.editing && (
<Group>
<Button
@@ -261,7 +243,9 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
>
{t("bank.previous")}
</Button>
<Text>{t("bank.page", { page: props.sourcePage })}</Text>
<Button
disabled={props.nextDisabled}
onClick={props.onNext}
@@ -271,31 +255,39 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
</Button>
</Group>
)}
{props.refundFeedback}
</>
)}
{props.fields.kind === "expense" && (
<Button onClick={props.onAddCategory} variant="subtle">
{t("bank.addCategory")}
</Button>
)}
{props.categoriesFeedback}
{props.feedback}
{props.onReload && (
<Button onClick={props.onReload} variant="default">
{t("bank.reloadRecord")}
</Button>
)}
{props.latest && (
<>
<Text>
{t("bank.versionHint", { details: props.latest.details })}
</Text>
<Button onClick={props.latest.onAdopt} variant="default">
{t("bank.adoptVersion", { version: props.latest.version })}
</Button>
</>
)}
<Group>
<Button
disabled={props.saveDisabled}
@@ -304,6 +296,7 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
>
{t("bank.save")}
</Button>
<Button onClick={props.onCancel} variant="default">
{t("bank.cancel")}
</Button>
@@ -2,7 +2,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import {
bankDateInputValue,
@@ -11,15 +10,12 @@ import {
type BankOperation,
bankOperationKinds,
bankToday,
moneyCents,
positiveMoneySchema,
useBankCategories,
useBankExpenses,
useBankFormFocus,
useBankMutations,
useBankOperation,
} from "@/entities/bank-card";
import { validationMessage } from "@/shared/i18n";
import { formatDate, formatMoney } from "@/shared/lib";
import type {
@@ -27,53 +23,7 @@ import type {
OperationValues,
} from "./OperationForm.types";
// ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod.
enum FormIssueCode { Custom = "custom" }
const MAX_DESCRIPTION_LENGTH = 500;
const operationSchema = z.object({
amount: positiveMoneySchema,
cardId: z.string(),
categoryId: z.string(),
date: bankDateSchema,
description: z
.string()
.max(
MAX_DESCRIPTION_LENGTH,
validationMessage("bank.validation.description")(undefined),
),
kind: z.enum(bankOperationKinds),
originalExpenseId: z.string(),
targetCardId: z.string(),
});
function validateRefund(values: OperationValues, context: z.RefinementCtx,
expense: BankOperation | undefined, limit: BankOperation["availableRefund"] | undefined) {
if (!expense || expense.cardId !== values.cardId)
context.addIssue({
code: FormIssueCode.Custom,
path: ["originalExpenseId"],
...validationMessage("bank.validation.required")(undefined),
});
if (expense && values.date < expense.date)
context.addIssue({
code: FormIssueCode.Custom,
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
const amount = moneyCents(values.amount);
if (
limit
&& amount !== null
&& amount > (moneyCents(limit.value) ?? 0n)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["amount"],
...validationMessage("bank.validation.refund")(undefined),
});
}
import { createOperationSchema } from "./OperationForm.schema";
const useOperationFormVm = (props: OperationFormProps) => {
useBankFormFocus();
@@ -83,19 +33,24 @@ const useOperationFormVm = (props: OperationFormProps) => {
const [version, setVersion] = useState(props.operation?.version ?? 1);
const [latest, setLatest] = useState<BankOperation>();
const [sourcePage, setSourcePage] = useState(1);
const [sourceId, setSourceId] = useState(
props.operation?.originalExpenseId ?? "",
);
const sourceQuery = useBankOperation(sourceId || undefined);
const currentQuery = useBankOperation(props.operation?.id);
const [refundLimit, setRefundLimit] = useState(
props.operation?.availableRefund,
);
const [intent] = useState(() => crypto.randomUUID());
const [submittedValues, setSubmittedValues] = useState<OperationValues>();
const [error, setError] = useState<unknown>();
const categories = useBankCategories();
const mutation = useBankMutations();
const methods = useForm<OperationValues>({
defaultValues: {
amount: props.operation?.amount.value ?? "",
@@ -108,84 +63,50 @@ const useOperationFormVm = (props: OperationFormProps) => {
targetCardId: props.operation?.targetCardId ?? "",
},
resolver: zodResolver(
operationSchema.superRefine((values, context) => {
// Repeated intent reaches server idempotency even after its source changed.
if (
submittedValues
&& JSON.stringify(values) === JSON.stringify(submittedValues)
)
return;
const source = props.cards.find(c => c.id === values.cardId);
const target = props.cards.find(c => c.id === values.targetCardId);
if (!source || source.archivedAt)
context.addIssue({
code: FormIssueCode.Custom,
path: ["cardId"],
...validationMessage("bank.validation.required")(undefined),
});
if (
values.kind === "transfer"
&& (!target || target.archivedAt || target.id === values.cardId)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["targetCardId"],
...validationMessage("bank.validation.destination")(undefined),
});
if (
(source && values.date < source.openingDate)
|| (target
&& values.kind === "transfer"
&& values.date < target.openingDate)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
if (values.kind === "refund") validateRefund(values, context, sourceQuery.data,
props.operation ? refundLimit : sourceQuery.data?.availableRefund);
if (
values.kind === "expense"
&& !categories.data?.some(
c =>
c.id
=== (values.categoryId
|| categories.data?.find(c => c.isDefault)?.id),
)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["categoryId"],
...validationMessage("bank.validation.required")(undefined),
});
createOperationSchema({
cards: props.cards,
categories: categories.data,
expense: sourceQuery.data,
limit: props.operation
? refundLimit
: sourceQuery.data?.availableRefund,
submittedValues,
}),
),
});
const kind = useWatch({ control: methods.control, name: "kind" });
const expenses = useBankExpenses(
kind === "refund" ? props.card.id : undefined,
{ page: sourcePage },
);
const source = useWatch({ control: methods.control, name: "cardId" });
const isPending = !opened || methods.formState.isSubmitting;
const options = props.cards
.filter(c => !c.archivedAt)
.map(c => ({ label: c.name, value: c.id }));
const onSubmit = methods.handleSubmit(async (values) => {
setSubmittedValues(values);
setError(undefined);
try {
const body = {
cardId: values.cardId,
kind: values.kind,
...((values.kind === "transfer") && { targetCardId: values.targetCardId }),
...((values.kind === "expense") && {
...(values.kind === "transfer" && {
targetCardId: values.targetCardId,
}),
...(values.kind === "expense" && {
categoryId:
values.categoryId
|| categories.data?.find(c => c.isDefault)?.id,
values.categoryId || categories.data?.find(c => c.isDefault)?.id,
}),
...(values.kind === "refund" && {
originalExpenseId: values.originalExpenseId,
}),
...((values.kind === "refund") && { originalExpenseId: values.originalExpenseId }),
amount: {
currency: "RUB" as const,
value: bankMoneyValue(values.amount),
@@ -193,6 +114,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
date: values.date,
description: values.description,
};
if (props.operation)
await mutation.updateOperation.mutateAsync({
body: { ...body, version },
@@ -209,6 +131,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
setError(error_);
}
});
const onReload = async () => {
const query = props.operation ? currentQuery : sourceQuery;
const result = await query.refetch();
@@ -231,16 +154,24 @@ const useOperationFormVm = (props: OperationFormProps) => {
}),
value: item.id,
});
const expenseOptions = expenses.data?.items.map(element => expenseChoice(element)) ?? [];
const expenseOptions
= expenses.data?.items.map(element => expenseChoice(element)) ?? [];
const selected = sourceQuery.data
? expenseChoice(sourceQuery.data)
: undefined;
return {
categoriesError: categories.isError ? categories.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
categoriesError: categories.isError
? categories.error
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
categoriesOpen,
closeCategories: () => setCategoriesOpen(false),
error,
expensesError: expenses.isError ? expenses.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
expensesError: expenses.isError
? expenses.error
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
methods,
onCategoryCreated: (id: string) => {
methods.setValue("categoryId", id);
@@ -249,7 +180,9 @@ const useOperationFormVm = (props: OperationFormProps) => {
retryCategories: () => void categories.refetch(),
retryExpenses: () => void expenses.refetch(),
retrySource: () => void sourceQuery.refetch(),
sourceError: sourceQuery.isError ? sourceQuery.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
sourceError: sourceQuery.isError
? sourceQuery.error
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
view: {
categoriesOpen,
editing: Boolean(props.operation),
@@ -0,0 +1,38 @@
import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { BankOperation } from "@/entities/bank-card";
import type { OperationValues } from "./OperationForm.types";
type Choice = { label: string; value: string };
type RefundFieldsProps = {
editing: boolean;
expenses: Choice[];
limit: BankOperation["availableRefund"] | undefined;
onSelect: (id: string) => void;
};
function useRefundFieldsVm(props: RefundFieldsProps) {
const { t } = useTranslation();
const {
control,
formState: { errors, isSubmitting },
} = useFormContext<OperationValues>();
const { field: originalExpenseIdField } = useController({
control,
name: "originalExpenseId",
});
const onExpenseChange: (value: null | string) => void = (value) => {
originalExpenseIdField.onChange(value ?? "");
props.onSelect(value ?? "");
};
return { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t };
}
export { useRefundFieldsVm };
+38 -22
View File
@@ -18,6 +18,7 @@ import { server } from "@/test/server";
import { CardsPage } from "./CardsPage";
const money = (value: string) => ({ currency: "RUB", value });
function renderCards(isFail = false, hasCategoryFailure = false) {
configureAuthClient({
accessToken: () => "test",
@@ -32,7 +33,8 @@ function renderCards(isFail = false, hasCategoryFailure = false) {
isFail ? HttpResponse.error() : HttpResponse.json([]),
),
http.get("*/api/v1/bank-cards/summary", () =>
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
HttpResponse.json(
{
cardId: null,
categories: [],
@@ -40,10 +42,12 @@ function renderCards(isFail = false, hasCategoryFailure = false) {
month: "2026-09",
net: money("0"),
refunds: money("0"),
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
),
);
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
@@ -54,6 +58,7 @@ function renderCards(isFail = false, hasCategoryFailure = false) {
</QueryClientProvider>,
);
}
describe("CardsPage", () => {
it("preserves exact negative opening amount and the creation intent through unknown failure", async () => {
renderCards();
@@ -67,12 +72,15 @@ describe("CardsPage", () => {
server.use(
http.post("*/api/v1/bank-cards", async ({ request }) => {
submitted.push((await request.json()) as Record<string, unknown>);
if (submitted.length === 1) {
const deferred = Promise.withResolvers<void>();
resolve = deferred.resolve;
await deferred.promise;
return HttpResponse.error();
}
return HttpResponse.json({ id: submitted[0].id }, { status: 201 });
}),
);
@@ -111,14 +119,16 @@ describe("CardsPage", () => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
);
expect(submitted[0]).toEqual(submitted[1]);
expect(submitted[0]).toMatchObject((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
expect(submitted[0]).toMatchObject(
{
bankName: null,
lastFour: null,
name: "Повседневная",
openingBalance: money("-92233720368547758.08"),
}
/* eslint-enable unicorn/no-null */));
},
/* eslint-enable unicorn/no-null */
);
});
it("uses archive only for card list and keeps all-card summary including negative net", async () => {
renderCards();
@@ -126,20 +136,22 @@ describe("CardsPage", () => {
server.use(
http.get("*/api/v1/bank-cards", ({ request }) => {
requests.push(new URL(request.url).search);
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
return HttpResponse.json([
(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
archivedAt: "2026-09-01",
balance: money("0"),
bankName: null,
id: "demo",
lastFour: null,
name: "Запасная",
openingBalance: money("0"),
openingDate: "2026-01-01",
version: 1,
}
/* eslint-enable unicorn/no-null */),
{
archivedAt: "2026-09-01",
balance: money("0"),
bankName: null,
id: "demo",
lastFour: null,
name: "Запасная",
openingBalance: money("0"),
openingDate: "2026-01-01",
version: 1,
},
/* eslint-enable unicorn/no-null */
]);
}),
);
@@ -162,15 +174,19 @@ describe("CardsPage", () => {
server.use(
http.get("*/api/v1/bank-cards/summary", ({ request }) => {
requests.push(new URL(request.url).search);
return HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
return HttpResponse.json(
{
cardId: null,
categories: [],
expenses: money("0"),
net: money("0"),
refunds: money("0"),
}
/* eslint-enable unicorn/no-null */));
},
/* eslint-enable unicorn/no-null */
);
}),
);
fireEvent.click(screen.getByLabelText("Период"));
+5 -2
View File
@@ -7,12 +7,15 @@ import { useCardsPageVm } from "./CardsPage.vm";
const CardsPage: FC = () => {
const vm = useCardsPageVm();
return (
<>
<CardsPageView vm={vm} />
{vm.adding && <BankCardForm onClose={() => vm.setAdding(false)} />}
{vm.adding && <BankCardForm onClose={vm.closeAdding} />}
{vm.managingCategories && (
<CategoryManager onClose={() => vm.setManagingCategories(false)} />
<CategoryManager onClose={vm.closeCategories} />
)}
</>
);
+76 -63
View File
@@ -12,7 +12,7 @@ import {
Text,
Title,
} from "@mantine/core";
import { AddIcon } from "@wealthledger/icons";
import { AddIcon, CategoriesIcon } from "@wealthledger/icons";
import { LoadingSkeleton, StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -27,61 +27,78 @@ import {
import type { CardsPageVm } from "./CardsPage.vm";
import classes from "./CardsPage.module.css";
import { useCardsPageViewVm } from "./CardsPageView.vm";
type CardsPageViewProps = { vm: CardsPageVm };
const CardsPageView: FC<CardsPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
function renderCards() {
if (vm.items) {
if (vm.items.length > 0) {
return (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
{vm.items.map(card => (
<Paper
className={classes.card}
component={Link}
key={card.id}
p={0}
to={card.to}
>
<BankCardFace card={card} />
</Paper>
))}
</SimpleGrid>
);
}
const CardsPageViewCards: FC<CardsPageViewProps> = (props) => {
const { t } = useTranslation();
const { vm } = props;
const { onAddCard } = useCardsPageViewVm(props);
if (vm.items) {
if (vm.items.length > 0) {
return (
<StateView
action={(
<Button onClick={() => vm.setAdding(true)}>
{t("bank.addCard")}
</Button>
)}
text={t(vm.archive ? "bank.emptyHint" : "bank.noActive")}
title={t("bank.empty")}
/>
);
}
if (vm.cards.isLoading) {
return (
<LoadingSkeleton label={t("bank.loading")}>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Skeleton h={250} />
<Skeleton h={250} />
</SimpleGrid>
</LoadingSkeleton>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
{vm.items.map(card => (
<Paper
className={classes.card}
component={Link}
key={card.id}
p={0}
to={card.to}
>
<BankCardFace card={card} />
</Paper>
))}
</SimpleGrid>
);
}
const text = t(vm.archive ? "bank.emptyHint" : "bank.noActive");
return (
<StateView
action={<Button onClick={onAddCard}>{t("bank.addCard")}</Button>}
text={text}
title={t("bank.empty")}
/>
);
}
if (vm.cards.isLoading) {
return (
<LoadingSkeleton label={t("bank.loading")}>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Skeleton h={250} />
<Skeleton h={250} />
</SimpleGrid>
</LoadingSkeleton>
);
}
};
const CardsPageView: FC<CardsPageViewProps> = (props) => {
const {
onAddCard,
onArchiveChange,
onCategories,
retryCards,
retryCategories,
retrySummary,
t,
vm,
} = useCardsPageViewVm(props);
return (
<Stack gap="lg">
<Group gap="sm">
<Title order={1}>{t("bank.title")}</Title>
<ActionIcon
aria-label={t("bank.addCard")}
onClick={() => vm.setAdding(true)}
onClick={onAddCard}
size="lg"
title={t("bank.addCard")}
variant="light"
@@ -89,63 +106,59 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
<AddIcon size={20} strokeWidth={2} />
</ActionIcon>
</Group>
<Switch
checked={vm.archive}
label={t("bank.showArchive")}
onChange={event => vm.setArchive(event.currentTarget.checked)}
onChange={onArchiveChange}
/>
{renderCards()}
<CardsPageViewCards {...props} />
{vm.cards.isError && (
<Group role="alert">
<Text>{t(vm.items ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={() => void vm.cards.refetch()} variant="default">
<Button onClick={retryCards} variant="default">
{t("bank.retry")}
</Button>
</Group>
)}
{vm.cards.data && vm.cards.isFetching && (
<Text role="status">{t("bank.refreshing")}</Text>
)}
<Group align="flex-end" justify="space-between">
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
{Boolean(vm.categories.data?.length || vm.cards.data?.length) && (
<Button
color="gray"
leftSection={(
<svg
aria-hidden="true"
fill="none"
height="18"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.7"
viewBox="0 0 24 24"
width="18"
>
<path d="M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z" />
</svg>
)}
onClick={() => vm.setManagingCategories(true)}
leftSection={<CategoriesIcon size={18} strokeWidth="1.7" />}
onClick={onCategories}
variant="subtle"
>
{t("bank.categories")}
</Button>
)}
</Group>
<BankSummaryView
all
data={vm.summary.data}
error={vm.summary.isError}
fetching={vm.summary.isFetching}
loading={vm.summary.isLoading}
retry={() => void vm.summary.refetch()}
retry={retrySummary}
/>
{vm.categories.isError && (
<BankFeedback
cached={Boolean(vm.categories.data)}
error={vm.categories.error}
loadingError
retry={() => void vm.categories.refetch()}
retry={retryCategories}
/>
)}
</Stack>
@@ -9,6 +9,7 @@ import {
} from "@/entities/bank-card";
type CardsPageVm = ReturnType<typeof useCardsPageVm>;
function useCardsPageVm() {
const [parameters, setParameters] = useSearchParams();
const period = bankPeriod(parameters);
@@ -18,11 +19,14 @@ function useCardsPageVm() {
const [adding, setAdding] = useState(false);
const cards = useBankCards(archive);
const summary = useBankSummary(period);
return {
adding,
archive,
cards,
categories,
closeAdding: () => setAdding(false),
closeCategories: () => setManagingCategories(false),
items: cards.data?.map(card => ({
...card,
to: `/cards/${card.id}?${new URLSearchParams(period)}`,
@@ -0,0 +1,35 @@
import type { ComponentProps } from "react";
import { Switch } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { CardsPageVm } from "./CardsPage.vm";
type CardsPageViewProps = { vm: CardsPageVm };
function useCardsPageViewVm(props: CardsPageViewProps) {
const { vm } = props;
const { t } = useTranslation();
const onAddCard = () => vm.setAdding(true);
const onArchiveChange: ComponentProps<typeof Switch>["onChange"] = event =>
vm.setArchive(event.currentTarget.checked);
const retryCards = () => void vm.cards.refetch();
const onCategories = () => vm.setManagingCategories(true);
const retrySummary = () => void vm.summary.refetch();
const retryCategories = () => void vm.categories.refetch();
return {
onAddCard,
onArchiveChange,
onCategories,
retryCards,
retryCategories,
retrySummary,
t,
vm,
};
}
export { useCardsPageViewVm };
+1
View File
@@ -13,6 +13,7 @@ const NotFoundPage: FC = () => {
return (
<Stack>
<Title order={1}>{t("notFound.title")}</Title>
<StateView
action={(
<Button component={Link} to="/accounts">
+1 -1
View File
@@ -1 +1 @@
export { SettingsPage } from "./ui/SettingsPage";
export { SettingsPage } from "./ui/SettingsPage/SettingsPage";
-31
View File
@@ -1,31 +0,0 @@
import type { FC } from "react";
import { Button, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useAuth } from "@/features/auth";
import { TbankConnectionSection } from "@/features/tbank-connection";
const SettingsPage: FC = () => {
const { t } = useTranslation();
const { logout } = useAuth();
const navigate = useNavigate();
return (
<Stack>
<Title order={1}>{t("settings.title")}</Title>
<TbankConnectionSection />
<Button
onClick={async () => {
await logout();
await navigate("/login", { replace: true });
}}
>
{t("auth.logout")}
</Button>
</Stack>
);
};
export { SettingsPage };
@@ -0,0 +1,23 @@
import type { FC } from "react";
import { Button, Stack, Title } from "@mantine/core";
import { TbankConnectionSection } from "@/features/tbank-connection";
import { useSettingsPageVm } from "./SettingsPage.vm";
const SettingsPage: FC = () => {
const { onLogout, t } = useSettingsPageVm();
return (
<Stack>
<Title order={1}>{t("settings.title")}</Title>
<TbankConnectionSection />
<Button onClick={onLogout}>{t("auth.logout")}</Button>
</Stack>
);
};
export { SettingsPage };
@@ -0,0 +1,19 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useAuth } from "@/features/auth";
function useSettingsPageVm() {
const { t } = useTranslation();
const { logout } = useAuth();
const navigate = useNavigate();
const onLogout = async () => {
await logout();
await navigate("/login", { replace: true });
};
return { onLogout, t };
}
export { useSettingsPageVm };

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