diff --git a/src/pages/account-income/ui/AccountIncomePage.module.css b/src/pages/account-income/ui/AccountIncomePage.module.css index dae1904..43d8209 100644 --- a/src/pages/account-income/ui/AccountIncomePage.module.css +++ b/src/pages/account-income/ui/AccountIncomePage.module.css @@ -1,51 +1,45 @@ -.trendChart { - align-items: stretch; +.page { + min-width: 0; + overflow-wrap: anywhere; +} + +.fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--mantine-spacing-sm); +} + +.actions { display: flex; - gap: var(--mantine-spacing-xs); - height: 160px; - overflow-x: auto; - padding: 0 var(--mantine-spacing-xs); + flex-wrap: wrap; + gap: var(--mantine-spacing-sm); } -.trendColumn { - flex: 1 0 32px; - min-width: 32px; - position: relative; +.entries { + list-style: none; + padding: 0; + margin: 0; + min-width: 0; } -.baseline { - background: var(--mantine-color-gray-4); - height: 1px; - left: 0; - position: absolute; - right: 0; - top: 50%; +/* 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; } -.positiveStack, -.negativeStack { - display: flex; - left: 25%; - position: absolute; - right: 25%; -} +@media (max-width: 35.999em) { + .fields { + grid-template-columns: minmax(0, 1fr); + } -.positiveStack { - bottom: 50%; - flex-direction: column-reverse; - top: 0; -} + .actions { + flex-direction: column; + } -.negativeStack { - bottom: 0; - flex-direction: column; - top: 50%; -} - -.coupon { - background: var(--mantine-color-blue-5); -} - -.dividend { - background: var(--mantine-color-grape-5); + .actions > button, + .fields input { + min-height: 44px; + } } diff --git a/src/pages/account-income/ui/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage.test.tsx index 65facc3..15aa57b 100644 --- a/src/pages/account-income/ui/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage.test.tsx @@ -1,5 +1,8 @@ +import "dayjs/locale/ru"; import { MantineProvider } from "@mantine/core"; +import { DatesProvider } from "@mantine/dates"; import { + act, fireEvent, render, screen, @@ -7,15 +10,22 @@ import { within, } from "@testing-library/react"; import { HttpResponse, http } from "msw"; -import { MemoryRouter, Route, Routes } from "react-router"; -import { describe, expect, it, vi } from "vitest"; +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 { AccountIncomePage } from "./AccountIncomePage"; import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm"; -import { IncomeBreakdown } from "./components/IncomeBreakdown.view"; +import { IncomePayments } from "./components/IncomePayments.view"; const analytics = { accountId: "account-1", @@ -108,25 +118,31 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") { refresh: async () => true, clear: vi.fn(), }); - const { wrapper: QueryWrapper } = createQueryWrapper(); - return render( - - - - - } - /> - Events} - /> - - - + const { wrapper: QueryWrapper, queryClient } = createQueryWrapper(); + const result = render( + + + + + + + } + /> + Events} + /> + + + + , ); + return { ...result, queryClient }; } describe("AccountIncomePage", () => { @@ -147,7 +163,7 @@ describe("AccountIncomePage", () => { ); }); - it("preserves every repeated-ID payment when an open drill-down updates", async () => { + it("preserves every repeated-ID payment when an open card updates", async () => { const first = { ...entry(), id: "same-id", description: "Выплата A" }; const second = { ...entry(), @@ -177,14 +193,13 @@ describe("AccountIncomePage", () => { }); expect(display).not.toBeNull(); return ( - - + + ); } const { rerender } = render(breakdown([first, second, third], "9")); - fireEvent.click(screen.getByRole("button", { name: "BOND · 9 ₽ · 3" })); - const panel = await screen.findByRole("region", { name: "BOND · 9 ₽ · 3" }); + const panel = await screen.findByRole("region", { name: "Выплаты" }); const payments = () => within(panel) .getAllByText(/^Выплата [ABC]$/) @@ -201,9 +216,7 @@ describe("AccountIncomePage", () => { rerender(breakdown([second, first], "8")); expect(payments()).toEqual(["Выплата B", "Выплата A"]); - expect( - screen.getByRole("button", { name: "BOND · 8 ₽ · 2" }), - ).toHaveAttribute("aria-expanded", "true"); + expect(within(panel).getAllByRole("listitem")).toHaveLength(2); }); it.each([ @@ -227,10 +240,14 @@ describe("AccountIncomePage", () => { ); renderPage(); const retry = await screen.findByRole("button", { name: "Повторить" }); - expect(screen.queryByText("BOND · 12 ₽ · 2")).not.toBeInTheDocument(); + expect( + screen.queryByRole("region", { name: "Выплаты" }), + ).not.toBeInTheDocument(); response = analytics; fireEvent.click(retry); - expect(await screen.findByText("BOND · 12 ₽ · 2")).toBeInTheDocument(); + expect( + await screen.findByRole("region", { name: "Выплаты" }), + ).toBeInTheDocument(); }); it.each([ @@ -273,7 +290,9 @@ describe("AccountIncomePage", () => { expect( await screen.findByRole("button", { name: "Повторить" }), ).toBeInTheDocument(); - expect(screen.queryByText("BOND · 12 ₽ · 2")).not.toBeInTheDocument(); + expect( + screen.queryByRole("region", { name: "Выплаты" }), + ).not.toBeInTheDocument(); }); it("accepts an empty analytics response", async () => { @@ -331,10 +350,7 @@ describe("AccountIncomePage", () => { ), ); renderPage(); - fireEvent.click( - await screen.findByRole("button", { name: "BOND · 8 ₽ · 2" }), - ); - const panel = await screen.findByRole("region", { name: "BOND · 8 ₽ · 2" }); + const panel = await screen.findByRole("region", { name: "Выплаты" }); expect(within(panel).getByText("Исходная выплата")).toBeInTheDocument(); expect( within(panel).getByText("Отрицательная корректировка"), @@ -348,39 +364,13 @@ describe("AccountIncomePage", () => { expect(isDate("2024-02-29")).toBe(true); }); - it("groups income entries once in the view model", () => { - const display = toIncomeDisplayModel(analytics); - - expect(display?.instruments[0].entries).toEqual(analytics.entries); - expect(display?.trend[0].buckets[0].total).toEqual( - analytics.buckets[0].totalsByCurrency[0], - ); - }); - - it("renders realized analytics, drill-down, and the independent forecast", async () => { + 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), ), - http.get("*/api/v1/brokerage-accounts/account-1/events", () => - HttpResponse.json({ - accountId: "account-1", - calculatedAt: "2026-08-27T00:00:00Z", - incomeTotalsByCurrency: [money("RUB", "3")], - events: [ - { - type: "coupon", - date: "2026-08-28", - dateKind: "event", - figi: "FIGI", - instrumentUid: null, - ticker: "BOND", - displayName: "BOND", - projectedAmount: money("RUB", "3"), - }, - ], - }), - ), + http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest), ); renderPage(); await waitFor(() => @@ -391,8 +381,10 @@ describe("AccountIncomePage", () => { expect( screen.getByText("Налоговые операции не включены в этот результат."), ).toBeInTheDocument(); - expect(screen.getByText("Ближайший прогноз")).toBeInTheDocument(); - expect(screen.getByText("BOND · 12 ₽ · 2")).toBeInTheDocument(); + expect(screen.queryByText("Ближайший прогноз")).not.toBeInTheDocument(); + expect(screen.queryByText("Динамика")).not.toBeInTheDocument(); + expect(eventsRequest).not.toHaveBeenCalled(); + expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument(); expect( screen.getByText( "Выбранный период: Купоны 10 ₽ · Дивиденды 2 ₽ · Итого 12 ₽", @@ -403,54 +395,6 @@ describe("AccountIncomePage", () => { ).toBeInTheDocument(); }); - it("renders an empty trend bucket as zero money", async () => { - server.use( - http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json({ - ...analytics, - buckets: [ - ...analytics.buckets, - { - period: { from: "2026-08-02", to: "2026-08-02" }, - totalsByCurrency: [], - }, - ], - }), - ), - ); - renderPage(); - await waitFor(() => - expect( - screen.getByText("2026-08-02 — 2026-08-02: Купоны 0 ₽, Дивиденды 0 ₽"), - ).toBeInTheDocument(), - ); - }); - - it("renders signed coupon and dividend segments on opposite baseline sides", async () => { - server.use( - http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json({ - ...analytics, - buckets: [ - { - period: { from: "2026-08-01", to: "2026-08-01" }, - totalsByCurrency: [summary("RUB", "-10", "2")], - }, - ], - }), - ), - ); - renderPage(); - await waitFor(() => { - expect( - screen.getByTestId("trend-negative-coupon-2026-08-01"), - ).toBeInTheDocument(); - expect( - screen.getByTestId("trend-positive-dividend-2026-08-01"), - ).toBeInTheDocument(); - }); - }); - it("shows a recoverable realized error", async () => { server.use( http.get( @@ -466,3 +410,291 @@ describe("AccountIncomePage", () => { ); }); }); + +const LocationProbe: FC = () => { + const location = useLocation(); + const navigate = useNavigate(); + return ( + <> + {location.search} + + + ); +}; + +function inputDate(label: string, value: string) { + const field = screen.getByLabelText(label); + fireEvent.focus(field); + fireEvent.input(field, { target: { value } }); + fireEvent.blur(field); + return field; +} + +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("С")).toHaveValue("01.08.2026"); + expect(screen.getByLabelText("По")).toHaveValue("27.08.2026"); + inputDate("С", "02.08.2026"); + 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("С")).toHaveValue("02.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("С")).toHaveValue("01.08.2026"), + ); + }); + + it.each([ + ["С", "", "Введите существующую дату"], + ["С", "31.02.2026", "Введите существующую дату"], + ["По", "29.02.2026", "Введите существующую дату"], + ["По", "2026-08-02", "Введите существующую дату"], + ["С", "01.01.2099", "Дата не может быть позже"], + ["По", "01.01.2099", "Дата не может быть позже"], + ["По", "31.07.2026", "Укажите корректный период"], + ])( + "rejects visible %s=%s without replacing data or URL", + async (label, value, error) => { + const requests: string[] = []; + respondWithRequestedPeriod(requests); + renderPage(); + await screen.findByLabelText("С"); + const field = inputDate(label, value); + fireEvent.click(screen.getByRole("button", { name: "Применить" })); + expect(await screen.findByText(new RegExp(error))).toBeInTheDocument(); + expect(field).toHaveValue(value); + expect(field).toHaveAttribute("aria-invalid", "true"); + await waitFor(() => expect(field).toHaveFocus()); + expect(requests).toHaveLength(1); + expect(screen.getByTestId("location")).toHaveTextContent( + "from=2026-08-01&to=2026-08-27", + ); + expect( + screen.queryByText("2026-08-01 — 2026-08-27"), + ).not.toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible(); + }, + ); + + it("replaces invalid raw text with a calendar choice and closes the calendar with Escape", async () => { + respondWithRequestedPeriod([]); + renderPage(); + const from = await screen.findByLabelText("С"); + act(() => from.focus()); + fireEvent.input(from, { target: { value: "invalid" } }); + // The selected month stays available even when the visible draft is invalid. + const day = await screen.findByRole("button", { + name: /^2 августа 2026$/i, + }); + fireEvent.click(day); + expect(from).toHaveValue("02.08.2026"); + act(() => from.focus()); + fireEvent.keyDown(from, { key: "Escape" }); + await waitFor(() => + expect( + screen.queryByRole("button", { name: /^2 августа 2026$/i }), + ).not.toBeInTheDocument(), + ); + }); + + 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("С")).toHaveValue("01.09.2026"); + expect(screen.getByLabelText("По")).toHaveValue("01.09.2026"); + inputDate("С", "wrong"); + fireEvent.click(screen.getByRole("button", { name: "Применить" })); + await screen.findByText(/Введите существующую дату/); + fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); + await waitFor(() => + expect(screen.getByLabelText("С")).toHaveValue("01.09.2026"), + ); + expect( + screen.queryByText(/Введите существующую дату/), + ).not.toBeInTheDocument(); + expect(screen.getByLabelText("По")).toHaveValue("01.09.2026"); + 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("С"); + inputDate("С", "02.08.2026"); + let release!: () => void; + const pending = new Promise((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("С")).toHaveValue("02.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("С")).toHaveValue("02.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((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" }, + }); + }, + ), + ); + inputDate("С", "02.08.2026"); + 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("С")).toHaveValue("02.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( + + + , + ); + 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); + }); +}); diff --git a/src/pages/account-income/ui/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage.vm.ts index 0511e21..b5e3c6b 100644 --- a/src/pages/account-income/ui/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage.vm.ts @@ -1,36 +1,16 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useParams, useSearchParams } from "react-router"; import { z } from "zod"; -import type { BrokerageEvent } from "@/entities/brokerage-event"; -import { useBrokerageEvents } from "@/entities/brokerage-event"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { validationMessage } from "@/shared/i18n"; type IncomePeriod = { from: string; to: string }; type IncomeForm = IncomePeriod; -type IncomeForecast = - | { state: "loading" | "empty" } - | { state: "error"; retry: () => void } - | { - state: "loaded"; - totals: { currency: string; value: string }[]; - events: BrokerageEvent[]; - }; - type IncomeDisplayModel = { - trend: { - currency: string; - buckets: { - period: IncomeAnalytics["buckets"][number]["period"]; - total: IncomeAnalytics["totalsByCurrency"][number]; - }[]; - }[]; - instruments: (IncomeAnalytics["instruments"][number] & { - entries: IncomeAnalytics["entries"]; - })[]; + entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; }; type AccountIncomePageViewModel = @@ -40,24 +20,34 @@ type AccountIncomePageViewModel = state: "loaded"; analytics: IncomeAnalytics; display: IncomeDisplayModel; - forecast: IncomeForecast; + formRevision: number; + refreshing: boolean; + refreshError: boolean; + retry: () => void; form: ReturnType>; onReset: () => void; onSubmit: (values: IncomeForm) => void; }; const datePattern = /^\d{4}-\d{2}-\d{2}$/; -const incomeFormSchema = z - .object({ - from: z - .string() - .refine(isDate, validationMessage("income.validation.date")), - to: z.string().refine(isDate, validationMessage("income.validation.date")), - }) - .refine(validPeriod, { - path: ["to"], - ...validationMessage("income.validation.period")({}), - }); +const draftDate = z + .string() + .refine( + (value) => parseDraftDate(value) !== null, + validationMessage("income.validation.date"), + ) + .refine((value) => { + const date = parseDraftDate(value); + return !date || date <= moscowDate(new Date()); + }, 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 = (): AccountIncomePageViewModel => { const { accountId } = useParams(); @@ -74,19 +64,32 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { [requested], ); const form = useForm({ - defaultValues: period, + defaultValues: draftPeriod(period), resolver: zodResolver(incomeFormSchema), }); + const [formRevision, setFormRevision] = useState(0); const analytics = useIncomeAnalytics(accountId, period); - const events = useBrokerageEvents(accountId); useEffect(() => { if (!validPeriod(requested)) setSearchParams(period, { replace: true }); }, [period, requested, setSearchParams]); - useEffect(() => form.reset(period), [form, period]); + useEffect(() => { + form.reset(draftPeriod(period)); + setFormRevision((revision) => revision + 1); + }, [form, period]); - const onSubmit = (values: IncomeForm) => setSearchParams(values); - const onReset = () => setSearchParams(defaultPeriod(), { replace: true }); + 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)); + // DateInput retains raw text while its calendar is open; reset that UI state too. + setFormRevision((revision) => revision + 1); + setSearchParams(next, { replace: true }); + }; if (analytics.isLoading || !analytics.data) { if (analytics.isError) return { state: "error", retry: () => analytics.refetch(), period }; @@ -99,29 +102,30 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { state: "loaded", analytics: analytics.data, display, - forecast: toForecast(events), + formRevision, + refreshing: analytics.isFetching, + refreshError: analytics.isError, + retry: () => analytics.refetch(), form, onSubmit, onReset, }; }; -function toForecast( - events: ReturnType, -): IncomeForecast { - if (events.isLoading) return { state: "loading" }; - if (events.isError) return { state: "error", retry: () => events.refetch() }; - const response = events.data; - const upcoming = - response?.events.filter( - (event) => event.type === "coupon" || event.type === "dividend", - ) ?? []; - if (!response || !upcoming.length) return { state: "empty" }; - return { - state: "loaded", - totals: response.incomeTotalsByCurrency, - events: upcoming.slice(0, 3), - }; +function formatDraftDate(value: string): string { + const [year, month, day] = value.split("-"); + return `${day}.${month}.${year}`; +} + +function parseDraftDate(value: string): string | null { + if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null; + const [day, month, year] = value.split("."); + const date = `${year}-${month}-${day}`; + return isDate(date) ? date : null; +} + +function draftPeriod(period: IncomePeriod): IncomeForm { + return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) }; } function defaultPeriod(): IncomePeriod { @@ -156,48 +160,56 @@ function toIncomeDisplayModel( ): IncomeDisplayModel | null { const instruments = new Map< string, - IncomeDisplayModel["instruments"][number] + { + 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) + instruments.has(instrument.key) || + !currencies.has(instrument.total.currency) ) return null; - instruments.set(instrument.key, { ...instrument, entries: [] }); + instruments.set(instrument.key, { + currency: instrument.total.currency, + receiptCount: instrument.receiptCount, + includedCount: 0, + }); } + const entries: IncomeDisplayModel["entries"] = []; + const occurrences = new Map(); 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.total.currency) + if (!instrument || entry.payment?.currency !== instrument.currency) return null; - instrument.entries.push(entry); + 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.entries.length !== instrument.receiptCount) return null; + if (instrument.includedCount !== instrument.receiptCount) return null; } - return { - trend: analytics.totalsByCurrency.map(({ currency }) => ({ - currency, - buckets: analytics.buckets.map((bucket) => ({ - period: bucket.period, - total: - bucket.totalsByCurrency.find((item) => item.currency === currency) ?? - zeroSummary(currency), - })), - })), - instruments: [...instruments.values()], - }; -} - -function zeroSummary( - currency: string, -): IncomeAnalytics["totalsByCurrency"][number] { - const zero = { currency, value: "0" }; - return { currency, coupon: zero, dividend: zero, total: zero }; + return { entries }; } function moscowDate(value: Date): string { @@ -214,7 +226,11 @@ function moscowDate(value: Date): string { export { type AccountIncomePageViewModel, + formatDraftDate, + type IncomeForm, isDate, + moscowDate, + parseDraftDate, toIncomeDisplayModel, useAccountIncomePageViewModel, }; diff --git a/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx index 26ac1bd..37ec50a 100644 --- a/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx +++ b/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx @@ -2,52 +2,62 @@ import { Alert, Button, Group, + Loader, Stack, Text, - TextInput, Title, } from "@mantine/core"; import type { FC } from "react"; +import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { StateView } from "@/shared/ui"; +import classes from "../AccountIncomePage.module.css"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; -import { IncomeBreakdown } from "./IncomeBreakdown.view"; import { IncomeComparisons } from "./IncomeComparisons.view"; -import { IncomeForecast } from "./IncomeForecast.view"; +import { IncomePayments } from "./IncomePayments.view"; +import { IncomePeriodForm } from "./IncomePeriodForm.view"; import { IncomeSummary } from "./IncomeSummary.view"; -import { IncomeTrend } from "./IncomeTrend.view"; type LoadedProps = Extract; const AccountIncomePageLoaded: FC = (props) => { - const { analytics, display, form, forecast, onSubmit, onReset } = props; + const { + analytics, + display, + form, + refreshing, + refreshError, + retry, + onSubmit, + onReset, + } = props; const { t } = useTranslation(); return ( - + {t("income.title")} -
- - - - - + + + + {refreshing ? ( + + + {t("income.refreshing")} - - - {analytics.period.from} — {analytics.period.to} - + ) : null} + {refreshError ? ( + + + {t("income.refreshError")} + + + + ) : null} {analytics.totalsByCurrency.length ? ( ) : ( @@ -58,9 +68,7 @@ const AccountIncomePageLoaded: FC = (props) => { )} {t("income.taxBoundary")} - - - +
); }; diff --git a/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx b/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx index 3dd5dec..b39968c 100644 --- a/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx +++ b/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx @@ -1,15 +1,31 @@ -import { Skeleton } from "@mantine/core"; +import { Paper, Skeleton, Stack } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { LoadingSkeleton } from "@/shared/ui"; +import classes from "../AccountIncomePage.module.css"; const AccountIncomePageLoading: FC = () => { const { t } = useTranslation(); return ( - - - + + + + + +
+ + +
+ +
+
+ + + + + +
); }; diff --git a/src/pages/account-income/ui/components/IncomeBreakdown.view.tsx b/src/pages/account-income/ui/components/IncomeBreakdown.view.tsx deleted file mode 100644 index 907bcfb..0000000 --- a/src/pages/account-income/ui/components/IncomeBreakdown.view.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Accordion, Stack, Text, Title } from "@mantine/core"; -import type { FC } from "react"; -import { useTranslation } from "react-i18next"; -import { LedgerEntryRow } from "@/entities/broker-operation"; -import { formatMoney } from "@/shared/lib"; -import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; - -type LoadedProps = Extract; -type IncomeBreakdownProps = { - instruments: LoadedProps["display"]["instruments"]; -}; - -const IncomeBreakdown: FC = (props) => { - const { t } = useTranslation(); - return ( - - {t("income.instruments")} - - {props.instruments.map((instrument) => { - const occurrences = new Map(); - return ( - - - {instrument.displayName} · {formatMoney(instrument.total)} ·{" "} - {instrument.receiptCount} - - - - {t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "} - {t("income.dividend")}: {formatMoney(instrument.dividend)} - - - {instrument.entries.map((entry) => { - // Distinguish repeated IDs and reset image fallback when its source changes. - const occurrence = occurrences.get(entry.id) ?? 0; - occurrences.set(entry.id, occurrence + 1); - const key = JSON.stringify([ - entry.id, - occurrence, - entry.logoName, - ]); - return ; - })} - - - - ); - })} - - - ); -}; - -export { IncomeBreakdown }; diff --git a/src/pages/account-income/ui/components/IncomeComparisons.view.tsx b/src/pages/account-income/ui/components/IncomeComparisons.view.tsx index b776fbd..9824ad6 100644 --- a/src/pages/account-income/ui/components/IncomeComparisons.view.tsx +++ b/src/pages/account-income/ui/components/IncomeComparisons.view.tsx @@ -40,7 +40,7 @@ const IncomeComparisons: FC = (props) => { const Comparison: FC = (props) => { const { t } = useTranslation(); return ( - + {props.title} {props.values.map((value) => ( diff --git a/src/pages/account-income/ui/components/IncomeForecast.view.tsx b/src/pages/account-income/ui/components/IncomeForecast.view.tsx deleted file mode 100644 index 91b0c29..0000000 --- a/src/pages/account-income/ui/components/IncomeForecast.view.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Alert, Button, Paper, Stack, Text, Title } from "@mantine/core"; -import type { FC } from "react"; -import { useTranslation } from "react-i18next"; -import { Link } from "react-router"; -import type { BrokerageEvent } from "@/entities/brokerage-event"; -import { formatMoney } from "@/shared/lib"; -import { StateView } from "@/shared/ui"; -import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; - -type LoadedProps = Extract; -type IncomeForecastProps = { forecast: LoadedProps["forecast"] }; -type ForecastEventProps = { event: BrokerageEvent }; - -const IncomeForecast: FC = (props) => { - const { t } = useTranslation(); - if (props.forecast.state === "loading") - return {t("income.forecastLoading")}; - if (props.forecast.state === "error") - return ( - {t("income.retry")} - } - /> - ); - if (props.forecast.state === "empty") - return ( - - ); - if (props.forecast.state !== "loaded") return null; - return ( - - {t("income.forecastTitle")} - {t("income.forecastWarning")} - {props.forecast.totals.map((total) => ( - - {formatMoney(total)} - - ))} - {props.forecast.events.map((event) => ( - - ))} - - - ); -}; - -const ForecastEvent: FC = (props) => ( - - {props.event.displayName} - - {props.event.date} · {formatMoney(props.event.projectedAmount)} - - -); - -export { IncomeForecast }; diff --git a/src/pages/account-income/ui/components/IncomePayments.view.tsx b/src/pages/account-income/ui/components/IncomePayments.view.tsx new file mode 100644 index 0000000..92499e8 --- /dev/null +++ b/src/pages/account-income/ui/components/IncomePayments.view.tsx @@ -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; +type IncomePaymentsProps = { entries: LoadedProps["display"]["entries"] }; + +const IncomePayments: FC = (props) => { + const { t } = useTranslation(); + return ( + + {t("income.payments")} + + {props.entries.map(({ key, operation }) => ( +
  • + +
  • + ))} +
    +
    + ); +}; + +export { IncomePayments }; diff --git a/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx b/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx new file mode 100644 index 0000000..ec9cce9 --- /dev/null +++ b/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx @@ -0,0 +1,92 @@ +import { Button, Paper, Stack, Text } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import type { FC, FormEventHandler } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import classes from "../AccountIncomePage.module.css"; +import { + formatDraftDate, + type IncomeForm, + moscowDate, + parseDraftDate, +} from "../AccountIncomePage.vm"; + +type IncomePeriodFormProps = { + onSubmit: FormEventHandler; + onReset: () => void; +}; + +const IncomePeriodForm: FC = (props) => { + const { t } = useTranslation(); + const { + control, + formState: { isSubmitting }, + } = useFormContext(); + const [from, to] = useWatch({ control, name: ["from", "to"] }); + const today = moscowDate(new Date()); + const start = parseDraftDate(from); + const end = parseDraftDate(to); + return ( + + + {t("income.period")} +
    + {(["from", "to"] as const).map((name) => ( + ( + field.onChange(event.currentTarget.value)} + onChange={(value) => + field.onChange(value ? formatDraftDate(value) : "") + } + onBlur={(event) => { + field.onChange(event.currentTarget.value); + field.onBlur(); + }} + minDate={ + name === "to" && start && start <= today ? start : undefined + } + maxDate={name === "from" && end && end <= today ? end : today} + error={fieldState.error?.message} + disabled={isSubmitting} + /> + )} + /> + ))} +
    +
    + + +
    +
    +
    + ); +}; + +export { IncomePeriodForm }; diff --git a/src/pages/account-income/ui/components/IncomeSummary.view.tsx b/src/pages/account-income/ui/components/IncomeSummary.view.tsx index 102655a..1836cdb 100644 --- a/src/pages/account-income/ui/components/IncomeSummary.view.tsx +++ b/src/pages/account-income/ui/components/IncomeSummary.view.tsx @@ -1,4 +1,4 @@ -import { Paper, Stack, Text } from "@mantine/core"; +import { Paper, Stack, Text, Title } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import type { IncomeAnalytics } from "@/entities/income"; @@ -10,8 +10,9 @@ const IncomeSummary: FC = (props) => { const { t } = useTranslation(); return ( + {t("income.summaryTitle")} {props.totals.map((total) => ( - + {formatMoney(total.total)} {t("income.coupon")}: {formatMoney(total.coupon)} ·{" "} diff --git a/src/pages/account-income/ui/components/IncomeTrend.view.tsx b/src/pages/account-income/ui/components/IncomeTrend.view.tsx deleted file mode 100644 index 94ab216..0000000 --- a/src/pages/account-income/ui/components/IncomeTrend.view.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { SegmentedControl, Stack, Text, Title } from "@mantine/core"; -import type { FC } from "react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { formatMoney } from "@/shared/lib"; -import styles from "../AccountIncomePage.module.css"; -import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; - -type LoadedProps = Extract; -type IncomeTrendProps = { trend: LoadedProps["display"]["trend"] }; - -const IncomeTrend: FC = (props) => { - const currencies = props.trend.map(({ currency }) => currency); - const [currency, setCurrency] = useState(currencies[0] ?? ""); - const { t } = useTranslation(); - const buckets = - props.trend.find((item) => item.currency === currency)?.buckets ?? []; - const values = buckets.map((bucket) => ({ - ...bucket, - coupon: Number(bucket.total.coupon.value), - dividend: Number(bucket.total.dividend.value), - })); - const scale = Math.max( - 1, - ...values.map((value) => - Math.max( - positive(value.coupon) + positive(value.dividend), - negative(value.coupon) + negative(value.dividend), - ), - ), - ); - return ( - - {t("income.trend")} - {currencies.length > 1 ? ( - - ) : null} - - - {buckets.map((bucket) => ( - - {bucket.period.from} — {bucket.period.to}: {t("income.coupon")}{" "} - {formatMoney(bucket.total.coupon)}, {t("income.dividend")}{" "} - {formatMoney(bucket.total.dividend)} - - ))} - - - ); -}; - -function positive(value: number): number { - return Math.max(0, value); -} - -function negative(value: number): number { - return Math.max(0, -value); -} - -function chartHeight(value: number, scale: number): string { - return `${(Math.abs(value) / scale) * 100}%`; -} - -export { IncomeTrend }; diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index e089421..dca43a8 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -175,22 +175,20 @@ const ru = { compared: "Сравниваемый период", difference: "Разница", total: "Итого", - trend: "Динамика", - currency: "Валюта графика", - instruments: "По инструментам", + payments: "Выплаты", emptyTitle: "Купоны и дивиденды не найдены", emptyText: "За выбранный период подходящих исполненных выплат нет.", errorTitle: "Не удалось рассчитать пассивный доход", errorText: "Попробуйте загрузить результат еще раз.", retry: "Повторить", - forecastTitle: "Ближайший прогноз", - forecastLoading: "Загрузка прогноза", - forecastError: "Не удалось загрузить прогноз", - forecastEmpty: "Ближайших купонов и дивидендов не найдено.", - forecastWarning: - "Это известный прогноз, а не гарантированное поступление.", + period: "Период", + datePlaceholder: "ДД.ММ.ГГГГ", + refreshing: "Обновляем пассивный доход", + refreshError: + "Не удалось обновить пассивный доход. Показан последний загруженный результат.", validation: { - date: "Введите существующую дату.", + date: "Введите существующую дату в формате ДД.ММ.ГГГГ.", + future: "Дата не может быть позже сегодняшнего дня.", period: "Укажите корректный период без будущих дат: начало не позже окончания.", },