chore: finish frontend ESLint migration #18
Enable every plugin recommended preset and replace Biome with ESLint formatting. Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { resource } from "@/test/resource";
|
||||
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";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
@@ -11,8 +13,7 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { FC } from "react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import {
|
||||
MemoryRouter,
|
||||
Route,
|
||||
@@ -21,10 +22,14 @@ import {
|
||||
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 { createTestQueryClient } from "@/test/query";
|
||||
import { resource } from "@/test/resource";
|
||||
import { server } from "@/test/server";
|
||||
|
||||
import { isDate } from "../../model/period";
|
||||
import { AccountIncomePage } from "./AccountIncomePage";
|
||||
import {
|
||||
@@ -33,16 +38,9 @@ import {
|
||||
} from "./AccountIncomePage.vm";
|
||||
import { IncomePayments } from "./components/IncomePayments.view";
|
||||
|
||||
const analytics = {
|
||||
const analytics = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
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: [
|
||||
{
|
||||
@@ -50,113 +48,124 @@ const analytics = {
|
||||
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,
|
||||
},
|
||||
],
|
||||
calculatedAt: "2026-08-27T00:00:00Z",
|
||||
entries: [
|
||||
entry(),
|
||||
{
|
||||
...entry(),
|
||||
id: "dividend-1",
|
||||
type: "OPERATION_TYPE_DIVIDEND",
|
||||
payment: money("RUB", "2"),
|
||||
type: "OPERATION_TYPE_DIVIDEND",
|
||||
},
|
||||
],
|
||||
} satisfies IncomeAnalytics;
|
||||
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;
|
||||
|
||||
function comparison(currency: string) {
|
||||
return {
|
||||
compared: summary(currency, "1", "0"),
|
||||
currency,
|
||||
difference: summary(currency, "9", "2"),
|
||||
selected: summary(currency, "10", "2"),
|
||||
};
|
||||
}
|
||||
|
||||
function entry(): IncomeAnalytics["entries"][number] {
|
||||
return (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
accruedInterest: null,
|
||||
category: "income",
|
||||
commission: null,
|
||||
description: "Купон",
|
||||
figi: "FIGI",
|
||||
id: "coupon-1",
|
||||
instrumentKey: "income-group-opaque-v2",
|
||||
instrumentType: "bond",
|
||||
instrumentUid: null,
|
||||
logoName: null,
|
||||
occurredAt: "2026-08-01T12:00:00Z",
|
||||
payment: money("RUB", "10"),
|
||||
price: null,
|
||||
quantity: null,
|
||||
ticker: "BOND",
|
||||
type: "OPERATION_TYPE_COUPON",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
}
|
||||
|
||||
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(),
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
|
||||
const result = render(
|
||||
const queryClient = createTestQueryClient();
|
||||
const view = render(
|
||||
<MantineProvider env="test">
|
||||
<DatesProvider settings={{ locale: "ru" }}>
|
||||
<QueryWrapper>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter
|
||||
initialEntries={[`/accounts/account-1/income?${search}`]}
|
||||
>
|
||||
<LocationProbe />
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/income"
|
||||
element={<AccountIncomePage />}
|
||||
path="/accounts/:accountId/income"
|
||||
/>
|
||||
<Route
|
||||
path="/accounts/:accountId/events"
|
||||
element={<h1>Events</h1>}
|
||||
path="/accounts/:accountId/events"
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryWrapper>
|
||||
</QueryClientProvider>
|
||||
</DatesProvider>
|
||||
</MantineProvider>,
|
||||
);
|
||||
return { ...result, queryClient };
|
||||
return { ...view, queryClient };
|
||||
}
|
||||
|
||||
function summary(currency: string, coupon: string, dividend: string) {
|
||||
return {
|
||||
coupon: money(currency, coupon),
|
||||
currency,
|
||||
dividend: money(currency, dividend),
|
||||
total: money(currency, String(Number(coupon) + Number(dividend))),
|
||||
};
|
||||
}
|
||||
|
||||
describe("AccountIncomePage", () => {
|
||||
it("keeps reads and sync disabled without an account scope", async () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
const requests: string[] = [];
|
||||
server.use(
|
||||
@@ -165,17 +174,17 @@ describe("AccountIncomePage", () => {
|
||||
return new HttpResponse(null, { status: 503 });
|
||||
}),
|
||||
);
|
||||
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
|
||||
const queryClient = createTestQueryClient();
|
||||
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryWrapper>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
|
||||
{children}
|
||||
</MemoryRouter>
|
||||
</QueryWrapper>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
await act(async () => {});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(result.current).toEqual({ state: "loading" });
|
||||
expect(queryClient.isFetching()).toBe(0);
|
||||
expect(requests).toEqual([]);
|
||||
@@ -190,13 +199,15 @@ describe("AccountIncomePage", () => {
|
||||
({ request }) => {
|
||||
requested.push(new URL(request.url).search);
|
||||
return HttpResponse.json(
|
||||
resource(null, {
|
||||
sync: {
|
||||
id: null,
|
||||
state: "failed",
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
},
|
||||
}),
|
||||
resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
sync: {
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
id: null,
|
||||
state: "failed",
|
||||
},
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -216,9 +227,9 @@ describe("AccountIncomePage", () => {
|
||||
await waitFor(() =>
|
||||
expect(syncRequests).toEqual([
|
||||
{
|
||||
screen: "income",
|
||||
accountId: "account-1",
|
||||
from: "2024-02-29",
|
||||
screen: "income",
|
||||
to: "2024-03-01",
|
||||
},
|
||||
]),
|
||||
@@ -243,32 +254,32 @@ describe("AccountIncomePage", () => {
|
||||
});
|
||||
|
||||
it("preserves every repeated-ID payment when an open card updates", async () => {
|
||||
const first = { ...entry(), id: "same-id", description: "Выплата A" };
|
||||
const first = { ...entry(), description: "Выплата A", id: "same-id" };
|
||||
const second = {
|
||||
...entry(),
|
||||
id: "same-id",
|
||||
description: "Выплата B",
|
||||
id: "same-id",
|
||||
payment: money("RUB", "-2"),
|
||||
};
|
||||
const third = {
|
||||
...entry(),
|
||||
id: "other-id",
|
||||
description: "Выплата C",
|
||||
id: "other-id",
|
||||
payment: money("RUB", "1"),
|
||||
};
|
||||
function breakdown(entries: IncomeAnalytics["entries"], total: string) {
|
||||
const display = toIncomeDisplayModel({
|
||||
...analytics,
|
||||
entries,
|
||||
instruments: [
|
||||
{
|
||||
...analytics.instruments[0],
|
||||
coupon: money("RUB", total),
|
||||
dividend: money("RUB", "0"),
|
||||
total: money("RUB", total),
|
||||
receiptCount: entries.length,
|
||||
total: money("RUB", total),
|
||||
},
|
||||
],
|
||||
entries,
|
||||
});
|
||||
expect(display).not.toBeNull();
|
||||
return (
|
||||
@@ -282,7 +293,7 @@ describe("AccountIncomePage", () => {
|
||||
const payments = () =>
|
||||
within(panel)
|
||||
.getAllByText(/^Выплата [ABC]$/)
|
||||
.map((element) => element.textContent);
|
||||
.map(element => element.textContent);
|
||||
expect(payments()).toEqual(["Выплата A", "Выплата B", "Выплата C"]);
|
||||
|
||||
rerender(breakdown([third, first, second], "9"));
|
||||
@@ -380,10 +391,10 @@ describe("AccountIncomePage", () => {
|
||||
HttpResponse.json(
|
||||
resource({
|
||||
...analytics,
|
||||
totalsByCurrency: [],
|
||||
buckets: [],
|
||||
instruments: [],
|
||||
entries: [],
|
||||
instruments: [],
|
||||
totalsByCurrency: [],
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -398,33 +409,35 @@ describe("AccountIncomePage", () => {
|
||||
});
|
||||
|
||||
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: "Отрицательная корректировка",
|
||||
},
|
||||
],
|
||||
};
|
||||
const response = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...analytics,
|
||||
entries: [
|
||||
{
|
||||
...analytics.entries[0],
|
||||
description: "Исходная выплата",
|
||||
figi: null,
|
||||
instrumentKey: "server-only-key",
|
||||
ticker: null,
|
||||
},
|
||||
{
|
||||
...analytics.entries[1],
|
||||
description: "Отрицательная корректировка",
|
||||
instrumentKey: "server-only-key",
|
||||
instrumentUid: "unrelated-uid",
|
||||
payment: money("RUB", "-2"),
|
||||
},
|
||||
],
|
||||
instruments: [
|
||||
{
|
||||
...analytics.instruments[0],
|
||||
dividend: money("RUB", "-2"),
|
||||
key: "server-only-key",
|
||||
total: money("RUB", "8"),
|
||||
},
|
||||
],
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(resource(response)),
|
||||
@@ -485,7 +498,7 @@ describe("AccountIncomePage", () => {
|
||||
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Годом ранее", level: 2 }),
|
||||
screen.getByRole("heading", { level: 2, name: "Годом ранее" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Сравнения")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("RUB", { exact: true })).not.toBeInTheDocument();
|
||||
@@ -501,6 +514,7 @@ describe("AccountIncomePage", () => {
|
||||
expect(within(difference).queryByText("нет базы")).not.toBeInTheDocument();
|
||||
expect(within(difference).getAllByText(/%$/)).toHaveLength(2);
|
||||
expect(
|
||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
within(difference).getByText("+2 ₽").closest("dd"),
|
||||
).toHaveTextContent(/^\+2 ₽$/);
|
||||
});
|
||||
@@ -509,11 +523,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", () =>
|
||||
@@ -556,12 +572,12 @@ describe("income comparison display", () => {
|
||||
["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],
|
||||
["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],
|
||||
])("formats change %s against base %s", (difference, base, expected) => {
|
||||
const value = comparison("RUB");
|
||||
value.difference.coupon = money("RUB", difference);
|
||||
@@ -574,7 +590,7 @@ describe("income comparison display", () => {
|
||||
},
|
||||
});
|
||||
const result = display?.comparison?.values[0];
|
||||
expect(result?.percentages.coupon?.replaceAll("\u00a0", " ") ?? null).toBe(
|
||||
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?.difference.coupon.value).toBe(difference);
|
||||
@@ -596,7 +612,7 @@ describe("income comparison display", () => {
|
||||
expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe(
|
||||
"01.12.2024 – 09.01.2025",
|
||||
);
|
||||
expect(display?.comparison?.values.map((value) => value.currency)).toEqual([
|
||||
expect(display?.comparison?.values.map(value => value.currency)).toEqual([
|
||||
"RUB",
|
||||
"USD",
|
||||
]);
|
||||
@@ -609,19 +625,13 @@ const LocationProbe: FC = () => {
|
||||
return (
|
||||
<>
|
||||
<output data-testid="location">{location.search}</output>
|
||||
<button type="button" onClick={() => navigate(-1)}>
|
||||
<button onClick={() => navigate(-1)} type="button">
|
||||
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(
|
||||
@@ -643,6 +653,12 @@ function respondWithRequestedPeriod(requests: string[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function selectPeriod() {
|
||||
fireEvent.click(screen.getByLabelText("Период"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^27 августа 2026$/i }));
|
||||
}
|
||||
|
||||
describe("income period and snapshot boundary", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
@@ -664,6 +680,7 @@ describe("income period and snapshot boundary", () => {
|
||||
fireEvent.submit(
|
||||
screen
|
||||
.getByRole("button", { name: "Применить" })
|
||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
.closest("form") as HTMLFormElement,
|
||||
);
|
||||
await waitFor(() => expect(requests).toHaveLength(2));
|
||||
@@ -735,10 +752,8 @@ describe("income period and snapshot boundary", () => {
|
||||
const { queryClient } = renderPage();
|
||||
await screen.findByLabelText("Период");
|
||||
selectPeriod();
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
||||
@@ -768,22 +783,24 @@ describe("income period and snapshot boundary", () => {
|
||||
HttpResponse.json(
|
||||
{
|
||||
id: "income",
|
||||
scope: { screen: "income", accountId: "account-1" },
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
scope: { accountId: "account-1", screen: "income" },
|
||||
},
|
||||
{ status: 202 },
|
||||
),
|
||||
),
|
||||
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||
HttpResponse.json({
|
||||
id: "income",
|
||||
scope: null,
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
state: "succeeded",
|
||||
errorCode: null,
|
||||
progress: { total: 1, completed: 1, discovering: false },
|
||||
resources: [resource(null, { stateVersion: "2" }).meta],
|
||||
}),
|
||||
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
errorCode: null,
|
||||
id: "income",
|
||||
now: "2026-09-25T00:00:00Z",
|
||||
progress: { completed: 1, discovering: false, total: 1 },
|
||||
resources: [resource(null, { stateVersion: "2" }).meta],
|
||||
scope: null,
|
||||
state: "succeeded",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(resource(analytics, { stateVersion: "2" })),
|
||||
@@ -804,10 +821,8 @@ describe("income period and snapshot boundary", () => {
|
||||
respondWithRequestedPeriod([]);
|
||||
renderPage();
|
||||
await screen.findByLabelText("Период");
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
||||
@@ -843,39 +858,39 @@ describe("income period and snapshot boundary", () => {
|
||||
const instruments = [
|
||||
{
|
||||
...analytics.instruments[0],
|
||||
key: "usd",
|
||||
total: money("USD", "10"),
|
||||
coupon: money("USD", "10"),
|
||||
dividend: money("USD", "0"),
|
||||
key: "usd",
|
||||
receiptCount: 1,
|
||||
total: money("USD", "10"),
|
||||
},
|
||||
{
|
||||
...analytics.instruments[0],
|
||||
key: "rub",
|
||||
total: money("RUB", "-2"),
|
||||
coupon: money("RUB", "-2"),
|
||||
dividend: money("RUB", "0"),
|
||||
key: "rub",
|
||||
receiptCount: 1,
|
||||
total: money("RUB", "-2"),
|
||||
},
|
||||
{
|
||||
...analytics.instruments[0],
|
||||
key: "rub-negative",
|
||||
displayName: "Другой инструмент",
|
||||
total: money("RUB", "-3"),
|
||||
coupon: money("RUB", "-3"),
|
||||
displayName: "Другой инструмент",
|
||||
dividend: money("RUB", "0"),
|
||||
key: "rub-negative",
|
||||
receiptCount: 1,
|
||||
total: money("RUB", "-3"),
|
||||
},
|
||||
];
|
||||
const display = toIncomeDisplayModel({
|
||||
...analytics,
|
||||
totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")],
|
||||
instruments,
|
||||
entries: instruments.map((instrument) => ({
|
||||
entries: instruments.map(instrument => ({
|
||||
...entry(),
|
||||
instrumentKey: instrument.key,
|
||||
payment: instrument.total,
|
||||
})),
|
||||
instruments,
|
||||
totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")],
|
||||
});
|
||||
render(
|
||||
<MantineProvider env="test">
|
||||
@@ -902,22 +917,26 @@ 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 = {
|
||||
...resource(null).meta,
|
||||
resourceKey: "opaque/year-looking-key",
|
||||
role: "previous" as const,
|
||||
sync: {
|
||||
id: null,
|
||||
state: "failed" as const,
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
},
|
||||
};
|
||||
const year = {
|
||||
...resource(null).meta,
|
||||
resourceKey: "opaque/previous-looking-key",
|
||||
role: "year" as const,
|
||||
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
|
||||
};
|
||||
const previous = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...resource(null).meta,
|
||||
resourceKey: "opaque/year-looking-key",
|
||||
role: "previous" as const,
|
||||
sync: {
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
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",
|
||||
}
|
||||
);
|
||||
for (const dependencies of [
|
||||
[selected, previous, year],
|
||||
[year, selected, previous],
|
||||
@@ -939,16 +958,20 @@ it.each([false, true])(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
{ ...analytics, previous: null, yearComparison: null },
|
||||
{
|
||||
dependencies: legacy
|
||||
? [
|
||||
resource(null).meta,
|
||||
resource(null).meta,
|
||||
resource(null).meta,
|
||||
]
|
||||
: [],
|
||||
},
|
||||
(/* 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,
|
||||
]
|
||||
: [],
|
||||
}
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -969,16 +992,20 @@ 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) => ({
|
||||
...resource(role === "selected" ? analytics : null).meta,
|
||||
role,
|
||||
}),
|
||||
role => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...resource(role === "selected" ? analytics : null).meta,
|
||||
role,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
);
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
{ ...analytics, previous: null, yearComparison: null },
|
||||
(/* 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 },
|
||||
),
|
||||
),
|
||||
@@ -1005,15 +1032,17 @@ 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 },
|
||||
{
|
||||
...resource(null).meta,
|
||||
role: "year" as const,
|
||||
sync: {
|
||||
id: null,
|
||||
state: "failed" as const,
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
},
|
||||
},
|
||||
(/* 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(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm";
|
||||
import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
|
||||
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
|
||||
@@ -9,9 +10,7 @@ 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} />;
|
||||
return viewModel.state === "error" ? <AccountIncomePageError {...viewModel} /> : <AccountIncomePageLoaded {...viewModel} />;
|
||||
};
|
||||
|
||||
export { AccountIncomePage };
|
||||
|
||||
@@ -3,70 +3,73 @@ 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 {
|
||||
formatDraftDate,
|
||||
type IncomeForm,
|
||||
type IncomePeriod,
|
||||
isDate,
|
||||
ISO_DATE_LENGTH,
|
||||
moscowDate,
|
||||
parseDraftDate,
|
||||
} from "../../model/period";
|
||||
|
||||
const ISO_MONTH_LENGTH = 7;
|
||||
|
||||
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
|
||||
type ComparisonNotice =
|
||||
| "comparisonPending"
|
||||
| "comparisonFailed"
|
||||
| "previousUnavailable"
|
||||
| "yearUnavailable";
|
||||
type AccountIncomePageViewModel
|
||||
= | {
|
||||
analytics: IncomeAnalytics;
|
||||
display: IncomeDisplayModel;
|
||||
form: ReturnType<typeof useForm<IncomeForm>>;
|
||||
formRevision: string;
|
||||
lastSuccessfulAt?: null | string;
|
||||
onReset: () => void;
|
||||
onSubmit: (values: IncomeForm) => void;
|
||||
partial?: boolean;
|
||||
refreshError: boolean;
|
||||
refreshing: boolean;
|
||||
retry: () => Promise<unknown> | undefined;
|
||||
sourceError?: boolean;
|
||||
state: "loaded";
|
||||
}
|
||||
| {
|
||||
period: IncomePeriod;
|
||||
retry: () => Promise<unknown> | undefined;
|
||||
state: "error";
|
||||
}
|
||||
| { state: "loading" };
|
||||
type ComparisonNotice
|
||||
= | "comparisonFailed"
|
||||
| "comparisonPending"
|
||||
| "previousUnavailable"
|
||||
| "yearUnavailable";
|
||||
|
||||
type IncomeDisplayModel = {
|
||||
previousComparison: IncomeDisplayModel["comparison"];
|
||||
previousNotice: ComparisonNotice;
|
||||
yearNotice: ComparisonNotice;
|
||||
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
|
||||
comparison: {
|
||||
selectedPeriod: string;
|
||||
comparison: null | {
|
||||
comparedPeriod: string;
|
||||
selectedPeriod: string;
|
||||
values: (NonNullable<
|
||||
IncomeAnalytics["yearComparison"]
|
||||
>["totalsByCurrency"][number] & {
|
||||
percentages: Record<"total" | "coupon" | "dividend", string | null>;
|
||||
percentages: Record<"coupon" | "dividend" | "total", null | string>;
|
||||
})[];
|
||||
} | null;
|
||||
};
|
||||
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
|
||||
previousComparison: IncomeDisplayModel["comparison"];
|
||||
previousNotice: ComparisonNotice;
|
||||
yearNotice: ComparisonNotice;
|
||||
};
|
||||
|
||||
type AccountIncomePageViewModel =
|
||||
| { state: "loading" }
|
||||
| {
|
||||
state: "error";
|
||||
retry: () => undefined | Promise<unknown>;
|
||||
period: IncomePeriod;
|
||||
}
|
||||
| {
|
||||
state: "loaded";
|
||||
analytics: IncomeAnalytics;
|
||||
display: IncomeDisplayModel;
|
||||
formRevision: string;
|
||||
sourceError?: boolean;
|
||||
lastSuccessfulAt?: string | null;
|
||||
partial?: boolean;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry: () => undefined | Promise<unknown>;
|
||||
form: ReturnType<typeof useForm<IncomeForm>>;
|
||||
onReset: () => void;
|
||||
onSubmit: (values: IncomeForm) => void;
|
||||
};
|
||||
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
|
||||
|
||||
const draftDate = z
|
||||
.string()
|
||||
.refine((value) => parseDraftDate(value) !== null, {
|
||||
.refine(value => parseDraftDate(value) !== null, {
|
||||
error: validationMessage("income.validation.date"),
|
||||
})
|
||||
.refine(
|
||||
@@ -90,16 +93,16 @@ const useAccountIncomePageViewModel = (
|
||||
): AccountIncomePageViewModel => {
|
||||
const { accountId: routeAccountId } = useParams();
|
||||
const accountId = scopeId ?? routeAccountId ?? "";
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||
const requested = useMemo(
|
||||
() => ({
|
||||
from: searchParams.get("from") ?? "",
|
||||
to: searchParams.get("to") ?? "",
|
||||
from: searchParameters.get("from") ?? "",
|
||||
to: searchParameters.get("to") ?? "",
|
||||
}),
|
||||
[searchParams],
|
||||
[searchParameters],
|
||||
);
|
||||
const period = useMemo(
|
||||
() => (validPeriod(requested) ? requested : defaultPeriod()),
|
||||
() => (isValidPeriod(requested) ? requested : defaultPeriod()),
|
||||
[requested],
|
||||
);
|
||||
const form = useForm<IncomeForm>({
|
||||
@@ -109,60 +112,94 @@ const useAccountIncomePageViewModel = (
|
||||
const [resetRevision, setResetRevision] = useState(0);
|
||||
const analytics = useIncomeAnalytics(accountId, period);
|
||||
const sync = useScreenSync(
|
||||
{ screen: "income", accountId, from: period.from, to: period.to },
|
||||
{ accountId, from: period.from, screen: "income", to: period.to },
|
||||
[analytics],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!validPeriod(requested)) setSearchParams(period, { replace: true });
|
||||
}, [period, requested, setSearchParams]);
|
||||
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" };
|
||||
}
|
||||
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
|
||||
if (!display)
|
||||
return {
|
||||
period,
|
||||
retry: () => analytics.observe(),
|
||||
state: "error",
|
||||
};
|
||||
const onSubmit = (values: IncomeForm) => {
|
||||
const from = parseDraftDate(values.from);
|
||||
const to = parseDraftDate(values.to);
|
||||
if (from && to) setSearchParams({ from, to });
|
||||
if (from && to) setSearchParameters({ from, to });
|
||||
};
|
||||
const onReset = () => {
|
||||
const next = defaultPeriod();
|
||||
form.reset(draftPeriod(next));
|
||||
// Reset the open calendar along with the form.
|
||||
setResetRevision((revision) => revision + 1);
|
||||
setSearchParams(next, { replace: true });
|
||||
setResetRevision(revision => revision + 1);
|
||||
setSearchParameters(next, { replace: true });
|
||||
};
|
||||
if (analytics.isLoading || !analytics.data) {
|
||||
if (analytics.isError || analytics.meta?.sync.state === "failed")
|
||||
return { state: "error", retry: sync.retry, period };
|
||||
return { state: "loading" };
|
||||
}
|
||||
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
|
||||
if (!display)
|
||||
return {
|
||||
state: "error",
|
||||
retry: () => analytics.observe(),
|
||||
period,
|
||||
};
|
||||
return {
|
||||
state: "loaded",
|
||||
analytics: analytics.data,
|
||||
display,
|
||||
formRevision: `${period.from}:${period.to}:${resetRevision}`,
|
||||
sourceError: sync.sourceError,
|
||||
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
|
||||
partial: analytics.meta?.availability !== "complete",
|
||||
refreshing: analytics.isFetching || sync.refreshing,
|
||||
refreshError: analytics.isError || sync.sourceError || sync.transportError,
|
||||
retry: sync.retry,
|
||||
form,
|
||||
onSubmit,
|
||||
formRevision: `${period.from}:${period.to}:${resetRevision}`,
|
||||
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
|
||||
onReset,
|
||||
onSubmit,
|
||||
partial: analytics.meta?.availability !== "complete",
|
||||
refreshError: analytics.isError || sync.sourceError || sync.transportError,
|
||||
refreshing: analytics.isFetching || sync.refreshing,
|
||||
retry: sync.retry,
|
||||
sourceError: sync.sourceError,
|
||||
state: "loaded",
|
||||
};
|
||||
};
|
||||
|
||||
function draftPeriod(period: IncomePeriod): IncomeForm {
|
||||
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
|
||||
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;
|
||||
return {
|
||||
comparedPeriod: formatComparisonPeriod(comparison.period),
|
||||
selectedPeriod: formatComparisonPeriod(period),
|
||||
values: comparison.totalsByCurrency.map(value => ({
|
||||
...value,
|
||||
percentages: {
|
||||
coupon: formatIncomeChange(
|
||||
value.difference.coupon.value,
|
||||
value.compared.coupon.value,
|
||||
),
|
||||
dividend: formatIncomeChange(
|
||||
value.difference.dividend.value,
|
||||
value.compared.dividend.value,
|
||||
),
|
||||
total: formatIncomeChange(
|
||||
value.difference.total.value,
|
||||
value.compared.total.value,
|
||||
),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function comparisonNotice(
|
||||
dependency:
|
||||
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
|
||||
| undefined,
|
||||
unavailable: "previousUnavailable" | "yearUnavailable",
|
||||
): ComparisonNotice {
|
||||
if (!dependency || dependency.unavailableReason) return unavailable;
|
||||
return dependency.sync.state === "failed"
|
||||
? "comparisonFailed"
|
||||
: "comparisonPending";
|
||||
}
|
||||
|
||||
function defaultPeriod(): IncomePeriod {
|
||||
@@ -170,16 +207,40 @@ function defaultPeriod(): IncomePeriod {
|
||||
return { from: `${today.slice(0, ISO_MONTH_LENGTH)}-01`, to: today };
|
||||
}
|
||||
|
||||
function validPeriod(period: IncomePeriod): period is IncomePeriod {
|
||||
function draftPeriod(period: IncomePeriod): IncomeForm {
|
||||
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
|
||||
}
|
||||
|
||||
function formatComparisonPeriod(period: IncomePeriod): string {
|
||||
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
|
||||
}
|
||||
|
||||
function formatIncomeChange(
|
||||
difference: string,
|
||||
compared: string,
|
||||
): 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;
|
||||
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;
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
signDisplay: "exceptZero",
|
||||
style: "percent",
|
||||
}).format(ratio);
|
||||
}
|
||||
|
||||
function isValidPeriod(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
|
||||
from.toISOString().slice(0, ISO_DATE_LENGTH) === period.from
|
||||
&& to.toISOString().slice(0, ISO_DATE_LENGTH) === period.to
|
||||
&& from <= to
|
||||
&& to <= today
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,8 +252,8 @@ function toIncomeDisplayModel(
|
||||
string,
|
||||
{
|
||||
currency: string;
|
||||
receiptCount: number;
|
||||
includedCount: number;
|
||||
receiptCount: number;
|
||||
}
|
||||
>();
|
||||
const currencies = new Set(
|
||||
@@ -200,25 +261,28 @@ function toIncomeDisplayModel(
|
||||
);
|
||||
for (const instrument of analytics.instruments) {
|
||||
if (
|
||||
typeof instrument.key !== "string" ||
|
||||
!instrument.key.trim() ||
|
||||
instruments.has(instrument.key) ||
|
||||
!currencies.has(instrument.total.currency)
|
||||
typeof instrument.key !== "string"
|
||||
|| !instrument.key.trim()
|
||||
|| instruments.has(instrument.key)
|
||||
|| !currencies.has(instrument.total.currency)
|
||||
)
|
||||
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
|
||||
return null;
|
||||
instruments.set(instrument.key, {
|
||||
currency: instrument.total.currency,
|
||||
receiptCount: instrument.receiptCount,
|
||||
includedCount: 0,
|
||||
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.
|
||||
return null;
|
||||
const instrument = instruments.get(entry.instrumentKey);
|
||||
if (!instrument || entry.payment?.currency !== instrument.currency)
|
||||
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
|
||||
return null;
|
||||
const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]);
|
||||
const occurrence = occurrences.get(occurrenceKey) ?? 0;
|
||||
@@ -235,80 +299,20 @@ function toIncomeDisplayModel(
|
||||
});
|
||||
}
|
||||
for (const instrument of instruments.values()) {
|
||||
if (instrument.includedCount !== instrument.receiptCount) return null;
|
||||
if (instrument.includedCount !== instrument.receiptCount) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ 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,
|
||||
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
|
||||
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
|
||||
previousNotice: comparisonNotice(previous, "previousUnavailable"),
|
||||
yearNotice: comparisonNotice(year, "yearUnavailable"),
|
||||
};
|
||||
}
|
||||
|
||||
function comparisonNotice(
|
||||
dependency:
|
||||
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
|
||||
| undefined,
|
||||
unavailable: "previousUnavailable" | "yearUnavailable",
|
||||
): ComparisonNotice {
|
||||
if (!dependency || dependency.unavailableReason) return unavailable;
|
||||
return dependency.sync.state === "failed"
|
||||
? "comparisonFailed"
|
||||
: "comparisonPending";
|
||||
}
|
||||
|
||||
function comparisonDisplay(
|
||||
period: IncomePeriod,
|
||||
comparison: IncomeAnalytics["yearComparison"],
|
||||
): IncomeDisplayModel["comparison"] {
|
||||
if (!comparison) return null;
|
||||
return {
|
||||
selectedPeriod: formatComparisonPeriod(period),
|
||||
comparedPeriod: formatComparisonPeriod(comparison.period),
|
||||
values: comparison.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,
|
||||
),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
+6
-4
@@ -1,7 +1,9 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
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" }>;
|
||||
@@ -10,10 +12,10 @@ 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>}
|
||||
error
|
||||
text={t("income.errorText")}
|
||||
title={t("income.errorTitle")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+39
-30
@@ -1,3 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -8,12 +10,14 @@ import {
|
||||
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 { formatSnapshotTime } from "@/shared/lib";
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
import { IncomeComparisons } from "./IncomeComparisons.view";
|
||||
import { IncomePayments } from "./IncomePayments.view";
|
||||
import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view";
|
||||
@@ -26,41 +30,55 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
analytics,
|
||||
display,
|
||||
form,
|
||||
refreshing,
|
||||
refreshError,
|
||||
retry,
|
||||
onSubmit,
|
||||
onReset,
|
||||
onSubmit,
|
||||
refreshError,
|
||||
refreshing,
|
||||
retry,
|
||||
} = 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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap="lg" className={classes.page}>
|
||||
{props.lastSuccessfulAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
<Stack className={classes.page} gap="lg">
|
||||
{!!(props.lastSuccessfulAt) && ((
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
))}
|
||||
<Title order={1}>{t("income.title")}</Title>
|
||||
<Button onClick={retry} disabled={refreshing} w="fit-content">
|
||||
<Button disabled={refreshing} onClick={retry} w="fit-content">
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
<FormProvider {...form}>
|
||||
<IncomePeriodForm
|
||||
key={props.formRevision}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
onReset={onReset}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
/>
|
||||
</FormProvider>
|
||||
{refreshing ? (
|
||||
<Group gap="xs" role="status" aria-label={t("income.refreshing")}>
|
||||
{!!(refreshing) && ((
|
||||
<Group aria-label={t("income.refreshing")} gap="xs" role="status">
|
||||
<Loader size="sm" />
|
||||
<Text>{t("income.refreshing")}</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{refreshError ? (
|
||||
<Alert color="orange" role="status" aria-live="polite">
|
||||
))}
|
||||
{!!(refreshError) && ((
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
<Stack gap="sm">
|
||||
<Text>
|
||||
{t(
|
||||
@@ -74,27 +92,18 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
{props.partial ? (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
) : analytics.totalsByCurrency.length ? (
|
||||
<IncomeSummary totals={analytics.totalsByCurrency} />
|
||||
) : (
|
||||
<StateView
|
||||
title={t("income.emptyTitle")}
|
||||
text={t("income.emptyText")}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
{renderSummary()}
|
||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||
<IncomeComparisons
|
||||
title={t("income.previousPeriod")}
|
||||
comparison={display.previousComparison}
|
||||
notice={t(`income.${display.previousNotice}`)}
|
||||
title={t("income.previousPeriod")}
|
||||
/>
|
||||
<IncomeComparisons
|
||||
title={t("income.yearAgo")}
|
||||
comparison={display.comparison}
|
||||
notice={t(`income.${display.yearNotice}`)}
|
||||
title={t("income.yearAgo")}
|
||||
/>
|
||||
<IncomePayments entries={display.entries} />
|
||||
</Stack>
|
||||
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
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 = () => {
|
||||
|
||||
+40
-36
@@ -1,38 +1,44 @@
|
||||
import { Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Paper, Stack, Text, Title } from "@mantine/core";
|
||||
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"];
|
||||
title: string;
|
||||
notice: string;
|
||||
};
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
|
||||
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]> & {
|
||||
title: string;
|
||||
};
|
||||
type ComparisonValuesProps = {
|
||||
kind: "selected" | "compared" | "difference";
|
||||
kind: "compared" | "difference" | "selected";
|
||||
label: string;
|
||||
values: ComparisonProps["values"][number]["selected"];
|
||||
percentages?: ComparisonProps["values"][number]["percentages"];
|
||||
values: ComparisonProps["values"][number]["selected"];
|
||||
};
|
||||
type IncomeComparisonsProps = {
|
||||
comparison: IncomeDisplayModel["comparison"];
|
||||
notice: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{props.title}</Title>
|
||||
{props.comparison ? (
|
||||
<Comparison {...props.comparison} title={props.title} />
|
||||
) : (
|
||||
<Paper p="sm" radius="md" className={classes.comparisonNotice}>
|
||||
<Text size="sm" c="gray.7" role="status" aria-live="polite">
|
||||
{props.notice}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
{props.comparison
|
||||
? (
|
||||
<Comparison {...props.comparison} title={props.title} />
|
||||
)
|
||||
: (
|
||||
<Paper className={classes.comparisonNotice} p="sm" radius="md">
|
||||
<Text aria-live="polite" c="gray.7" role="status" size="sm">
|
||||
{props.notice}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -42,12 +48,12 @@ const Comparison: FC<ComparisonProps> = (props) => {
|
||||
return (
|
||||
<section aria-label={props.title} className={classes.comparison}>
|
||||
<Stack gap="md">
|
||||
{props.values.map((value) => (
|
||||
{props.values.map(value => (
|
||||
<Stack
|
||||
aria-label={value.currency}
|
||||
gap="xs"
|
||||
key={value.currency}
|
||||
role="group"
|
||||
aria-label={value.currency}
|
||||
>
|
||||
<div className={classes.comparisonGrid}>
|
||||
<ComparisonValues
|
||||
@@ -63,17 +69,17 @@ const Comparison: FC<ComparisonProps> = (props) => {
|
||||
<ComparisonValues
|
||||
kind="difference"
|
||||
label={t("income.difference")}
|
||||
values={value.difference}
|
||||
percentages={value.percentages}
|
||||
values={value.difference}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
))}
|
||||
{props.values.length === 0 ? (
|
||||
<Text size="sm" c="gray.7">
|
||||
{props.values.length === 0 && ((
|
||||
<Text c="gray.7" size="sm">
|
||||
{t("income.comparisonEmpty")}
|
||||
</Text>
|
||||
) : null}
|
||||
))}
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
@@ -87,30 +93,28 @@ const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
|
||||
className={classes.comparisonPanel}
|
||||
data-kind={props.kind}
|
||||
data-tone={
|
||||
props.kind === "difference"
|
||||
? differenceTone(props.values.total.value)
|
||||
: undefined
|
||||
(props.kind === "difference") ? (differenceTone(props.values.total.value)) : undefined
|
||||
}
|
||||
>
|
||||
<Text size="sm" fw={600} className={classes.comparisonLabel}>
|
||||
<Text className={classes.comparisonLabel} fw={600} size="sm">
|
||||
{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"
|
||||
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 tone
|
||||
= props.kind === "difference"
|
||||
? differenceTone(money.value)
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={
|
||||
key === "total"
|
||||
? classes.comparisonTotal
|
||||
: classes.comparisonDetail
|
||||
}
|
||||
key={key}
|
||||
>
|
||||
<dt>{t(`income.${key}`)}</dt>
|
||||
<dd data-tone={tone}>
|
||||
@@ -118,11 +122,11 @@ const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
|
||||
{tone === "positive" ? "+" : ""}
|
||||
{formatMoney(money)}
|
||||
</span>
|
||||
{percentage ? (
|
||||
{!!(percentage) && ((
|
||||
<span className={classes.comparisonPercent}>
|
||||
{percentage}
|
||||
</span>
|
||||
) : null}
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
@@ -132,7 +136,7 @@ const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
function differenceTone(value: string): "positive" | "negative" | "neutral" {
|
||||
function differenceTone(value: string): "negative" | "neutral" | "positive" {
|
||||
if (!/[1-9]/.test(value)) return "neutral";
|
||||
return value.startsWith("-") ? "negative" : "positive";
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
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" }>;
|
||||
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 component="section" aria-label={t("income.payments")}>
|
||||
<Stack aria-label={t("income.payments")} component="section">
|
||||
<Title order={2}>{t("income.payments")}</Title>
|
||||
<Stack component="ul" gap="sm" className={classes.entries}>
|
||||
<Stack className={classes.entries} component="ul" gap="sm">
|
||||
{props.entries.map(({ key, operation }) => (
|
||||
<li key={key}>
|
||||
<LedgerEntryRow operation={operation} />
|
||||
|
||||
+8
-5
@@ -1,12 +1,15 @@
|
||||
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: "Синтетическая ошибка периода" },
|
||||
to: { message: "Синтетическая ошибка периода", type: "manual" },
|
||||
};
|
||||
|
||||
type PeriodExampleProps = { invalid?: boolean };
|
||||
@@ -17,14 +20,14 @@ const PeriodExample: FC<PeriodExampleProps> = (props) => {
|
||||
errors: props.invalid ? errors : undefined,
|
||||
});
|
||||
const { isSubmitSuccessful } = useFormState({ control: methods.control });
|
||||
const submitted = isSubmitSuccessful ? methods.getValues() : null;
|
||||
const submitted = isSubmitSuccessful ? methods.getValues() : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null;
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<IncomePeriodForm
|
||||
onSubmit={methods.handleSubmit(() => undefined)}
|
||||
onReset={() => {
|
||||
methods.reset(period);
|
||||
}}
|
||||
onSubmit={methods.handleSubmit(() => {})}
|
||||
/>
|
||||
<output aria-live="polite">
|
||||
{submitted
|
||||
@@ -36,9 +39,7 @@ const PeriodExample: FC<PeriodExampleProps> = (props) => {
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Product/IncomePeriodForm",
|
||||
component: PeriodExample,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
@@ -48,6 +49,8 @@ const meta = {
|
||||
},
|
||||
},
|
||||
render: () => <PeriodExample />,
|
||||
tags: ["autodocs"],
|
||||
title: "Product/IncomePeriodForm",
|
||||
} satisfies Meta<typeof PeriodExample>;
|
||||
|
||||
export default meta;
|
||||
|
||||
+26
-22
@@ -1,8 +1,11 @@
|
||||
import type { FC, SubmitEventHandler } from "react";
|
||||
|
||||
import { Button, Paper, Stack, Text } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import type { FC, FormEventHandler } from "react";
|
||||
import { useState } from "react";
|
||||
import { useController, useFormContext, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
formatDraftDate,
|
||||
type IncomeForm,
|
||||
@@ -12,8 +15,8 @@ import {
|
||||
import classes from "../../AccountIncomePage.module.css";
|
||||
|
||||
type IncomePeriodFormProps = {
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
onReset: () => void;
|
||||
onSubmit: SubmitEventHandler<HTMLFormElement>;
|
||||
};
|
||||
|
||||
const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
||||
@@ -23,15 +26,15 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
||||
formState: { isSubmitting },
|
||||
} = useFormContext<IncomeForm>();
|
||||
const {
|
||||
field: { ref: fromRef, onChange: onFromChange, onBlur: onFromBlur },
|
||||
field: { onBlur: onFromBlur, onChange: onFromChange, ref: fromRef },
|
||||
fieldState: { error: fromError },
|
||||
} = useController({ control, name: "from" });
|
||||
const {
|
||||
field: { onChange: onToChange, onBlur: onToBlur },
|
||||
field: { onBlur: onToBlur, onChange: onToChange },
|
||||
fieldState: { error: toError },
|
||||
} = useController({ control, name: "to" });
|
||||
const [from, to] = useWatch({ control, name: ["from", "to"] });
|
||||
const today = moscowDate(new Date());
|
||||
const [today, setToday] = useState(() => moscowDate(new Date()));
|
||||
const start = parseDraftDate(from);
|
||||
const end = parseDraftDate(to);
|
||||
return (
|
||||
@@ -46,39 +49,40 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
||||
<Text fw={700}>{t("income.period")}</Text>
|
||||
<div className={classes.fields}>
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
allowSingleDateInRange
|
||||
clearable
|
||||
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
|
||||
ref={fromRef}
|
||||
disabled={isSubmitting}
|
||||
error={fromError?.message ?? toError?.message}
|
||||
label={t("income.period")}
|
||||
valueFormat="DD.MM.YYYY"
|
||||
value={[start, end]}
|
||||
onChange={([from, to]) => {
|
||||
onFromChange(from ? formatDraftDate(from) : "");
|
||||
onToChange(to ? formatDraftDate(to) : "");
|
||||
}}
|
||||
maxDate={today}
|
||||
onBlur={() => {
|
||||
onFromBlur();
|
||||
onToBlur();
|
||||
}}
|
||||
maxDate={today}
|
||||
error={fromError?.message ?? toError?.message}
|
||||
disabled={isSubmitting}
|
||||
popoverProps={{
|
||||
returnFocus: true,
|
||||
middlewares: { shift: { crossAxis: true } },
|
||||
onChange={([from, to]) => {
|
||||
onFromChange(from ? formatDraftDate(from) : "");
|
||||
onToChange(to ? formatDraftDate(to) : "");
|
||||
}}
|
||||
popoverProps={{
|
||||
middlewares: { shift: { crossAxis: true } },
|
||||
onOpen: () => setToday(moscowDate(new Date())),
|
||||
returnFocus: true,
|
||||
}}
|
||||
ref={fromRef}
|
||||
type="range"
|
||||
value={[start, end]}
|
||||
valueFormat="DD.MM.YYYY"
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.actions}>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
<Button loading={isSubmitting} type="submit">
|
||||
{t("income.apply")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={props.onReset}
|
||||
disabled={isSubmitting}
|
||||
onClick={props.onReset}
|
||||
type="button"
|
||||
variant="default"
|
||||
>
|
||||
{t("income.reset")}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { IncomeAnalytics } from "@/entities/income";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
type IncomeSummaryProps = { totals: IncomeAnalytics["totalsByCurrency"] };
|
||||
@@ -11,12 +14,21 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t("income.summaryTitle")}</Title>
|
||||
{props.totals.map((total) => (
|
||||
{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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user