refactor: colocate frontend component files
Frontend security / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 5m10s
Conventional commits / commits (pull_request) Successful in 25s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m10s
Frontend security / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 5m10s
Conventional commits / commits (pull_request) Successful in 25s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m10s
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
.page {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.entries {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Keep the reused Ledger row's full text readable inside income cards. */
|
||||
.entries :global(.mantine-Text-root) {
|
||||
-webkit-line-clamp: unset;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.comparison {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.comparisonGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.comparisonPanel {
|
||||
min-width: 0;
|
||||
padding: var(--mantine-spacing-md);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border-top: 3px solid var(--mantine-color-gray-4);
|
||||
background: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
.comparisonPanel[data-kind="selected"] {
|
||||
border-color: var(--mantine-color-blue-6);
|
||||
background: var(--mantine-color-blue-0);
|
||||
}
|
||||
|
||||
.comparisonPanel[data-tone="positive"] {
|
||||
border-color: var(--mantine-color-green-8);
|
||||
background: var(--mantine-color-green-0);
|
||||
}
|
||||
|
||||
.comparisonPanel[data-tone="negative"] {
|
||||
border-color: var(--mantine-color-red-8);
|
||||
background: var(--mantine-color-red-0);
|
||||
}
|
||||
|
||||
.comparisonLabel,
|
||||
.comparisonValues dt {
|
||||
color: var(--mantine-color-gray-7);
|
||||
}
|
||||
|
||||
.comparisonValues {
|
||||
margin: var(--mantine-spacing-md) 0 0;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.comparisonValues dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.comparisonTotal {
|
||||
margin-bottom: var(--mantine-spacing-md);
|
||||
padding-bottom: var(--mantine-spacing-md);
|
||||
border-bottom: 1px solid var(--mantine-color-gray-3);
|
||||
}
|
||||
|
||||
.comparisonTotal dd {
|
||||
margin-top: 4px;
|
||||
font-size: var(--mantine-h2-font-size);
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.comparisonValues dd[data-tone="positive"] {
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-green-9) 85%,
|
||||
var(--mantine-color-black)
|
||||
);
|
||||
}
|
||||
|
||||
.comparisonValues dd[data-tone="negative"] {
|
||||
color: var(--mantine-color-red-9);
|
||||
}
|
||||
|
||||
.comparisonValues dd[data-tone="neutral"] {
|
||||
color: var(--mantine-color-gray-7);
|
||||
}
|
||||
|
||||
.comparisonPanel[data-kind="selected"] .comparisonTotal dd {
|
||||
color: var(--mantine-color-blue-8);
|
||||
}
|
||||
|
||||
.comparisonDetail {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 4px var(--mantine-spacing-xs);
|
||||
margin-top: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.comparisonPercent {
|
||||
display: inline-block;
|
||||
margin-left: var(--mantine-spacing-xs);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: var(--mantine-color-white);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
letter-spacing: normal;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.comparisonNotice {
|
||||
background: var(--mantine-color-gray-0);
|
||||
border-left: 3px solid var(--mantine-color-gray-4);
|
||||
}
|
||||
|
||||
@container (min-width: 42em) {
|
||||
.comparisonGrid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 35.999em) {
|
||||
.fields {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions > button,
|
||||
.fields input {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
import "dayjs/locale/ru";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { DatesProvider } from "@mantine/dates";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
MemoryRouter,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { IncomeAnalytics } from "@/entities/income";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { isDate } from "../../model/period";
|
||||
import { AccountIncomePage } from "./AccountIncomePage";
|
||||
import { toIncomeDisplayModel } from "./AccountIncomePage.vm";
|
||||
import { IncomePayments } from "./components/IncomePayments.view";
|
||||
|
||||
const analytics = {
|
||||
accountId: "account-1",
|
||||
period: { from: "2026-08-01", to: "2026-08-27" },
|
||||
calculatedAt: "2026-08-27T00:00:00Z",
|
||||
totalsByCurrency: [summary("RUB", "10", "2")],
|
||||
previous: {
|
||||
period: { from: "2026-07-01", to: "2026-07-31" },
|
||||
totalsByCurrency: [comparison("RUB")],
|
||||
},
|
||||
yearComparison: null,
|
||||
bucketGranularity: "day",
|
||||
buckets: [
|
||||
{
|
||||
period: { from: "2026-08-01", to: "2026-08-01" },
|
||||
totalsByCurrency: [summary("RUB", "10", "2")],
|
||||
},
|
||||
],
|
||||
instruments: [
|
||||
{
|
||||
key: "income-group-opaque-v2",
|
||||
figi: "FIGI",
|
||||
instrumentUid: null,
|
||||
ticker: "BOND",
|
||||
instrumentType: "bond",
|
||||
displayName: "BOND",
|
||||
coupon: money("RUB", "10"),
|
||||
dividend: money("RUB", "2"),
|
||||
total: money("RUB", "12"),
|
||||
receiptCount: 2,
|
||||
},
|
||||
],
|
||||
entries: [
|
||||
entry(),
|
||||
{
|
||||
...entry(),
|
||||
id: "dividend-1",
|
||||
type: "OPERATION_TYPE_DIVIDEND",
|
||||
payment: money("RUB", "2"),
|
||||
},
|
||||
],
|
||||
} satisfies IncomeAnalytics;
|
||||
|
||||
function money(currency: string, value: string) {
|
||||
return { currency, value };
|
||||
}
|
||||
|
||||
function summary(currency: string, coupon: string, dividend: string) {
|
||||
return {
|
||||
currency,
|
||||
coupon: money(currency, coupon),
|
||||
dividend: money(currency, dividend),
|
||||
total: money(currency, String(Number(coupon) + Number(dividend))),
|
||||
};
|
||||
}
|
||||
|
||||
function comparison(currency: string) {
|
||||
return {
|
||||
currency,
|
||||
selected: summary(currency, "10", "2"),
|
||||
compared: summary(currency, "1", "0"),
|
||||
difference: summary(currency, "9", "2"),
|
||||
};
|
||||
}
|
||||
|
||||
function entry(): IncomeAnalytics["entries"][number] {
|
||||
return {
|
||||
id: "coupon-1",
|
||||
instrumentKey: "income-group-opaque-v2",
|
||||
occurredAt: "2026-08-01T12:00:00Z",
|
||||
type: "OPERATION_TYPE_COUPON",
|
||||
category: "income",
|
||||
description: "Купон",
|
||||
figi: "FIGI",
|
||||
instrumentUid: null,
|
||||
ticker: "BOND",
|
||||
instrumentType: "bond",
|
||||
logoName: null,
|
||||
payment: money("RUB", "10"),
|
||||
quantity: null,
|
||||
price: null,
|
||||
accruedInterest: null,
|
||||
commission: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPage(search = "from=2026-08-01&to=2026-08-27") {
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
|
||||
const result = render(
|
||||
<MantineProvider env="test">
|
||||
<DatesProvider settings={{ locale: "ru" }}>
|
||||
<QueryWrapper>
|
||||
<MemoryRouter
|
||||
initialEntries={[`/accounts/account-1/income?${search}`]}
|
||||
>
|
||||
<LocationProbe />
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/income"
|
||||
element={<AccountIncomePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts/:accountId/events"
|
||||
element={<h1>Events</h1>}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryWrapper>
|
||||
</DatesProvider>
|
||||
</MantineProvider>,
|
||||
);
|
||||
return { ...result, queryClient };
|
||||
}
|
||||
|
||||
describe("AccountIncomePage", () => {
|
||||
it("requests a multi-year period from the URL without resetting it", async () => {
|
||||
const requested: string[] = [];
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
|
||||
({ request }) => {
|
||||
requested.push(new URL(request.url).search);
|
||||
return HttpResponse.json(analytics);
|
||||
},
|
||||
),
|
||||
);
|
||||
renderPage("from=2020-01-01&to=2025-12-31");
|
||||
await waitFor(() =>
|
||||
expect(requested).toContain("?from=2020-01-01&to=2025-12-31"),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves every repeated-ID payment when an open card updates", async () => {
|
||||
const first = { ...entry(), id: "same-id", description: "Выплата A" };
|
||||
const second = {
|
||||
...entry(),
|
||||
id: "same-id",
|
||||
description: "Выплата B",
|
||||
payment: money("RUB", "-2"),
|
||||
};
|
||||
const third = {
|
||||
...entry(),
|
||||
id: "other-id",
|
||||
description: "Выплата C",
|
||||
payment: money("RUB", "1"),
|
||||
};
|
||||
function breakdown(entries: IncomeAnalytics["entries"], total: string) {
|
||||
const display = toIncomeDisplayModel({
|
||||
...analytics,
|
||||
instruments: [
|
||||
{
|
||||
...analytics.instruments[0],
|
||||
coupon: money("RUB", total),
|
||||
dividend: money("RUB", "0"),
|
||||
total: money("RUB", total),
|
||||
receiptCount: entries.length,
|
||||
},
|
||||
],
|
||||
entries,
|
||||
});
|
||||
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"));
|
||||
expect(payments()).toEqual(["Выплата C", "Выплата A", "Выплата B"]);
|
||||
expect(within(panel).getByText("−2 ₽")).toBeInTheDocument();
|
||||
|
||||
rerender(breakdown([second, second, first], "6"));
|
||||
expect(payments()).toEqual(["Выплата B", "Выплата B", "Выплата A"]);
|
||||
expect(within(panel).getAllByText("−2 ₽")).toHaveLength(2);
|
||||
|
||||
rerender(breakdown([second, first], "8"));
|
||||
expect(payments()).toEqual(["Выплата B", "Выплата A"]);
|
||||
expect(within(panel).getAllByRole("listitem")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing key", undefined],
|
||||
["empty key", ""],
|
||||
["whitespace key", " "],
|
||||
["non-string key", 12],
|
||||
["unknown key", "not-in-breakdown"],
|
||||
])("rejects %s and recovers through retry", async (_name, instrumentKey) => {
|
||||
let response = {
|
||||
...analytics,
|
||||
entries: [
|
||||
{ ...analytics.entries[0], instrumentKey },
|
||||
analytics.entries[1],
|
||||
],
|
||||
};
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(response),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||||
expect(
|
||||
screen.queryByRole("region", { name: "Выплаты" }),
|
||||
).not.toBeInTheDocument();
|
||||
response = analytics;
|
||||
fireEvent.click(retry);
|
||||
expect(
|
||||
await screen.findByRole("region", { name: "Выплаты" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"duplicate instrument keys",
|
||||
{
|
||||
...analytics,
|
||||
instruments: [...analytics.instruments, analytics.instruments[0]],
|
||||
},
|
||||
],
|
||||
[
|
||||
"currency mismatch",
|
||||
{
|
||||
...analytics,
|
||||
entries: [
|
||||
{ ...analytics.entries[0], payment: money("USD", "10") },
|
||||
analytics.entries[1],
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"receipt count mismatch",
|
||||
{
|
||||
...analytics,
|
||||
instruments: [{ ...analytics.instruments[0], receiptCount: 3 }],
|
||||
},
|
||||
],
|
||||
["missing entries", { ...analytics, entries: [] }],
|
||||
[
|
||||
"empty instrument key",
|
||||
{ ...analytics, instruments: [{ ...analytics.instruments[0], key: "" }] },
|
||||
],
|
||||
])("shows a local error for %s", async (_name, response) => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(response),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Повторить" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("region", { name: "Выплаты" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("accepts an empty analytics response", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json({
|
||||
...analytics,
|
||||
totalsByCurrency: [],
|
||||
buckets: [],
|
||||
instruments: [],
|
||||
entries: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Пассивный доход" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Повторить" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the signed drill-down using opaque membership rather than instrument identity", async () => {
|
||||
const response = {
|
||||
...analytics,
|
||||
instruments: [
|
||||
{
|
||||
...analytics.instruments[0],
|
||||
key: "server-only-key",
|
||||
total: money("RUB", "8"),
|
||||
dividend: money("RUB", "-2"),
|
||||
},
|
||||
],
|
||||
entries: [
|
||||
{
|
||||
...analytics.entries[0],
|
||||
instrumentKey: "server-only-key",
|
||||
figi: null,
|
||||
ticker: null,
|
||||
description: "Исходная выплата",
|
||||
},
|
||||
{
|
||||
...analytics.entries[1],
|
||||
instrumentKey: "server-only-key",
|
||||
instrumentUid: "unrelated-uid",
|
||||
payment: money("RUB", "-2"),
|
||||
description: "Отрицательная корректировка",
|
||||
},
|
||||
],
|
||||
};
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(response),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
const panel = await screen.findByRole("region", { name: "Выплаты" });
|
||||
expect(within(panel).getByText("Исходная выплата")).toBeInTheDocument();
|
||||
expect(
|
||||
within(panel).getByText("Отрицательная корректировка"),
|
||||
).toBeInTheDocument();
|
||||
expect(within(panel).getByText("−2 ₽")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rejects malformed calendar dates without throwing", () => {
|
||||
expect(isDate("2026-99-99")).toBe(false);
|
||||
expect(isDate("2026-02-29")).toBe(false);
|
||||
expect(isDate("2024-02-29")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders realized text and comparisons without subscribing to events", async () => {
|
||||
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json({
|
||||
...analytics,
|
||||
yearComparison: {
|
||||
period: { from: "2025-08-01", to: "2025-08-27" },
|
||||
totalsByCurrency: [comparison("RUB")],
|
||||
},
|
||||
}),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Пассивный доход" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Налоговые операции не включены в этот результат."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Ближайший прогноз")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
|
||||
expect(eventsRequest).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
|
||||
const selected = screen.getByRole("region", {
|
||||
name: "01.08.2026 – 27.08.2026",
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Годом ранее", level: 2 }),
|
||||
).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 = screen.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();
|
||||
expect(within(difference).getAllByText(/%$/)).toHaveLength(2);
|
||||
expect(
|
||||
within(difference).getByText("+2 ₽").closest("dd"),
|
||||
).toHaveTextContent(/^\+2 ₽$/);
|
||||
});
|
||||
|
||||
it("shows the unavailable year state without falling back to the previous period", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(analytics),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByText("Сравнение с годом ранее недоступно."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("region", { name: "Разница" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a recoverable realized error", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
||||
() => new HttpResponse(null, { status: 503 }),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Повторить" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("income comparison display", () => {
|
||||
it.each([
|
||||
["25", "100", "+25 %"],
|
||||
["-25", "100", "-25 %"],
|
||||
["0", "100", "0 %"],
|
||||
["-0.000", "100", "0 %"],
|
||||
["0.0001", "100", "0 %"],
|
||||
["1", "3", "+33,3 %"],
|
||||
["0.01", "0.04", "+25 %"],
|
||||
["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"],
|
||||
["10", "0", null],
|
||||
["0", "0", null],
|
||||
["10", "-0.00", null],
|
||||
["10", "-100", null],
|
||||
["9".repeat(400), "1", null],
|
||||
["1", "9".repeat(400), 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: {
|
||||
period: { from: "2025-08-01", to: "2025-08-27" },
|
||||
totalsByCurrency: [value],
|
||||
},
|
||||
});
|
||||
const result = display?.comparison?.values[0];
|
||||
expect(result?.percentages.coupon?.replaceAll("\u00a0", " ") ?? null).toBe(
|
||||
expected,
|
||||
);
|
||||
expect(result?.difference.coupon.value).toBe(difference);
|
||||
expect(result?.compared.coupon.value).toBe(base);
|
||||
});
|
||||
|
||||
it("formats calendar ranges across years and preserves separate currencies", () => {
|
||||
const display = toIncomeDisplayModel({
|
||||
...analytics,
|
||||
period: { from: "2025-12-01", to: "2026-01-09" },
|
||||
yearComparison: {
|
||||
period: { from: "2024-12-01", to: "2025-01-09" },
|
||||
totalsByCurrency: [comparison("RUB"), comparison("USD")],
|
||||
},
|
||||
});
|
||||
expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe(
|
||||
"01.12.2025 – 09.01.2026",
|
||||
);
|
||||
expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe(
|
||||
"01.12.2024 – 09.01.2025",
|
||||
);
|
||||
expect(display?.comparison?.values.map((value) => value.currency)).toEqual([
|
||||
"RUB",
|
||||
"USD",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const LocationProbe: FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<>
|
||||
<output data-testid="location">{location.search}</output>
|
||||
<button type="button" onClick={() => navigate(-1)}>
|
||||
Back
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function selectPeriod() {
|
||||
fireEvent.click(screen.getByLabelText("Период"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^27 августа 2026$/i }));
|
||||
}
|
||||
|
||||
function respondWithRequestedPeriod(requests: string[]) {
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
||||
({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
requests.push(url.search);
|
||||
return HttpResponse.json({
|
||||
...analytics,
|
||||
period: {
|
||||
from: url.searchParams.get("from"),
|
||||
to: url.searchParams.get("to"),
|
||||
},
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("income period and snapshot boundary", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("restores formatted URL dates, keeps draft unapplied, submits ISO and follows browser history", async () => {
|
||||
const requests: string[] = [];
|
||||
respondWithRequestedPeriod(requests);
|
||||
renderPage();
|
||||
expect(await screen.findByLabelText("Период")).toHaveTextContent(
|
||||
"01.08.2026 – 27.08.2026",
|
||||
);
|
||||
selectPeriod();
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(
|
||||
screen.queryByText("2026-08-01 — 2026-08-27"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("location")).toHaveTextContent(
|
||||
"from=2026-08-01&to=2026-08-27",
|
||||
);
|
||||
fireEvent.submit(
|
||||
screen
|
||||
.getByRole("button", { name: "Применить" })
|
||||
.closest("form") as HTMLFormElement,
|
||||
);
|
||||
await waitFor(() => expect(requests).toHaveLength(2));
|
||||
expect(await screen.findByLabelText("Период")).toHaveTextContent(
|
||||
"02.08.2026 – 27.08.2026",
|
||||
);
|
||||
expect(screen.getByTestId("location")).toHaveTextContent(
|
||||
"from=2026-08-02&to=2026-08-27",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||
"01.08.2026 – 27.08.2026",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an incomplete range without replacing the URL and allows a single day", async () => {
|
||||
const requests: string[] = [];
|
||||
respondWithRequestedPeriod(requests);
|
||||
renderPage();
|
||||
const period = await screen.findByLabelText("Период");
|
||||
fireEvent.click(period);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
|
||||
fireEvent.keyDown(period, { key: "Escape" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||
expect(await screen.findByText(/Введите существующую дату/)).toBeVisible();
|
||||
expect(requests).toHaveLength(1);
|
||||
if (period.getAttribute("aria-expanded") !== "true")
|
||||
fireEvent.click(period);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||
await waitFor(() =>
|
||||
expect(requests.at(-1)).toBe("?from=2026-08-02&to=2026-08-02"),
|
||||
);
|
||||
});
|
||||
|
||||
it("resets a dirty default period and errors using Moscow's day across a UTC month boundary", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-08-31T21:30:00Z"));
|
||||
const requests: string[] = [];
|
||||
respondWithRequestedPeriod(requests);
|
||||
renderPage("");
|
||||
expect(await screen.findByLabelText("Период")).toHaveTextContent(
|
||||
"01.09.2026 – 01.09.2026",
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("Период"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^1 сентября 2026$/i }));
|
||||
fireEvent.keyDown(screen.getByLabelText("Период"), { key: "Escape" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||
await screen.findByText(/Введите существующую дату/);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||
"01.09.2026 – 01.09.2026",
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(/Введите существующую дату/),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("location")).toHaveTextContent(
|
||||
"from=2026-09-01&to=2026-09-01",
|
||||
);
|
||||
expect(requests).toEqual(["?from=2026-09-01&to=2026-09-01"]);
|
||||
});
|
||||
|
||||
it("keeps valid data and draft during refetch, warns on failure and retries", async () => {
|
||||
respondWithRequestedPeriod([]);
|
||||
const { queryClient } = renderPage();
|
||||
await screen.findByLabelText("Период");
|
||||
selectPeriod();
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
||||
async () => {
|
||||
await pending;
|
||||
return new HttpResponse(null, { status: 503 });
|
||||
},
|
||||
),
|
||||
);
|
||||
act(() => {
|
||||
void queryClient.invalidateQueries();
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole("status", { name: "Обновляем пассивный доход" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
|
||||
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||
"02.08.2026 – 27.08.2026",
|
||||
);
|
||||
release();
|
||||
expect(
|
||||
await screen.findByText(/Показан последний загруженный результат/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
|
||||
respondWithRequestedPeriod([]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(/Показан последний загруженный результат/),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||
"02.08.2026 – 27.08.2026",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows busy skeleton for a new uncached period without relabeling the previous result", async () => {
|
||||
respondWithRequestedPeriod([]);
|
||||
renderPage();
|
||||
await screen.findByLabelText("Период");
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
||||
async () => {
|
||||
await pending;
|
||||
return HttpResponse.json({
|
||||
...analytics,
|
||||
period: { from: "2026-08-02", to: "2026-08-27" },
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
selectPeriod();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||
expect(
|
||||
await screen.findByRole("status", { name: "Загрузка" }),
|
||||
).toHaveAttribute("aria-busy", "true");
|
||||
expect(
|
||||
screen.queryByRole("region", { name: "Выплаты" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("2026-08-02 — 2026-08-27"),
|
||||
).not.toBeInTheDocument();
|
||||
release();
|
||||
expect(await screen.findByLabelText("Период")).toHaveTextContent(
|
||||
"02.08.2026 – 27.08.2026",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows one flat payment card per entry in server order without currency or instrument groups", () => {
|
||||
const instruments = [
|
||||
{
|
||||
...analytics.instruments[0],
|
||||
key: "usd",
|
||||
total: money("USD", "10"),
|
||||
coupon: money("USD", "10"),
|
||||
dividend: money("USD", "0"),
|
||||
receiptCount: 1,
|
||||
},
|
||||
{
|
||||
...analytics.instruments[0],
|
||||
key: "rub",
|
||||
total: money("RUB", "-2"),
|
||||
coupon: money("RUB", "-2"),
|
||||
dividend: money("RUB", "0"),
|
||||
receiptCount: 1,
|
||||
},
|
||||
{
|
||||
...analytics.instruments[0],
|
||||
key: "rub-negative",
|
||||
displayName: "Другой инструмент",
|
||||
total: money("RUB", "-3"),
|
||||
coupon: money("RUB", "-3"),
|
||||
dividend: money("RUB", "0"),
|
||||
receiptCount: 1,
|
||||
},
|
||||
];
|
||||
const display = toIncomeDisplayModel({
|
||||
...analytics,
|
||||
totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")],
|
||||
instruments,
|
||||
entries: instruments.map((instrument) => ({
|
||||
...entry(),
|
||||
instrumentKey: instrument.key,
|
||||
payment: instrument.total,
|
||||
})),
|
||||
});
|
||||
render(
|
||||
<MantineProvider env="test">
|
||||
<IncomePayments entries={display?.entries ?? []} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Выплаты" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { level: 3 })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { level: 4 })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("RUB")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("USD")).not.toBeInTheDocument();
|
||||
const cards = screen.getAllByRole("listitem");
|
||||
expect(cards).toHaveLength(3);
|
||||
expect(cards[0]).toHaveTextContent("+10 USD");
|
||||
expect(cards[1]).toHaveTextContent("−2 ₽");
|
||||
expect(cards[2]).toHaveTextContent("−3 ₽");
|
||||
expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("Купон")).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { FC } from "react";
|
||||
import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm";
|
||||
import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
|
||||
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
|
||||
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
|
||||
|
||||
type AccountIncomePageProps = { accountId?: string };
|
||||
|
||||
const AccountIncomePage: FC<AccountIncomePageProps> = (props) => {
|
||||
const viewModel = useAccountIncomePageViewModel(props.accountId);
|
||||
if (viewModel.state === "loading") return <AccountIncomePageLoading />;
|
||||
if (viewModel.state === "error")
|
||||
return <AccountIncomePageError {...viewModel} />;
|
||||
return <AccountIncomePageLoaded {...viewModel} />;
|
||||
};
|
||||
|
||||
export { AccountIncomePage };
|
||||
@@ -0,0 +1,267 @@
|
||||
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 { validationMessage } from "@/shared/i18n";
|
||||
import { formatDate } from "@/shared/lib";
|
||||
import {
|
||||
formatDraftDate,
|
||||
type IncomeForm,
|
||||
type IncomePeriod,
|
||||
isDate,
|
||||
moscowDate,
|
||||
parseDraftDate,
|
||||
} from "../../model/period";
|
||||
|
||||
const ISO_MONTH_LENGTH = 7;
|
||||
|
||||
type IncomeDisplayModel = {
|
||||
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
|
||||
comparison: {
|
||||
selectedPeriod: string;
|
||||
comparedPeriod: string;
|
||||
values: (NonNullable<
|
||||
IncomeAnalytics["yearComparison"]
|
||||
>["totalsByCurrency"][number] & {
|
||||
percentages: Record<"total" | "coupon" | "dividend", string | null>;
|
||||
})[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
type AccountIncomePageViewModel =
|
||||
| { state: "loading" }
|
||||
| { state: "error"; retry: () => void; period: IncomePeriod }
|
||||
| {
|
||||
state: "loaded";
|
||||
analytics: IncomeAnalytics;
|
||||
display: IncomeDisplayModel;
|
||||
formRevision: number;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry: () => void;
|
||||
form: ReturnType<typeof useForm<IncomeForm>>;
|
||||
onReset: () => void;
|
||||
onSubmit: (values: IncomeForm) => void;
|
||||
};
|
||||
|
||||
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 [searchParams, setSearchParams] = useSearchParams();
|
||||
const requested = useMemo(
|
||||
() => ({
|
||||
from: searchParams.get("from") ?? "",
|
||||
to: searchParams.get("to") ?? "",
|
||||
}),
|
||||
[searchParams],
|
||||
);
|
||||
const period = useMemo(
|
||||
() => (validPeriod(requested) ? requested : defaultPeriod()),
|
||||
[requested],
|
||||
);
|
||||
const form = useForm<IncomeForm>({
|
||||
defaultValues: draftPeriod(period),
|
||||
resolver: zodResolver(incomeFormSchema),
|
||||
});
|
||||
const [formRevision, setFormRevision] = useState(0);
|
||||
const analytics = useIncomeAnalytics(accountId, period);
|
||||
|
||||
useEffect(() => {
|
||||
if (!validPeriod(requested)) setSearchParams(period, { replace: true });
|
||||
}, [period, requested, setSearchParams]);
|
||||
useEffect(() => {
|
||||
form.reset(draftPeriod(period));
|
||||
setFormRevision((revision) => revision + 1);
|
||||
}, [form, period]);
|
||||
|
||||
const onSubmit = (values: IncomeForm) => {
|
||||
const from = parseDraftDate(values.from);
|
||||
const to = parseDraftDate(values.to);
|
||||
if (from && to) setSearchParams({ from, to });
|
||||
};
|
||||
const onReset = () => {
|
||||
const next = defaultPeriod();
|
||||
form.reset(draftPeriod(next));
|
||||
// Reset the open calendar along with the form.
|
||||
setFormRevision((revision) => revision + 1);
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
if (analytics.isLoading || !analytics.data) {
|
||||
if (analytics.isError)
|
||||
return { state: "error", retry: () => analytics.refetch(), period };
|
||||
return { state: "loading" };
|
||||
}
|
||||
const display = toIncomeDisplayModel(analytics.data);
|
||||
if (!display)
|
||||
return { state: "error", retry: () => analytics.refetch(), period };
|
||||
return {
|
||||
state: "loaded",
|
||||
analytics: analytics.data,
|
||||
display,
|
||||
formRevision,
|
||||
refreshing: analytics.isFetching,
|
||||
refreshError: analytics.isError,
|
||||
retry: () => analytics.refetch(),
|
||||
form,
|
||||
onSubmit,
|
||||
onReset,
|
||||
};
|
||||
};
|
||||
|
||||
function draftPeriod(period: IncomePeriod): IncomeForm {
|
||||
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
|
||||
}
|
||||
|
||||
function defaultPeriod(): IncomePeriod {
|
||||
const today = moscowDate(new Date());
|
||||
return { from: `${today.slice(0, ISO_MONTH_LENGTH)}-01`, to: today };
|
||||
}
|
||||
|
||||
function validPeriod(period: IncomePeriod): period is IncomePeriod {
|
||||
if (!isDate(period.from) || !isDate(period.to)) return false;
|
||||
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, 10) === period.from &&
|
||||
to.toISOString().slice(0, 10) === period.to &&
|
||||
from <= to &&
|
||||
to <= today
|
||||
);
|
||||
}
|
||||
|
||||
function toIncomeDisplayModel(
|
||||
analytics: IncomeAnalytics,
|
||||
): IncomeDisplayModel | null {
|
||||
const instruments = new Map<
|
||||
string,
|
||||
{
|
||||
currency: string;
|
||||
receiptCount: number;
|
||||
includedCount: number;
|
||||
}
|
||||
>();
|
||||
const currencies = new Set(
|
||||
analytics.totalsByCurrency.map(({ currency }) => currency),
|
||||
);
|
||||
for (const instrument of analytics.instruments) {
|
||||
if (
|
||||
typeof instrument.key !== "string" ||
|
||||
!instrument.key.trim() ||
|
||||
instruments.has(instrument.key) ||
|
||||
!currencies.has(instrument.total.currency)
|
||||
)
|
||||
return null;
|
||||
instruments.set(instrument.key, {
|
||||
currency: instrument.total.currency,
|
||||
receiptCount: instrument.receiptCount,
|
||||
includedCount: 0,
|
||||
});
|
||||
}
|
||||
const entries: IncomeDisplayModel["entries"] = [];
|
||||
const occurrences = new Map<string, number>();
|
||||
for (const entry of analytics.entries) {
|
||||
if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim())
|
||||
return null;
|
||||
const instrument = instruments.get(entry.instrumentKey);
|
||||
if (!instrument || entry.payment?.currency !== instrument.currency)
|
||||
return null;
|
||||
const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]);
|
||||
const occurrence = occurrences.get(occurrenceKey) ?? 0;
|
||||
occurrences.set(occurrenceKey, occurrence + 1);
|
||||
instrument.includedCount += 1;
|
||||
entries.push({
|
||||
key: JSON.stringify([
|
||||
entry.instrumentKey,
|
||||
entry.id,
|
||||
occurrence,
|
||||
entry.logoName,
|
||||
]),
|
||||
operation: entry,
|
||||
});
|
||||
}
|
||||
for (const instrument of instruments.values()) {
|
||||
if (instrument.includedCount !== instrument.receiptCount) return null;
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
comparison: analytics.yearComparison
|
||||
? {
|
||||
selectedPeriod: formatComparisonPeriod(analytics.period),
|
||||
comparedPeriod: formatComparisonPeriod(
|
||||
analytics.yearComparison.period,
|
||||
),
|
||||
values: analytics.yearComparison.totalsByCurrency.map((value) => ({
|
||||
...value,
|
||||
percentages: {
|
||||
total: formatIncomeChange(
|
||||
value.difference.total.value,
|
||||
value.compared.total.value,
|
||||
),
|
||||
coupon: formatIncomeChange(
|
||||
value.difference.coupon.value,
|
||||
value.compared.coupon.value,
|
||||
),
|
||||
dividend: formatIncomeChange(
|
||||
value.difference.dividend.value,
|
||||
value.compared.dividend.value,
|
||||
),
|
||||
},
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatComparisonPeriod(period: IncomePeriod): string {
|
||||
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
|
||||
}
|
||||
|
||||
function formatIncomeChange(
|
||||
difference: string,
|
||||
compared: string,
|
||||
): string | null {
|
||||
// Display-only ratio; server-provided Money values remain exact decimal strings.
|
||||
const base = Number(compared);
|
||||
if (!Number.isFinite(base) || base <= 0) return null;
|
||||
const ratio = Number(difference) / base;
|
||||
if (!Number.isFinite(ratio)) return null;
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
signDisplay: "exceptZero",
|
||||
}).format(ratio);
|
||||
}
|
||||
|
||||
export {
|
||||
type AccountIncomePageViewModel,
|
||||
type IncomeDisplayModel,
|
||||
toIncomeDisplayModel,
|
||||
useAccountIncomePageViewModel,
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
|
||||
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
|
||||
|
||||
const AccountIncomePageError: FC<ErrorProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("income.errorTitle")}
|
||||
text={t("income.errorText")}
|
||||
action={<Button onClick={props.retry}>{t("income.retry")}</Button>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountIncomePageError };
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
import { IncomeComparisons } from "./IncomeComparisons.view";
|
||||
import { IncomePayments } from "./IncomePayments.view";
|
||||
import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
|
||||
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
|
||||
|
||||
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
const {
|
||||
analytics,
|
||||
display,
|
||||
form,
|
||||
refreshing,
|
||||
refreshError,
|
||||
retry,
|
||||
onSubmit,
|
||||
onReset,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap="lg" className={classes.page}>
|
||||
<Title order={1}>{t("income.title")}</Title>
|
||||
<FormProvider {...form}>
|
||||
<IncomePeriodForm
|
||||
key={props.formRevision}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
onReset={onReset}
|
||||
/>
|
||||
</FormProvider>
|
||||
{refreshing ? (
|
||||
<Group gap="xs" role="status" aria-label={t("income.refreshing")}>
|
||||
<Loader size="sm" />
|
||||
<Text>{t("income.refreshing")}</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{refreshError ? (
|
||||
<Alert color="orange">
|
||||
<Stack gap="sm">
|
||||
<Text>{t("income.refreshError")}</Text>
|
||||
<Button onClick={retry} variant="default">
|
||||
{t("income.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
{analytics.totalsByCurrency.length ? (
|
||||
<IncomeSummary totals={analytics.totalsByCurrency} />
|
||||
) : (
|
||||
<StateView
|
||||
title={t("income.emptyTitle")}
|
||||
text={t("income.emptyText")}
|
||||
/>
|
||||
)}
|
||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||
<IncomeComparisons comparison={display.comparison} />
|
||||
<IncomePayments entries={display.entries} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountIncomePageLoaded };
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { Paper, Skeleton, Stack } from "@mantine/core";
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountIncomePageLoading };
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
import type { IncomeDisplayModel } from "../AccountIncomePage.vm";
|
||||
|
||||
type IncomeComparisonsProps = { comparison: IncomeDisplayModel["comparison"] };
|
||||
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]>;
|
||||
type ComparisonValuesProps = {
|
||||
kind: "selected" | "compared" | "difference";
|
||||
label: string;
|
||||
values: ComparisonProps["values"][number]["selected"];
|
||||
percentages?: ComparisonProps["values"][number]["percentages"];
|
||||
};
|
||||
|
||||
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t("income.yearAgo")}</Title>
|
||||
{props.comparison ? (
|
||||
<Comparison {...props.comparison} />
|
||||
) : (
|
||||
<Paper p="sm" radius="md" className={classes.comparisonNotice}>
|
||||
<Text size="sm" c="gray.7">
|
||||
{t("income.yearUnavailable")}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const Comparison: FC<ComparisonProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section aria-label={t("income.yearAgo")} className={classes.comparison}>
|
||||
<Stack gap="md">
|
||||
{props.values.map((value) => (
|
||||
<Stack
|
||||
gap="xs"
|
||||
key={value.currency}
|
||||
role="group"
|
||||
aria-label={value.currency}
|
||||
>
|
||||
<div className={classes.comparisonGrid}>
|
||||
<ComparisonValues
|
||||
kind="selected"
|
||||
label={props.selectedPeriod}
|
||||
values={value.selected}
|
||||
/>
|
||||
<ComparisonValues
|
||||
kind="compared"
|
||||
label={props.comparedPeriod}
|
||||
values={value.compared}
|
||||
/>
|
||||
<ComparisonValues
|
||||
kind="difference"
|
||||
label={t("income.difference")}
|
||||
values={value.difference}
|
||||
percentages={value.percentages}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
))}
|
||||
{props.values.length === 0 ? (
|
||||
<Text size="sm" c="gray.7">
|
||||
{t("income.comparisonEmpty")}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section
|
||||
aria-label={props.label}
|
||||
className={classes.comparisonPanel}
|
||||
data-kind={props.kind}
|
||||
data-tone={
|
||||
props.kind === "difference"
|
||||
? differenceTone(props.values.total.value)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Text size="sm" fw={600} className={classes.comparisonLabel}>
|
||||
{props.label}
|
||||
</Text>
|
||||
<dl className={classes.comparisonValues}>
|
||||
{(["total", "coupon", "dividend"] as const).map((key) => {
|
||||
const money = props.values[key];
|
||||
const percentage = props.percentages?.[key];
|
||||
const tone =
|
||||
props.kind === "difference"
|
||||
? differenceTone(money.value)
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={
|
||||
key === "total"
|
||||
? classes.comparisonTotal
|
||||
: classes.comparisonDetail
|
||||
}
|
||||
>
|
||||
<dt>{t(`income.${key}`)}</dt>
|
||||
<dd data-tone={tone}>
|
||||
<span>
|
||||
{tone === "positive" ? "+" : ""}
|
||||
{formatMoney(money)}
|
||||
</span>
|
||||
{percentage ? (
|
||||
<span className={classes.comparisonPercent}>
|
||||
{percentage}
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
function differenceTone(value: string): "positive" | "negative" | "neutral" {
|
||||
if (!/[1-9]/.test(value)) return "neutral";
|
||||
return value.startsWith("-") ? "negative" : "positive";
|
||||
}
|
||||
|
||||
export { IncomeComparisons };
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
|
||||
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
|
||||
type IncomePaymentsProps = { entries: LoadedProps["display"]["entries"] };
|
||||
|
||||
const IncomePayments: FC<IncomePaymentsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack component="section" aria-label={t("income.payments")}>
|
||||
<Title order={2}>{t("income.payments")}</Title>
|
||||
<Stack component="ul" gap="sm" className={classes.entries}>
|
||||
{props.entries.map(({ key, operation }) => (
|
||||
<li key={key}>
|
||||
<LedgerEntryRow operation={operation} />
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { IncomePayments };
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { FC } from "react";
|
||||
import { FormProvider, useForm, useFormState } from "react-hook-form";
|
||||
import type { IncomeForm } from "../../../../model/period";
|
||||
import { IncomePeriodForm } from "./IncomePeriodForm.view";
|
||||
|
||||
const period = { from: "01.08.2026", to: "27.08.2026" };
|
||||
const errors = {
|
||||
to: { type: "manual", message: "Синтетическая ошибка периода" },
|
||||
};
|
||||
|
||||
type PeriodExampleProps = { invalid?: boolean };
|
||||
|
||||
const PeriodExample: FC<PeriodExampleProps> = (props) => {
|
||||
const methods = useForm<IncomeForm>({
|
||||
defaultValues: period,
|
||||
errors: props.invalid ? errors : undefined,
|
||||
});
|
||||
const { isSubmitSuccessful } = useFormState({ control: methods.control });
|
||||
const submitted = isSubmitSuccessful ? methods.getValues() : null;
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<IncomePeriodForm
|
||||
onSubmit={methods.handleSubmit(() => undefined)}
|
||||
onReset={() => {
|
||||
methods.reset(period);
|
||||
}}
|
||||
/>
|
||||
<output aria-live="polite">
|
||||
{submitted
|
||||
? `Применено: ${submitted.from} – ${submitted.to}`
|
||||
: "Период не применён"}
|
||||
</output>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Product/IncomePeriodForm",
|
||||
component: PeriodExample,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Изолированная форма с синтетическим периодом. Submit показывает значения локально; reset возвращает fixture. Ошибка задана явно: query, URL и production validation остаются у page VM, API и router providers не используются.",
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => <PeriodExample />,
|
||||
} satisfies Meta<typeof PeriodExample>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Default: Story = {};
|
||||
export const ValidationError: Story = {
|
||||
render: () => <PeriodExample invalid />,
|
||||
};
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { Button, Paper, Stack, Text } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import type { FC, FormEventHandler } 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";
|
||||
|
||||
type IncomePeriodFormProps = {
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
} = useFormContext<IncomeForm>();
|
||||
const fromField = useController({ control, name: "from" });
|
||||
const toField = useController({ control, name: "to" });
|
||||
const [from, to] = useWatch({ control, name: ["from", "to"] });
|
||||
const today = moscowDate(new Date());
|
||||
const start = parseDraftDate(from);
|
||||
const end = parseDraftDate(to);
|
||||
return (
|
||||
<Paper
|
||||
component="form"
|
||||
onSubmit={props.onSubmit}
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fw={700}>{t("income.period")}</Text>
|
||||
<div className={classes.fields}>
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
allowSingleDateInRange
|
||||
clearable
|
||||
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
|
||||
ref={fromField.field.ref}
|
||||
label={t("income.period")}
|
||||
valueFormat="DD.MM.YYYY"
|
||||
value={[start, end]}
|
||||
onChange={([from, to]) => {
|
||||
fromField.field.onChange(from ? formatDraftDate(from) : "");
|
||||
toField.field.onChange(to ? formatDraftDate(to) : "");
|
||||
}}
|
||||
onBlur={() => {
|
||||
fromField.field.onBlur();
|
||||
toField.field.onBlur();
|
||||
}}
|
||||
maxDate={today}
|
||||
error={
|
||||
fromField.fieldState.error?.message ??
|
||||
toField.fieldState.error?.message
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
popoverProps={{
|
||||
returnFocus: true,
|
||||
middlewares: { shift: { crossAxis: true } },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.actions}>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{t("income.apply")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={props.onReset}
|
||||
disabled={isSubmitting}
|
||||
variant="default"
|
||||
>
|
||||
{t("income.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { IncomePeriodForm };
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { IncomeAnalytics } from "@/entities/income";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
type 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)}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { IncomeSummary };
|
||||
Reference in New Issue
Block a user