From d61cdd2e8fd8a3355ba95745fa7b53c8c7c52db7 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 13 Oct 2026 17:45:58 +0300 Subject: [PATCH] feat: unify income payment cards and drop period comparisons (WealthLedger/main#33) - income page renders only selected-period totals and payments; the comparison blocks, notices and display-only percentage math removed - new shared PayoutCard presents executed payouts, forecasts and per-security schedules with explicit money semantics - ledger rows, upcoming events, stock dividends and bond coupons use the same card; period filter lost its duplicated paper wrapper --- src/app/routes/AppRoutes/AllAccounts.test.tsx | 5 - .../broker-operation/model/display.ts | 8 +- .../broker-operation/ui/LedgerEntryRow.tsx | 25 +- .../brokerage-event/ui/BrokerageEventCard.tsx | 24 +- src/entities/income/api/useIncome.ts | 4 - src/entities/income/index.ts | 1 - .../AccountEventsPage.test.tsx | 2 +- .../account-income/model/display.test.ts | 171 +------------ src/pages/account-income/model/display.ts | 105 +------- .../AccountIncomePage.module.css | 117 --------- .../AccountIncomePage.test.tsx | 212 +-------------- .../AccountIncomePage/AccountIncomePage.vm.ts | 2 +- .../AccountIncomePageLoaded.view.tsx | 13 - .../components/IncomeComparisons.view.tsx | 167 ------------ .../IncomePeriodForm.view.tsx | 13 +- .../ui/AccountPage/AccountPage.test.tsx | 2 +- src/pages/bond/ui/BondPage/BondPage.view.tsx | 2 + .../BondPage/components/BondCoupons.view.tsx | 94 ++++--- .../stock/ui/StockPage/StockPage.test.tsx | 8 +- .../stock/ui/StockPage/StockPage.view.tsx | 2 + .../components/StockDividends.view.tsx | 241 ++++++------------ src/shared/api/generated/schema.d.ts | 14 +- src/shared/api/spec/openapi.json | 65 +---- src/shared/i18n/locales/ru.ts | 17 +- src/shared/ui/PayoutCard/PayoutCard.test.tsx | 85 ++++++ src/shared/ui/PayoutCard/PayoutCard.tsx | 166 ++++++++++++ src/shared/ui/index.ts | 1 + 27 files changed, 474 insertions(+), 1092 deletions(-) delete mode 100644 src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx create mode 100644 src/shared/ui/PayoutCard/PayoutCard.test.tsx create mode 100644 src/shared/ui/PayoutCard/PayoutCard.tsx diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index 1226035..5965eab 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -95,10 +95,6 @@ const analytics: IncomeAnalytics }, ], period: { from: "2026-08-01", to: "2026-08-31" }, - previous: { - period: { from: "2026-07-01", to: "2026-07-31" }, - totalsByCurrency: [], - }, totalsByCurrency: [ { coupon: { currency: "RUB", value: "2" }, @@ -107,7 +103,6 @@ const analytics: IncomeAnalytics total: { currency: "RUB", value: "2" }, }, ], - yearComparison: null, }; const CurrentLocation: FC = () => { diff --git a/src/entities/broker-operation/model/display.ts b/src/entities/broker-operation/model/display.ts index 411272b..2c9aa3a 100644 --- a/src/entities/broker-operation/model/display.ts +++ b/src/entities/broker-operation/model/display.ts @@ -36,6 +36,12 @@ function entryLabelKey(operation: BrokerageOperation): string { return `ledger.category.${operation.category}`; } +function isIncomePayment(operation: BrokerageOperation): boolean { + return [OperationType.Coupon, OperationType.Dividend].some(type => + isOperationType(operation.type, type), + ); +} + function isOperationType(value: unknown, expected: OperationType): boolean { return value === expected; } @@ -109,4 +115,4 @@ function paymentPresentation( }; } -export { operationLabelKey, paymentPresentation }; +export { isIncomePayment, operationLabelKey, paymentPresentation }; diff --git a/src/entities/broker-operation/ui/LedgerEntryRow.tsx b/src/entities/broker-operation/ui/LedgerEntryRow.tsx index bd49a21..1c3d1f3 100644 --- a/src/entities/broker-operation/ui/LedgerEntryRow.tsx +++ b/src/entities/broker-operation/ui/LedgerEntryRow.tsx @@ -11,10 +11,11 @@ import { formatSnapshotTime, instrumentLogoUrl, } from "@/shared/lib"; +import { PayoutCard } from "@/shared/ui"; import type { BrokerageOperation } from "../api/useBrokerageOperations"; -import { operationLabelKey, paymentPresentation } from "../model/display"; +import { isIncomePayment, operationLabelKey, paymentPresentation } from "../model/display"; type LedgerEntryRowProps = { operation: BrokerageOperation; @@ -75,6 +76,28 @@ const LedgerEntryRow: FC = (props) => { } else details = undefined; + if (isIncomePayment(operation)) { + const extraMetadata = [ + instrument, + props.withDetails ? null : operation.account?.name, + ].filter(Boolean).join(" · "); + + return ( + + ); + } + return ( = (props) => { const date = formatDate(event.date); + if (event.type === "coupon" || event.type === "dividend") { + const title = [t(`events.type.${event.type}`), event.displayName].join(" · "); + + const extraMetadata = [event.isin, event.account?.name] + .filter(Boolean) + .join(" · "); + + return ( + + ); + } + const metadata = [ date, event.dateKind === "registry" @@ -40,7 +62,7 @@ const BrokerageEventCard: FC = (props) => { title={[t(`events.type.${event.type}`), event.displayName].join(" · ")} tone="neutral" value={projectedAmount} - valueLabel={t("events.projectedAmount")} + valueLabel={t("payout.forecast")} /> ); }; diff --git a/src/entities/income/api/useIncome.ts b/src/entities/income/api/useIncome.ts index 449eef3..a6357e8 100644 --- a/src/entities/income/api/useIncome.ts +++ b/src/entities/income/api/useIncome.ts @@ -4,9 +4,6 @@ type IncomeAnalytics = NonNullable< paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"] >; -type IncomeAnalyticsMeta - = paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["meta"]; - type IncomeSummary = NonNullable< paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"] >; @@ -47,7 +44,6 @@ function useIncomeSummary(accountId?: string) { export { type IncomeAnalytics, - type IncomeAnalyticsMeta, type IncomeSummary, useIncomeAnalytics, useIncomeSummary, diff --git a/src/entities/income/index.ts b/src/entities/income/index.ts index 7ecf287..a277e6d 100644 --- a/src/entities/income/index.ts +++ b/src/entities/income/index.ts @@ -1,6 +1,5 @@ export { type IncomeAnalytics, - type IncomeAnalyticsMeta, type IncomeSummary, useIncomeAnalytics, useIncomeSummary, diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index 0f3b0e4..8deb468 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -156,7 +156,7 @@ describe("AccountEventsPage", () => { expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1); expect(screen.getByText("25.08.2026")).toBeVisible(); expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument(); - expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2); + expect(screen.getAllByText("Прогноз")).toHaveLength(2); const logo = screen.queryByAltText(""); expect(logo).toHaveAttribute( "src", diff --git a/src/pages/account-income/model/display.test.ts b/src/pages/account-income/model/display.test.ts index ce68f74..19a247e 100644 --- a/src/pages/account-income/model/display.test.ts +++ b/src/pages/account-income/model/display.test.ts @@ -2,168 +2,25 @@ import { describe, expect, it } from "vitest"; import type { IncomeAnalytics } from "@/entities/income"; -import { resource } from "@/test/resource"; - import { toIncomeDisplayModel } from "./display"; -const analytics: IncomeAnalytics = { - accountId: "account-1", - bucketGranularity: "day", - buckets: [], - calculatedAt: "2026-08-27T00:00:00Z", - entries: [], - instruments: [], - period: { from: "2026-08-01", to: "2026-08-27" }, - previous: { - period: { from: "2026-07-01", to: "2026-07-31" }, - totalsByCurrency: [comparison("RUB")], - }, - totalsByCurrency: [], - yearComparison: null, -}; +describe("income payments display", () => { + it("keeps repeated IDs, order and logo revisions without reconstructing financial data", () => { + const payment = { + id: "same", + instrumentKey: "bond:RUB", + logoName: null, + } as IncomeAnalytics["entries"][number]; -function comparison(currency: string) { - return { - compared: summary(currency, "1", "0"), - currency, - difference: summary(currency, "9", "2"), - selected: summary(currency, "10", "2"), - }; -} + const analytics = { + entries: [payment, payment, { ...payment, logoName: "bond.png" }], + } as IncomeAnalytics; -function money(currency: string, value: string) { - return { currency, value }; -} + const display = toIncomeDisplayModel(analytics); -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("income comparison display", () => { - it.each([ - ["25", "100", "+25 %"], - ["-25", "100", "-25 %"], - ["0", "100", "0 %"], - ["-0.000", "100", "0 %"], - ["0.0001", "100", "0 %"], - ["1", "3", "+33,3 %"], - ["0.01", "0.04", "+25 %"], - ["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"], - [ - "10", - "0", - null, - ], - [ - "0", - "0", - null, - ], - [ - "10", - "-0.00", - null, - ], - [ - "10", - "-100", - null, - ], - [ - "9".repeat(400), - "1", - null, - ], - [ - "1", - "9".repeat(400), - null, - ], - ])("formats change %s against base %s", (difference, base, expected) => { - const value = comparison("RUB"); - value.difference.coupon = money("RUB", difference); - value.compared.coupon = money("RUB", base); - - const display = toIncomeDisplayModel({ - ...analytics, - yearComparison: { - period: { from: "2025-08-01", to: "2025-08-27" }, - totalsByCurrency: [value], - }, - }); - - const result = display?.comparison?.values[0]; - expect( - result?.percentages.coupon?.replaceAll("\u{A0}", " ") - - ?? null, - ).toBe(expected); - expect(result?.difference.coupon.value).toBe(difference); - expect(result?.compared.coupon.value).toBe(base); - }); - - it("formats calendar ranges across years and preserves separate currencies", () => { - const display = toIncomeDisplayModel({ - ...analytics, - period: { from: "2025-12-01", to: "2026-01-09" }, - yearComparison: { - period: { from: "2024-12-01", to: "2025-01-09" }, - totalsByCurrency: [comparison("RUB"), comparison("USD")], - }, - }); - - expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe( - "01.12.2025 – 09.01.2026", + expect(display.entries.map(({ operation }) => operation)).toEqual( + analytics.entries, ); - expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe( - "01.12.2024 – 09.01.2025", - ); - expect(display?.comparison?.values.map(value => value.currency)).toEqual([ - "RUB", - "USD", - ]); + expect(new Set(display.entries.map(({ key }) => key)).size).toBe(3); }); }); - -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, - lastAttempt: { - finishedAt: "2026-08-14T00:01:00Z", - problems: [], - startedAt: "2026-08-14T00:00:00Z", - state: "failed" as const, - }, - resourceKey: "opaque/year-looking-key", - role: "previous" as const, - }; - - 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], - ]) { - const display = toIncomeDisplayModel(analytics, { - ...resource(analytics).meta, - dependencies, - }); - - expect(display?.previousNotice).toBe("comparisonFailed"); - expect(display?.yearNotice).toBe("yearUnavailable"); - expect(display?.previousComparison).not.toBeNull(); - } -}); diff --git a/src/pages/account-income/model/display.ts b/src/pages/account-income/model/display.ts index bcd1b7c..371d25f 100644 --- a/src/pages/account-income/model/display.ts +++ b/src/pages/account-income/model/display.ts @@ -1,102 +1,11 @@ -import type { IncomeAnalytics, IncomeAnalyticsMeta } from "@/entities/income"; - -import { formatDate } from "@/shared/lib"; - -import type { IncomePeriod } from "./period"; - -type ComparisonNotice - = | "comparisonFailed" - | "comparisonPending" - | "previousUnavailable" - | "yearUnavailable"; +import type { IncomeAnalytics } from "@/entities/income"; type IncomeDisplayModel = { - comparison: null - | { - comparedPeriod: string; - selectedPeriod: string; - values: (NonNullable< - IncomeAnalytics["yearComparison"] - >["totalsByCurrency"][number] & { - percentages: Record<"coupon" - | "dividend" - | "total", null | string>; - })[]; - }; entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; - previousComparison: IncomeDisplayModel["comparison"]; - previousNotice: ComparisonNotice; - yearNotice: ComparisonNotice; }; -function comparisonDisplay( - period: IncomePeriod, - comparison: IncomeAnalytics["yearComparison"], -): IncomeDisplayModel["comparison"] { - if (!comparison) - return 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: - IncomeAnalyticsMeta["dependencies"][number] | undefined, - unavailable: "previousUnavailable" | "yearUnavailable", -): ComparisonNotice { - if (!dependency || dependency.unavailableReason) return unavailable; - - return dependency.lastAttempt.state === "failed" - ? "comparisonFailed" - : "comparisonPending"; -} - -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 null; - const ratio = Number(difference) / base; - if (!Number.isFinite(ratio)) - return null; - - return new Intl.NumberFormat("ru-RU", { - maximumFractionDigits: 1, - signDisplay: "exceptZero", - style: "percent", - }).format(ratio); -} - function toIncomeDisplayModel( analytics: IncomeAnalytics, - meta?: IncomeAnalyticsMeta, ): IncomeDisplayModel { const entries: IncomeDisplayModel["entries"] = []; const occurrences = new Map(); @@ -116,17 +25,7 @@ function toIncomeDisplayModel( }); } - const dependencies = meta?.dependencies ?? []; - const previous = dependencies.find(({ role }) => role === "previous"); - const year = dependencies.find(({ role }) => role === "year"); - - return { - comparison: comparisonDisplay(analytics.period, analytics.yearComparison), - entries, - previousComparison: comparisonDisplay(analytics.period, analytics.previous), - previousNotice: comparisonNotice(previous, "previousUnavailable"), - yearNotice: comparisonNotice(year, "yearUnavailable"), - }; + return { entries }; } export { type IncomeDisplayModel, toIncomeDisplayModel }; diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css index 2e737bc..27431fb 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css @@ -22,123 +22,6 @@ min-width: 0; } -.comparison { - container-type: inline-size; -} - -.comparisonGrid { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: var(--mantine-spacing-sm); -} - -.comparisonPanel { - min-width: 0; - padding: var(--mantine-spacing-md); - border-radius: var(--mantine-radius-md); - border-top: 3px solid var(--mantine-color-gray-4); - background: var(--mantine-color-gray-0); -} - -.comparisonPanel[data-kind="selected"] { - border-color: var(--mantine-color-blue-6); - background: var(--mantine-color-blue-0); -} - -.comparisonPanel[data-tone="positive"] { - border-color: var(--mantine-color-green-8); - background: var(--mantine-color-green-0); -} - -.comparisonPanel[data-tone="negative"] { - border-color: var(--mantine-color-red-8); - background: var(--mantine-color-red-0); -} - -.comparisonLabel, -.comparisonValues dt { - color: var(--mantine-color-gray-7); -} - -.comparisonValues { - margin: var(--mantine-spacing-md) 0 0; - font-size: var(--mantine-font-size-sm); -} - -.comparisonValues dd { - min-width: 0; - margin: 0; - font-weight: 600; - font-variant-numeric: tabular-nums; - overflow-wrap: anywhere; -} - -.comparisonTotal { - margin-bottom: var(--mantine-spacing-md); - padding-bottom: var(--mantine-spacing-md); - border-bottom: 1px solid var(--mantine-color-gray-3); -} - -.comparisonTotal dd { - margin-top: 4px; - font-size: var(--mantine-h2-font-size); - line-height: 1.3; - font-weight: 700; - letter-spacing: -0.025em; -} - -.comparisonValues dd[data-tone="positive"] { - color: color-mix( - in srgb, - var(--mantine-color-green-9) 85%, - var(--mantine-color-black) - ); -} - -.comparisonValues dd[data-tone="negative"] { - color: var(--mantine-color-red-9); -} - -.comparisonValues dd[data-tone="neutral"] { - color: var(--mantine-color-gray-7); -} - -.comparisonPanel[data-kind="selected"] .comparisonTotal dd { - color: var(--mantine-color-blue-8); -} - -.comparisonDetail { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 4px var(--mantine-spacing-xs); - margin-top: var(--mantine-spacing-xs); -} - -.comparisonPercent { - display: inline-block; - margin-left: var(--mantine-spacing-xs); - padding: 2px 6px; - border-radius: var(--mantine-radius-sm); - background: var(--mantine-color-white); - font-size: var(--mantine-font-size-xs); - font-weight: 600; - line-height: 1.5; - letter-spacing: normal; - vertical-align: middle; -} - -.comparisonNotice { - background: var(--mantine-color-gray-0); - border-left: 3px solid var(--mantine-color-gray-4); -} - -@container (min-width: 42em) { - .comparisonGrid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } -} - @media (max-width: 35.999em) { .fields { grid-template-columns: minmax(0, 1fr); diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 063be7e..0ae6c3e 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -72,23 +72,9 @@ const analytics }, ], 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, } satisfies IncomeAnalytics & { instruments: [unknown] }; -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 { accruedInterest: null, @@ -372,19 +358,11 @@ describe("AccountIncomePage", () => { expect(within(panel).getByText("−2 ₽")).toBeInTheDocument(); }); - it("renders realized text and comparisons without subscribing to events", async () => { + it("renders realized income without comparison blocks or event requests", async () => { const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 })); server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json( - resource({ - ...analytics, - yearComparison: { - period: { from: "2025-08-01", to: "2025-08-27" }, - totalsByCurrency: [comparison("RUB")], - }, - }), - ), + HttpResponse.json(resource(analytics)), ), http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest), ); @@ -399,72 +377,9 @@ describe("AccountIncomePage", () => { expect(screen.queryByText("Динамика")).not.toBeInTheDocument(); expect(eventsRequest).not.toHaveBeenCalled(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument(); - const year = screen.getByRole("region", { name: "Годом ранее" }); - - const selected = within(year).getByRole("region", { - name: "01.08.2026 – 27.08.2026", - }); - - expect( - screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("heading", { name: "Предыдущий период" }), - ).toBeVisible(); - expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument(); - expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument(); - expect( - screen.getByRole("heading", { level: 2, name: "Годом ранее" }), - ).toBeInTheDocument(); - expect(screen.queryByText("Сравнения")).not.toBeInTheDocument(); - expect(screen.queryByText("RUB", { exact: true })).not.toBeInTheDocument(); - - for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) { - expect(within(selected).getByText(amount)).toBeInTheDocument(); - } - - const difference = within(year).getByRole("region", { name: "Разница" }); - - for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) { - expect(within(difference).getByText(amount)).toBeInTheDocument(); - } - - expect(within(difference).getByText("+900 %")).toBeInTheDocument(); - expect(within(difference).getByText("+1 100 %")).toBeInTheDocument(); - expect(within(difference).queryByText("нет базы")).not.toBeInTheDocument(); - 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 ₽$/); - }); - - it("shows the unavailable year state without falling back to the previous period", async () => { - 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", - }, - ]; - - server.use( - http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json(resource(analytics, { dependencies })), - ), - ); - renderPage(); - expect( - await screen.findByText("Сравнение с годом ранее недоступно."), - ).toBeInTheDocument(); - expect( - screen.queryByRole("region", { name: "Годом ранее" }), - ).not.toBeInTheDocument(); - expect( - screen.getByRole("region", { name: "Предыдущий период" }), - ).toBeVisible(); + expect(screen.queryByText("Предыдущий период")).not.toBeInTheDocument(); + expect(screen.queryByText("Годом ранее")).not.toBeInTheDocument(); + expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument(); }); it("shows a quiet realized error", async () => { @@ -748,120 +663,3 @@ describe("income period and snapshot boundary", () => { expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); }); - -it.each([false, true])( - "shows unavailable comparisons for absent roles without positional fallback (legacy=%s)", - async (legacy) => { - server.use( - 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, - ] - : [], - }, - ), - ), - ), - ); - renderPage(); - expect( - await screen.findByText("Сравнение с предыдущим периодом недоступно."), - ).toBeVisible(); - expect( - screen.getByText("Сравнение с годом ранее недоступно."), - ).toBeVisible(); - expect( - screen.queryByText("Период сравнения ещё не загружен полностью."), - ).not.toBeInTheDocument(); - expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible(); - }, -); - -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, - }), - ); - - server.use( - http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json( - - resource( - { ...analytics, previous: null, yearComparison: null }, - { dependencies }, - ), - ), - ), - ); - renderPage(); - expect(await screen.findByRole("region", { name: "Выплаты" })).toBeVisible(); - expect( - screen.queryByRole("region", { name: "Год назад" }), - ).not.toBeInTheDocument(); - expect(screen.getAllByText("12 ₽").length).toBeGreaterThan(0); - expect( - screen.getAllByText("Период сравнения ещё не загружен полностью."), - ).toHaveLength(2); - expect( - screen.queryByText("Сравнение с годом ранее недоступно."), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole("region", { name: "Разница" }), - ).not.toBeInTheDocument(); - expect(screen.getByLabelText("Период")).toBeVisible(); -}); - -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, - lastAttempt: { - finishedAt: "2026-08-14T00:01:00Z", - problems: [], - startedAt: "2026-08-14T00:00:00Z", - state: "failed" as const, - }, - role: "year" as const, - }, - { - ...resource(analytics).meta, - role: "selected" as const, - }, - ]; - - server.use( - http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json(resource(analytics, { dependencies })), - ), - ); - renderPage(); - expect( - await screen.findByRole("region", { name: "Предыдущий период" }), - ).toBeVisible(); - expect( - screen.getByText( - "Не удалось загрузить период сравнения. Повторите обновление.", - ), - ).toBeVisible(); - expect( - screen.queryByRole("region", { name: "Годом ранее" }), - ).not.toBeInTheDocument(); -}); diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index 853fcae..ae0a8e8 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -91,7 +91,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { ? { period, state: "error" } : { state: "loading" }; - const display = toIncomeDisplayModel(analytics.data, analytics.meta); + const display = toIncomeDisplayModel(analytics.data); const onSubmit = (values: IncomeForm) => { const from = parseDraftDate(values.from); diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx index 50db1bc..bef3258 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx @@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next"; 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"; import { IncomeSummary } from "./IncomeSummary.view"; @@ -54,18 +53,6 @@ const AccountIncomePageLoaded: FC = (props) => { {t("income.taxBoundary")} - - - - ); diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx deleted file mode 100644 index 5b0ceeb..0000000 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import type { FC } from "react"; - -import { Paper, Stack, Text, Title } from "@mantine/core"; -import { useTranslation } from "react-i18next"; - -import { formatMoney } from "@/shared/lib"; - -import type { IncomeDisplayModel } from "../../../model/display"; - -import classes from "../AccountIncomePage.module.css"; - -type ComparisonProps = NonNullable & { - title: string; -}; - -type ComparisonValuesProps = { - kind: "compared" - | "difference" - | "selected"; - label: string; - percentages?: ComparisonProps["values"][number]["percentages"]; - values: ComparisonProps["values"][number]["selected"]; -}; - -type IncomeComparisonsProps = { - comparison: IncomeDisplayModel["comparison"]; - notice: string; - title: string; -}; - -const IncomeComparisons: FC = (props) => { - return ( - - {props.title} - - {props.comparison - ? - : ( - - - {props.notice} - - - )} - - ); -}; - -const Comparison: FC = (props) => { - const { t } = useTranslation(); - - return ( -
- - {props.values.map(value => ( - -
- - - - - -
-
- ))} - - {props.values.length === 0 && ( - - {t("income.comparisonEmpty")} - - )} -
-
- ); -}; - -const ComparisonValues: FC = (props) => { - const { t } = useTranslation(); - - const data_tone - = props.kind === "difference" - ? differenceTone(props.values.total.value) - : undefined; - - return ( -
- - {props.label} - - -
- {(["total", "coupon", "dividend"] as const).map((key) => { - const money - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - = props.values[key]; - - const percentage - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - = props.percentages?.[key]; - - const tone - = props.kind === "difference" - ? differenceTone(money.value) - : undefined; - - const appearanceClass - = key === "total" - ? classes.comparisonTotal - : classes.comparisonDetail; - - return ( -
-
{t(`income.${key}`)}
- -
- - {tone === "positive" ? "+" : ""} - - {formatMoney(money)} - - - {!!percentage && ( - - {percentage} - - )} -
-
- ); - })} -
-
- ); -}; - -function differenceTone(value: string): "negative" - | "neutral" - | "positive" { - if (!/[1-9]/.test(value)) return "neutral"; - - return value.startsWith("-") ? "negative" : "positive"; -} - -export { IncomeComparisons }; diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx index a02faa5..f1e20fa 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx @@ -1,6 +1,6 @@ import type { FC, SubmitEventHandler } from "react"; -import { Button, Paper, Stack, Text } from "@mantine/core"; +import { Button, Stack } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; import classes from "../../AccountIncomePage.module.css"; @@ -27,15 +27,8 @@ const IncomePeriodForm: FC = (props) => { } = useIncomePeriodFormVm(); return ( - +
- {t("income.period")}
= (props) => {
- +
); }; diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index ea1bdcc..8b180e0 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -686,7 +686,7 @@ describe("AccountPage states", () => { ).toBeVisible(); for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"]) expect(content.getByText(amount)).toBeVisible(); - expect(content.getAllByText("Ожидаемая сумма")).toHaveLength(4); + expect(content.getAllByText("Прогноз")).toHaveLength(4); expect(content.queryByText("Без движения денег")).not.toBeInTheDocument(); expect(content.queryByText(/^\+/)).not.toBeInTheDocument(); expect( diff --git a/src/pages/bond/ui/BondPage/BondPage.view.tsx b/src/pages/bond/ui/BondPage/BondPage.view.tsx index b727fdf..71ab684 100644 --- a/src/pages/bond/ui/BondPage/BondPage.view.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.view.tsx @@ -191,8 +191,10 @@ const BondPageView: FC = (props) => { diff --git a/src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx b/src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx index 930d0dd..70aeffb 100644 --- a/src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx +++ b/src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx @@ -1,17 +1,20 @@ import type { FC } from "react"; -import { Accordion, Alert, Badge, Group, Paper, Stack, Text } from "@mantine/core"; +import { Accordion, Alert, Badge, Group, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import type { BondCoupons as Coupons } from "@/entities/bond"; -import { InstrumentMetric, InstrumentSection, type SectionState } from "@/features/instrument-details"; -import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; +import { InstrumentSection, type SectionState } from "@/features/instrument-details"; +import { formatDate, formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib"; +import { PayoutCard } from "@/shared/ui"; type BondCouponsProps = { coupons?: Coupons; + logoName?: null | string; pastCoupons: Coupons["coupons"]; state: SectionState; + ticker?: null | string; upcoming: Coupons["coupons"]; }; @@ -39,6 +42,8 @@ const BondCoupons: FC = (props) => { )) ) @@ -75,6 +80,8 @@ const BondCoupons: FC = (props) => { )) ) @@ -91,59 +98,48 @@ const BondCoupons: FC = (props) => { type CouponProps = { coupon: Coupons["coupons"][number]; + logoName?: null | string; + ticker?: null | string; }; const Coupon: FC = (props) => { const { coupon } = props; const { t } = useTranslation(); + const title = `${t("events.type.coupon")} · ${props.ticker || t("bond.instrument")}`; + + const metadata = [ + coupon.paymentDate ? formatDate(coupon.paymentDate) : t("stock.dateUnknown"), + t("bond.couponNumber", { number: coupon.number ?? t("bond.unknown") }), + ].join(" · "); + + const conditions = ( + + + {t(`bond.couponTypes.${coupon.couponType}`, { defaultValue: t("bond.unknown") })} + + + {coupon.periodDays != undefined && ( + {t("bond.couponDays", { count: coupon.periodDays })} + )} + + {coupon.recordDate && ( + {t("stock.record")}: {formatDate(coupon.recordDate)} + )} + + ); return ( - - -
- - {coupon.paymentDate - ? formatDate(coupon.paymentDate) - : t("stock.dateUnknown")} - - - - {t("bond.couponNumber", { - number: coupon.number ?? t("bond.unknown"), - })} - -
- - - - {!!coupon.estimatedAmount && ( - - )} -
- - - - {t(`bond.couponTypes.${coupon.couponType}`, { - defaultValue: t("bond.unknown"), - })} - - - {coupon.periodDays != undefined && ( - - {t("bond.couponDays", { count: coupon.periodDays })} - - )} - - {!!coupon.recordDate && ( - - {t("stock.record")}: {formatDate(coupon.recordDate)} - - )} - -
+ ); }; diff --git a/src/pages/stock/ui/StockPage/StockPage.test.tsx b/src/pages/stock/ui/StockPage/StockPage.test.tsx index e0d5c46..720ad65 100644 --- a/src/pages/stock/ui/StockPage/StockPage.test.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.test.tsx @@ -337,7 +337,7 @@ describe("stock page", () => { "/stocks/SBERP?period=year&events=trades", ), ); - expect(await screen.findByText("Зачисление дивидендов")).toBeVisible(); + expect(await screen.findByText("Дивиденд")).toBeVisible(); }); it("offers search for ambiguous tickers without requesting personal data", async () => { const { requests } = setup({ ambiguousTicker: true, path: "/stocks/SAME" }); @@ -368,18 +368,18 @@ describe("stock page", () => { screen.getByText("К закрытию предыдущего торгового дня, 09.09.2026"), ).toBeVisible(); expect(await screen.findByText("Зачисление дивидендов")).toBeVisible(); - expect(screen.getByText("Событие компании")).toBeVisible(); + expect(screen.getAllByText("на акцию").length).toBeGreaterThan(0); expect(screen.getByText("328,8 ₽")).toBeVisible(); expect(screen.getByText("P/E · TTM")).toBeVisible(); expect(screen.getByText("Описание компании")).toBeVisible(); expect( screen.queryByRole("button", { name: "О компании" }), ).not.toBeInTheDocument(); - expect(screen.getByText("13.10.2026")).toBeVisible(); + expect(screen.getByText(/13\.10\.2026/)).toBeVisible(); fireEvent.click( screen.getByRole("button", { name: "История дивидендов компании" }), ); - await waitFor(() => expect(screen.getByText("13.10.2025")).toBeVisible()); + await waitFor(() => expect(screen.getByText(/13\.10\.2025/)).toBeVisible()); expect(screen.getByText("Текущая средняя покупка: 200 ₽")).toBeVisible(); fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ })); const rows = within(await screen.findByRole("table")).getAllByRole("row"); diff --git a/src/pages/stock/ui/StockPage/StockPage.view.tsx b/src/pages/stock/ui/StockPage/StockPage.view.tsx index 28d963b..c0929ad 100644 --- a/src/pages/stock/ui/StockPage/StockPage.view.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.view.tsx @@ -63,8 +63,10 @@ const StockPageView: FC = (props) => { diff --git a/src/pages/stock/ui/StockPage/components/StockDividends.view.tsx b/src/pages/stock/ui/StockPage/components/StockDividends.view.tsx index ac54370..2f576e5 100644 --- a/src/pages/stock/ui/StockPage/components/StockDividends.view.tsx +++ b/src/pages/stock/ui/StockPage/components/StockDividends.view.tsx @@ -1,198 +1,96 @@ import type { FC } from "react"; -import { - Accordion, - Alert, - Badge, - Group, - Paper, - Stack, - Text, -} from "@mantine/core"; +import { Accordion, Alert, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import type { StockDividends as Dividends } from "@/entities/stock"; import { InstrumentSection, type SectionState } from "@/features/instrument-details"; -import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; +import { formatDate, formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib"; +import { PayoutCard } from "@/shared/ui"; -import classes from "../StockPage.module.css"; +type Dividend = Dividends["dividends"][number]; + +type DividendCardProps = { + event: Dividend; + logoName?: null | string; + ticker?: null | string; +}; type StockDividendsProps = { dividends?: Dividends; + logoName?: null | string; pastDividends: Dividends["dividends"]; state: SectionState; + ticker?: null | string; upcoming: Dividends["dividends"]; }; +const DividendCard: FC = (props) => { + const { event } = props; + const { t } = useTranslation(); + const title = `${t("events.type.dividend")} · ${props.ticker || t("stock.share")}`; + + const metadata = [ + event.recordDate + ? `${t("stock.record")}: ${formatDate(event.recordDate)}` + : t("stock.dateUnknown"), + event.paymentDate ? `${t("stock.payment")}: ${formatDate(event.paymentDate)}` : null, + ].filter(Boolean).join(" · "); + + const conditions = event.lastBuyDate + ? {t("stock.lastBuy")}: {formatDate(event.lastBuyDate)} + : undefined; + + return ( + + ); +}; + const StockDividends: FC = (props) => { const { dividends, pastDividends, state, upcoming } = props; const { t } = useTranslation(); - let upcomingContent; - - if (dividends) { - if (upcoming.length > 0) { - upcomingContent = ( - upcoming.map(event => ( -
- -
- - {t("stock.companyEvent")} - - - - {formatMoney(event.perShare)} - {" "} - - {t("stock.perShare")} - - -
- - {!!event.estimatedAmount && ( -
- - {t("stock.yourEstimate")} - - - - {formatMoney(event.estimatedAmount)} - -
- )} -
- - - {!!event.lastBuyDate && ( -
- - {t("stock.lastBuy")} - - - {formatDate(event.lastBuyDate)} -
- )} - - {!!event.recordDate && ( -
- - {t("stock.record")} - - - {formatDate(event.recordDate)} -
- )} - - {!!event.paymentDate && ( -
- - {t("stock.payment")} - - - {formatDate(event.paymentDate)} -
- )} -
-
- )) - ); - } - else { - upcomingContent = ( - - {t("stock.noUpcoming", { to: formatDate(dividends.to) })} - - ); - } - } - - let pastDividendsContent; - - if (pastDividends.length > 0) { - pastDividendsContent = ( - - {pastDividends.map(event => ( - - -
- - {t("stock.record")} - - - - {event.recordDate - ? formatDate(event.recordDate) - : t("stock.dateUnknown")} - - - {!!event.paymentDate && ( - - {t("stock.payment")}:{" "} - {formatDate(event.paymentDate)} - - )} -
- -
- - {formatMoney(event.perShare)} - - - - {t("stock.perShare")} - -
-
-
- ))} -
- ); - } - else { - pastDividendsContent = {t("stock.noDividends")}; - } - return ( {!!dividends && ( - {!!( - dividends.estimateUnavailable || dividends.positionIsStale - ) && ( + {(dividends.estimateUnavailable || dividends.positionIsStale) && ( {t("stock.estimateUnavailable")} )} - {upcomingContent} + {upcoming.length > 0 + ? upcoming.map(event => ( + + )) + : {t("stock.noUpcoming", { to: formatDate(dividends.to) })}} - - {t("stock.dividendNote")} - + {t("stock.dividendNote")} {!!dividends.positionAt && ( - {t("stock.snapshotAt", { - date: formatSnapshotTime(dividends.positionAt), - })} + {t("stock.snapshotAt", { date: formatSnapshotTime(dividends.positionAt) })} )} @@ -210,7 +108,24 @@ const StockDividends: FC = (props) => { })} - {pastDividendsContent} + {pastDividends.length > 0 + ? ( + + {pastDividends.map(event => ( + + ))} + + ) + : {t("stock.noDividends")}} diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index b8bfffb..cc9bfc5 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -1107,16 +1107,6 @@ export interface components { data: components["schemas"]["IncomeSummaryDto"] | null; meta: components["schemas"]["InvestmentResourceMetaDto"]; }; - IncomeComparisonCurrencyDto: { - currency: string; - selected: components["schemas"]["IncomeCurrencySummaryDto"]; - compared: components["schemas"]["IncomeCurrencySummaryDto"]; - difference: components["schemas"]["IncomeCurrencySummaryDto"]; - }; - IncomeComparisonDto: { - period: components["schemas"]["IncomePeriodDto"]; - totalsByCurrency: components["schemas"]["IncomeComparisonCurrencyDto"][]; - }; IncomeBucketDto: { period: components["schemas"]["IncomePeriodDto"]; totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][]; @@ -1160,8 +1150,6 @@ export interface components { totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][]; /** Format: date-time */ calculatedAt: string; - previous: components["schemas"]["IncomeComparisonDto"] | null; - yearComparison: components["schemas"]["IncomeComparisonDto"] | null; /** @enum {string} */ bucketGranularity: "day" | "week" | "month"; buckets: components["schemas"]["IncomeBucketDto"][]; @@ -1183,7 +1171,7 @@ export interface components { lastSuccessfulAt: string | null; lastAttempt: components["schemas"]["InvestmentAttemptDto"]; /** @enum {string} */ - role: "selected" | "previous" | "year"; + role: "selected"; }; IncomeAnalyticsMetaDto: { dataRevision: string; diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index d372b0b..dd7c7c2 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -6706,47 +6706,6 @@ "meta" ] }, - "IncomeComparisonCurrencyDto": { - "type": "object", - "properties": { - "currency": { - "type": "string" - }, - "selected": { - "$ref": "#/components/schemas/IncomeCurrencySummaryDto" - }, - "compared": { - "$ref": "#/components/schemas/IncomeCurrencySummaryDto" - }, - "difference": { - "$ref": "#/components/schemas/IncomeCurrencySummaryDto" - } - }, - "required": [ - "currency", - "selected", - "compared", - "difference" - ] - }, - "IncomeComparisonDto": { - "type": "object", - "properties": { - "period": { - "$ref": "#/components/schemas/IncomePeriodDto" - }, - "totalsByCurrency": { - "type": "array", - "items": { - "$ref": "#/components/schemas/IncomeComparisonCurrencyDto" - } - } - }, - "required": [ - "period", - "totalsByCurrency" - ] - }, "IncomeBucketDto": { "type": "object", "properties": { @@ -6948,24 +6907,6 @@ "type": "string", "format": "date-time" }, - "previous": { - "nullable": true, - "type": "object", - "allOf": [ - { - "$ref": "#/components/schemas/IncomeComparisonDto" - } - ] - }, - "yearComparison": { - "nullable": true, - "type": "object", - "allOf": [ - { - "$ref": "#/components/schemas/IncomeComparisonDto" - } - ] - }, "bucketGranularity": { "type": "string", "enum": [ @@ -6998,8 +6939,6 @@ "period", "totalsByCurrency", "calculatedAt", - "previous", - "yearComparison", "bucketGranularity", "buckets", "instruments", @@ -7056,9 +6995,7 @@ "role": { "type": "string", "enum": [ - "selected", - "previous", - "year" + "selected" ] } }, diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 4883bac..e780eb3 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -340,13 +340,8 @@ const ru = { }, income: { apply: "Применить", - comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.", - comparisonFailed: - "Не удалось загрузить период сравнения. Повторите обновление.", - comparisonPending: "Период сравнения ещё не загружен полностью.", coupon: "Купоны", datePlaceholder: "ДД.ММ.ГГГГ", - difference: "Разница", dividend: "Дивиденды", emptyText: "За выбранный период подходящих исполненных выплат нет.", emptyTitle: "Купоны и дивиденды не найдены", @@ -356,8 +351,6 @@ const ru = { open: "Открыть пассивный доход", payments: "Выплаты", period: "Период", - previousPeriod: "Предыдущий период", - previousUnavailable: "Сравнение с предыдущим периодом недоступно.", refreshError: "Не удалось обновить пассивный доход. Показан последний загруженный результат.", refreshing: "Обновляем пассивный доход", @@ -378,8 +371,6 @@ const ru = { period: "Укажите корректный период без будущих дат: начало не позже окончания.", }, - yearAgo: "Годом ранее", - yearUnavailable: "Сравнение с годом ранее недоступно.", }, investment: { attempt: { @@ -459,6 +450,14 @@ const ru = { stocks: "Акции", }, notFound: { action: "К счетам", title: "Страница не найдена" }, + payout: { + estimate: "Оценка по позиции", + executedAt: "Исполнено {{date}}", + forecast: "Прогноз", + noAmount: "Сумма неизвестна", + noMoney: "Без движения денег", + registryDate: "Дата реестра", + }, portfolio: { accountUnavailableText: "Вернитесь к списку и выберите доступный счет.", accountUnavailableTitle: "Счет недоступен", diff --git a/src/shared/ui/PayoutCard/PayoutCard.test.tsx b/src/shared/ui/PayoutCard/PayoutCard.test.tsx new file mode 100644 index 0000000..d1803ea --- /dev/null +++ b/src/shared/ui/PayoutCard/PayoutCard.test.tsx @@ -0,0 +1,85 @@ +import type { FC } from "react"; + +import { MantineProvider } from "@mantine/core"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { PayoutCard } from "./PayoutCard"; + +const Wrapper: FC<{ children: React.ReactNode }> = props => ( + {props.children} +); + +describe("PayoutCard", () => { + it("renders an executed payout with signed source money", () => { + render( + + + , + ); + expect(screen.getByText("−10,25 ₽")).toBeInTheDocument(); + expect(screen.getByText(/Исполнено 01.08.2026 12:00/)).toBeInTheDocument(); + }); + + it("keeps zero distinct from an unknown executed amount", () => { + render( + + + , + ); + expect(screen.getByText("0,000 ₽")).toBeInTheDocument(); + expect(screen.getByText("Без движения денег")).toBeInTheDocument(); + }); + + it("marks a forecast payout and a dividend registry date without inventing an amount", () => { + render( + + + , + ); + expect(screen.getByText("Прогноз")).toBeInTheDocument(); + expect(screen.getByText(/15.08.2026 · Дата реестра/)).toBeInTheDocument(); + expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument(); + }); + + it("shows a schedule payout with per-security label and separate estimate", () => { + render( + + + , + ); + expect(screen.getByText("12,5 ₽")).toBeInTheDocument(); + expect(screen.getByText("На облигацию")).toBeInTheDocument(); + expect(screen.getByText("Оценка по позиции")).toBeInTheDocument(); + expect(screen.getByText("125 ₽")).toBeInTheDocument(); + }); +}); diff --git a/src/shared/ui/PayoutCard/PayoutCard.tsx b/src/shared/ui/PayoutCard/PayoutCard.tsx new file mode 100644 index 0000000..97745f4 --- /dev/null +++ b/src/shared/ui/PayoutCard/PayoutCard.tsx @@ -0,0 +1,166 @@ +import type { FC, ReactNode } from "react"; + +import { Group, Text } from "@mantine/core"; +import { EntryCard } from "@wealthledger/ui"; +import { useTranslation } from "react-i18next"; + +import { formatMoney } from "@/shared/lib"; + +type ExecutedPayout = { + details?: ReactNode; + extraMetadata?: string; + imageSrc?: null | string; + initial: string; + kind: "executed"; + occurredAt: string; + payment: null | PayoutMoney; + title: string; + typeLabel: string; + wrap?: boolean; +}; + +type ForecastPayout = { + date: string; + dateKind?: "event" | "registry"; + extraMetadata?: string; + imageSrc?: null | string; + initial: string; + kind: "forecast"; + projectedAmount: null | PayoutMoney; + title: string; +}; + +type PayoutCardProps + = | ExecutedPayout + | ForecastPayout + | SchedulePayout; + +type PayoutMoney = { currency: string; value: string }; + +type PayoutTone + = | "negative" + | "neutral" + | "positive"; + +type SchedulePayout = { + conditions?: ReactNode; + estimate: null | PayoutMoney; + imageSrc?: null | string; + initial: string; + kind: "schedule"; + metadata: string; + perSecurity: null | PayoutMoney; + perSecurityLabel: string; + title: string; +}; + +const PayoutCard: FC = (props) => { + const { t } = useTranslation(); + + const common = { + imageSrc: props.imageSrc, + initial: props.initial, + title: props.title, + wrap: true, + }; + + if (props.kind === "executed") { + const payment = props.payment; + const presentation = executedPresentation(payment); + const isKnown = payment !== null && !isZeroDecimal(payment.value); + + const metadata = [ + t("payout.executedAt", { date: props.occurredAt }), + props.extraMetadata, + ].filter(Boolean).join(" · "); + + const valueLabel = isKnown ? props.typeLabel : t("payout.noMoney"); + + return ( + + ); + } + + if (props.kind === "forecast") { + const metadata = [ + props.date, + props.dateKind === "registry" ? t("payout.registryDate") : null, + props.extraMetadata, + ].filter(Boolean).join(" · "); + + const value = props.projectedAmount + ? formatMoney(props.projectedAmount) + : t("payout.noAmount"); + + return ( + + ); + } + + const details = ( + <> + {props.estimate + ? ( + + {t("payout.estimate")} + + {formatMoney(props.estimate)} + + ) + : null} + {props.conditions} + + ); + + const value = props.perSecurity + ? formatMoney(props.perSecurity) + : t("payout.noAmount"); + + return ( + + ); +}; + +function executedPresentation(payment: null | PayoutMoney): { + amount: string; + tone: PayoutTone; +} { + if (!payment) return { amount: "—", tone: "neutral" }; + if (isZeroDecimal(payment.value)) + return { amount: formatMoney(payment), tone: "neutral" }; + if (payment.value.startsWith("-")) + return { + amount: `−${formatMoney({ ...payment, value: payment.value.slice(1) })}`, + tone: "negative", + }; + + return { amount: `+${formatMoney(payment)}`, tone: "positive" }; +} + +function isZeroDecimal(value: string): boolean { + // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal check over a bounded Money string, with no nested repetition. + return /^-?0(?:\.0+)?$/.test(value); +} + +export { PayoutCard }; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 98b77c7..e887af9 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -1 +1,2 @@ +export { PayoutCard } from "./PayoutCard/PayoutCard"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";