refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -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 };
|
||||
|
||||
+1
@@ -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")} />
|
||||
);
|
||||
|
||||
+2
@@ -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>
|
||||
|
||||
+55
-41
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+24
@@ -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 };
|
||||
+7
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
+1
@@ -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>}
|
||||
|
||||
+41
-31
@@ -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>
|
||||
);
|
||||
|
||||
+10
@@ -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>
|
||||
|
||||
+44
-19
@@ -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}>
|
||||
|
||||
+10
-1
@@ -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 />,
|
||||
|
||||
+20
-38
@@ -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}
|
||||
|
||||
+69
@@ -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>
|
||||
))}
|
||||
|
||||
+59
-29
@@ -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 };
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+63
-64
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+78
@@ -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 };
|
||||
+6
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -19,4 +19,5 @@ const meta = {
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const InitialLoading: Story = { name: "Начальная загрузка счёта" };
|
||||
|
||||
+5
@@ -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>
|
||||
|
||||
+2
@@ -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 },
|
||||
|
||||
+7
-4
@@ -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>
|
||||
))}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+22
-13
@@ -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",
|
||||
},
|
||||
|
||||
+26
-14
@@ -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: [] }));
|
||||
|
||||
+8
-73
@@ -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 };
|
||||
|
||||
+54
-40
@@ -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>
|
||||
);
|
||||
|
||||
+108
@@ -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>
|
||||
))}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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("Период"));
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 @@
|
||||
export { SettingsPage } from "./ui/SettingsPage";
|
||||
export { SettingsPage } from "./ui/SettingsPage/SettingsPage";
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user