diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes.tsx index f8dc426..8b2b8bb 100644 --- a/src/app/routes/AppRoutes.tsx +++ b/src/app/routes/AppRoutes.tsx @@ -17,6 +17,10 @@ const AccountOperationsPage = lazy(async () => { const { AccountOperationsPage } = await import("@/pages/account-operations"); return { default: AccountOperationsPage }; }); +const AccountIncomePage = lazy(async () => { + const { AccountIncomePage } = await import("@/pages/account-income"); + return { default: AccountIncomePage }; +}); const AccountsPage = lazy(async () => { const { AccountsPage } = await import("@/pages/accounts"); return { default: AccountsPage }; @@ -188,6 +192,14 @@ const AppRoutes: FC = () => { } /> + + + + } + /> = (props) => { }; const AccountBreadcrumbs: FC = (props) => { - const { accountId, events, operations } = props; + const { accountId, events, income, operations } = props; const { data: accounts } = useBrokerageAccounts(); const { t } = useTranslation(); const accountName = @@ -66,10 +67,16 @@ const AccountBreadcrumbs: FC = (props) => { { label: accountName, to: accountPath }, { label: t("breadcrumbs.operations") }, ] - : [ - { label: t("breadcrumbs.accounts"), to: "/accounts" }, - { label: accountName }, - ] + : income + ? [ + { label: t("breadcrumbs.accounts"), to: "/accounts" }, + { label: accountName, to: accountPath }, + { label: t("breadcrumbs.income") }, + ] + : [ + { label: t("breadcrumbs.accounts"), to: "/accounts" }, + { label: accountName }, + ] } /> ); @@ -85,6 +92,10 @@ const AppBreadcrumbs: FC = () => { path: "/accounts/:accountId/events", end: true, }); + const accountIncome = useMatch({ + path: "/accounts/:accountId/income", + end: true, + }); const account = useMatch({ path: "/accounts/:accountId", end: true }); const accounts = useMatch({ path: "/accounts", end: true }); const settings = useMatch({ path: "/settings", end: true }); @@ -94,6 +105,7 @@ const AppBreadcrumbs: FC = () => { ); @@ -102,6 +114,16 @@ const AppBreadcrumbs: FC = () => { + ); + if (accountIncome) + return ( + ); @@ -110,6 +132,7 @@ const AppBreadcrumbs: FC = () => { ); diff --git a/src/entities/income/api/useIncome.ts b/src/entities/income/api/useIncome.ts new file mode 100644 index 0000000..89d2599 --- /dev/null +++ b/src/entities/income/api/useIncome.ts @@ -0,0 +1,49 @@ +import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; +import type { paths } from "@/shared/api/generated/schema"; + +type IncomeSummary = + paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]; +type IncomeAnalytics = + paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]; + +function useIncomeSummary(accountId: string | undefined) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions( + "get", + "/api/v1/brokerage-accounts/{accountId}/income-summary", + { params: { path: { accountId: accountId ?? "" } } }, + ), + enabled: Boolean(accountId), + staleTime: 30_000, + retry: false, + }); +} + +function useIncomeAnalytics( + accountId: string | undefined, + period: { from: string; to: string }, +) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions( + "get", + "/api/v1/brokerage-accounts/{accountId}/income-analytics", + { + params: { + path: { accountId: accountId ?? "" }, + query: period, + }, + }, + ), + enabled: Boolean(accountId), + staleTime: 30_000, + retry: false, + refetchOnWindowFocus: false, + }); +} + +export { + type IncomeAnalytics, + type IncomeSummary, + useIncomeAnalytics, + useIncomeSummary, +}; diff --git a/src/entities/income/index.ts b/src/entities/income/index.ts new file mode 100644 index 0000000..a277e6d --- /dev/null +++ b/src/entities/income/index.ts @@ -0,0 +1,6 @@ +export { + type IncomeAnalytics, + type IncomeSummary, + useIncomeAnalytics, + useIncomeSummary, +} from "./api/useIncome"; diff --git a/src/pages/account-income/index.ts b/src/pages/account-income/index.ts new file mode 100644 index 0000000..e93bb37 --- /dev/null +++ b/src/pages/account-income/index.ts @@ -0,0 +1 @@ +export { AccountIncomePage } from "./ui/AccountIncomePage"; diff --git a/src/pages/account-income/ui/AccountIncomePage.module.css b/src/pages/account-income/ui/AccountIncomePage.module.css new file mode 100644 index 0000000..dae1904 --- /dev/null +++ b/src/pages/account-income/ui/AccountIncomePage.module.css @@ -0,0 +1,51 @@ +.trendChart { + align-items: stretch; + display: flex; + gap: var(--mantine-spacing-xs); + height: 160px; + overflow-x: auto; + padding: 0 var(--mantine-spacing-xs); +} + +.trendColumn { + flex: 1 0 32px; + min-width: 32px; + position: relative; +} + +.baseline { + background: var(--mantine-color-gray-4); + height: 1px; + left: 0; + position: absolute; + right: 0; + top: 50%; +} + +.positiveStack, +.negativeStack { + display: flex; + left: 25%; + position: absolute; + right: 25%; +} + +.positiveStack { + bottom: 50%; + flex-direction: column-reverse; + top: 0; +} + +.negativeStack { + bottom: 0; + flex-direction: column; + top: 50%; +} + +.coupon { + background: var(--mantine-color-blue-5); +} + +.dividend { + background: var(--mantine-color-grape-5); +} diff --git a/src/pages/account-income/ui/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage.test.tsx new file mode 100644 index 0000000..e31ce97 --- /dev/null +++ b/src/pages/account-income/ui/AccountIncomePage.test.tsx @@ -0,0 +1,234 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import type { FC, PropsWithChildren } from "react"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { configureAuthClient } from "@/shared/api"; +import { server } from "@/test/server"; +import { AccountIncomePage } from "./AccountIncomePage"; + +const analytics = { + accountId: "account-1", + period: { from: "2026-08-01", to: "2026-08-27" }, + calculatedAt: "2026-08-27T00:00:00Z", + totalsByCurrency: [summary("RUB", "10", "2")], + previous: { + period: { from: "2026-07-01", to: "2026-07-31" }, + totalsByCurrency: [comparison("RUB")], + }, + yearComparison: null, + bucketGranularity: "day", + buckets: [ + { + period: { from: "2026-08-01", to: "2026-08-01" }, + totalsByCurrency: [summary("RUB", "10", "2")], + }, + ], + instruments: [ + { + key: "figi:FIGI:RUB", + figi: "FIGI", + instrumentUid: null, + ticker: "BOND", + instrumentType: "bond", + displayName: "BOND", + coupon: money("RUB", "10"), + dividend: money("RUB", "2"), + total: money("RUB", "12"), + receiptCount: 1, + }, + ], + entries: [entry()], +}; + +function money(currency: string, value: string) { + return { currency, value }; +} + +function summary(currency: string, coupon: string, dividend: string) { + return { + currency, + coupon: money(currency, coupon), + dividend: money(currency, dividend), + total: money(currency, String(Number(coupon) + Number(dividend))), + }; +} + +function comparison(currency: string) { + return { + currency, + selected: summary(currency, "10", "2"), + compared: summary(currency, "1", "0"), + difference: summary(currency, "9", "2"), + }; +} + +function entry() { + return { + id: "coupon-1", + occurredAt: "2026-08-01T12:00:00Z", + type: "OPERATION_TYPE_COUPON", + category: "income", + description: "Купон", + figi: "FIGI", + instrumentUid: null, + ticker: "BOND", + instrumentType: "bond", + logoName: null, + payment: money("RUB", "10"), + quantity: null, + price: null, + accruedInterest: null, + commission: null, + }; +} + +function renderPage() { + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); + const Wrapper: FC = (props) => ( + + + {props.children} + + + ); + return render( + + + + } + /> + Events} /> + + + , + ); +} + +describe("AccountIncomePage", () => { + afterEach(() => { + cleanup(); + configureAuthClient(null); + }); + + it("renders realized analytics, drill-down, and the independent forecast", async () => { + 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"), + }, + ], + }), + ), + ); + renderPage(); + await waitFor(() => + expect( + screen.getByRole("heading", { name: "Пассивный доход" }), + ).toBeInTheDocument(), + ); + expect( + screen.getByText("Налоговые операции не включены в этот результат."), + ).toBeInTheDocument(); + expect(screen.getByText("Ближайший прогноз")).toBeInTheDocument(); + expect(screen.getByText("BOND · 12 ₽ · 1")).toBeInTheDocument(); + expect( + screen.getByText( + "Выбранный период: Купоны 10 ₽ · Дивиденды 2 ₽ · Итого 12 ₽", + ), + ).toBeInTheDocument(); + expect( + screen.getByText("Разница: Купоны 9 ₽ · Дивиденды 2 ₽ · Итого 11 ₽"), + ).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( + "*/api/v1/brokerage-accounts/account-1/income-analytics", + () => new HttpResponse(null, { status: 503 }), + ), + ); + renderPage(); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Повторить" }), + ).toBeInTheDocument(), + ); + }); +}); diff --git a/src/pages/account-income/ui/AccountIncomePage.tsx b/src/pages/account-income/ui/AccountIncomePage.tsx new file mode 100644 index 0000000..4c94a76 --- /dev/null +++ b/src/pages/account-income/ui/AccountIncomePage.tsx @@ -0,0 +1,17 @@ +import type { FC } from "react"; +import { + AccountIncomePageError, + AccountIncomePageLoaded, + AccountIncomePageLoading, +} from "./AccountIncomePage.view"; +import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm"; + +const AccountIncomePage: FC = () => { + const viewModel = useAccountIncomePageViewModel(); + if (viewModel.state === "loading") return ; + if (viewModel.state === "error") + return ; + return ; +}; + +export { AccountIncomePage }; diff --git a/src/pages/account-income/ui/AccountIncomePage.view.tsx b/src/pages/account-income/ui/AccountIncomePage.view.tsx new file mode 100644 index 0000000..f9a923b --- /dev/null +++ b/src/pages/account-income/ui/AccountIncomePage.view.tsx @@ -0,0 +1,388 @@ +import { + Accordion, + Alert, + Button, + Group, + Paper, + SegmentedControl, + Skeleton, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import type { FC } from "react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { LedgerEntryRow } from "@/entities/broker-operation"; +import type { BrokerageEvent } from "@/entities/brokerage-event"; +import type { IncomeAnalytics } from "@/entities/income"; +import { formatMoney } from "@/entities/portfolio"; +import { LoadingSkeleton, StateView } from "@/shared/ui"; +import styles from "./AccountIncomePage.module.css"; +import type { AccountIncomePageViewModel } from "./AccountIncomePage.vm"; + +type LoadedProps = Extract; +type ErrorProps = Extract; + +const AccountIncomePageLoading: FC = () => { + const { t } = useTranslation(); + return ( + + + + + + ); +}; + +const AccountIncomePageError: FC = (props) => { + const { t } = useTranslation(); + return ( + {t("income.retry")}} + /> + ); +}; + +const AccountIncomePageLoaded: FC = (props) => { + const { analytics, form, forecast, onSubmit, onReset } = props; + const { t } = useTranslation(); + return ( + + {t("income.title")} +
+ + + + + + +
+ + {analytics.period.from} — {analytics.period.to} + + {analytics.totalsByCurrency.length ? ( + + ) : ( + + )} + {t("income.taxBoundary")} + + + + +
+ ); +}; + +const IncomeSummary: FC<{ totals: IncomeAnalytics["totalsByCurrency"] }> = ( + props, +) => { + const { t } = useTranslation(); + return ( + + {props.totals.map((total) => ( + + {formatMoney(total.total)} + + {t("income.coupon")}: {formatMoney(total.coupon)} ·{" "} + {t("income.dividend")}: {formatMoney(total.dividend)} + + + ))} + + ); +}; + +const IncomeComparisons: FC<{ analytics: IncomeAnalytics }> = (props) => { + const { t } = useTranslation(); + return ( + + {t("income.comparisons")} + + {props.analytics.yearComparison ? ( + + ) : ( + {t("income.yearUnavailable")} + )} + + ); +}; + +const Comparison: FC<{ + title: string; + values: IncomeAnalytics["previous"]["totalsByCurrency"]; +}> = (props) => { + const { t } = useTranslation(); + return ( + + {props.title} + {props.values.map((value) => ( + + {value.currency} + + {t("income.selected")}: {t("income.coupon")}{" "} + {formatMoney(value.selected.coupon)} · {t("income.dividend")}{" "} + {formatMoney(value.selected.dividend)} · {t("income.total")}{" "} + {formatMoney(value.selected.total)} + + + {t("income.compared")}: {t("income.coupon")}{" "} + {formatMoney(value.compared.coupon)} · {t("income.dividend")}{" "} + {formatMoney(value.compared.dividend)} · {t("income.total")}{" "} + {formatMoney(value.compared.total)} + + + {t("income.difference")}: {t("income.coupon")}{" "} + {formatMoney(value.difference.coupon)} · {t("income.dividend")}{" "} + {formatMoney(value.difference.dividend)} · {t("income.total")}{" "} + {formatMoney(value.difference.total)} + + + ))} + + ); +}; + +const IncomeTrend: FC<{ analytics: IncomeAnalytics }> = (props) => { + const currencies = props.analytics.totalsByCurrency.map( + (total) => total.currency, + ); + const [currency, setCurrency] = useState(currencies[0] ?? ""); + const { t } = useTranslation(); + const buckets = props.analytics.buckets.map((bucket) => ({ + period: bucket.period, + total: + bucket.totalsByCurrency.find((item) => item.currency === currency) ?? + zeroSummary(currency), + })); + 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 ?? null)}, {t("income.dividend")}{" "} + {formatMoney(bucket.total?.dividend ?? null)} + + ))} + + + ); +}; + +const IncomeBreakdown: FC<{ analytics: IncomeAnalytics }> = (props) => { + const { t } = useTranslation(); + return ( + + {t("income.instruments")} + + {props.analytics.instruments.map((instrument) => ( + + + {instrument.displayName} · {formatMoney(instrument.total)} ·{" "} + {instrument.receiptCount} + + + + {t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "} + {t("income.dividend")}: {formatMoney(instrument.dividend)} + + + {props.analytics.entries + .filter((entry) => entryKey(entry) === instrument.key) + .map((entry) => ( + + ))} + + + + ))} + + + ); +}; + +const IncomeForecast: FC<{ forecast: LoadedProps["forecast"] }> = (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<{ event: BrokerageEvent }> = (props) => ( + + {props.event.displayName} + + {props.event.date} · {formatMoney(props.event.projectedAmount)} + + +); + +function entryKey(entry: IncomeAnalytics["entries"][number]): string { + const base = entry.instrumentUid + ? `uid:${entry.instrumentUid}` + : entry.figi + ? `figi:${entry.figi}` + : entry.ticker || entry.instrumentType + ? `ticker:${entry.ticker ?? ""}:${entry.instrumentType ?? ""}` + : `operation:${entry.id}`; + return `${base}:${entry.payment?.currency ?? ""}`; +} + +function zeroSummary( + currency: string, +): IncomeAnalytics["totalsByCurrency"][number] { + const zero = { currency, value: "0" }; + return { currency, coupon: zero, dividend: zero, total: zero }; +} + +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 { + AccountIncomePageError, + AccountIncomePageLoaded, + AccountIncomePageLoading, +}; diff --git a/src/pages/account-income/ui/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage.vm.ts new file mode 100644 index 0000000..842e188 --- /dev/null +++ b/src/pages/account-income/ui/AccountIncomePage.vm.ts @@ -0,0 +1,147 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useMemo } 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 AccountIncomePageViewModel = + | { state: "loading" } + | { state: "error"; retry: () => void; period: IncomePeriod } + | { + state: "loaded"; + analytics: IncomeAnalytics; + forecast: IncomeForecast; + 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 useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { + const { accountId } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); + const requested = useMemo( + () => ({ + from: searchParams.get("from") ?? "", + to: searchParams.get("to") ?? "", + }), + [searchParams], + ); + const period = useMemo( + () => (validPeriod(requested) ? requested : defaultPeriod()), + [requested], + ); + const form = useForm({ + defaultValues: period, + resolver: zodResolver(incomeFormSchema), + }); + 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]); + + const onSubmit = (values: IncomeForm) => setSearchParams(values); + const onReset = () => setSearchParams(defaultPeriod(), { replace: true }); + if (analytics.isLoading || !analytics.data) { + if (analytics.isError) + return { state: "error", retry: () => analytics.refetch(), period }; + return { state: "loading" }; + } + return { + state: "loaded", + analytics: analytics.data, + forecast: toForecast(events), + 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 defaultPeriod(): IncomePeriod { + const today = moscowDate(new Date()); + return { from: `${today.slice(0, 7)}-01`, to: today }; +} + +function validPeriod(period: IncomePeriod): period is IncomePeriod { + if (!isDate(period.from) || !isDate(period.to)) return false; + const from = new Date(`${period.from}T00:00:00Z`); + const to = new Date(`${period.to}T00:00:00Z`); + const today = new Date(`${moscowDate(new Date())}T00:00:00Z`); + return ( + from.toISOString().slice(0, 10) === period.from && + to.toISOString().slice(0, 10) === period.to && + from <= to && + to <= today && + (to.valueOf() - from.valueOf()) / 86_400_000 + 1 <= 366 + ); +} + +function isDate(value: string): boolean { + return ( + datePattern.test(value) && + new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value + ); +} + +function moscowDate(value: Date): string { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Moscow", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(value); + const part = (type: string) => + parts.find((item) => item.type === type)?.value; + return `${part("year")}-${part("month")}-${part("day")}`; +} + +export { type AccountIncomePageViewModel, useAccountIncomePageViewModel }; diff --git a/src/pages/account/ui/AccountPage.tsx b/src/pages/account/ui/AccountPage.tsx index 57da3f5..d7189f1 100644 --- a/src/pages/account/ui/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage.tsx @@ -20,6 +20,7 @@ const AccountPage: FC = () => { retry={viewModel.retry} recentOperations={viewModel.recentOperations} recentEvents={viewModel.recentEvents} + incomeSummary={viewModel.incomeSummary} /> ); return ( @@ -32,6 +33,7 @@ const AccountPage: FC = () => { retry={viewModel.retry} recentOperations={viewModel.recentOperations} recentEvents={viewModel.recentEvents} + incomeSummary={viewModel.incomeSummary} /> ); }; diff --git a/src/pages/account/ui/AccountPage.view.tsx b/src/pages/account/ui/AccountPage.view.tsx index f33b415..6d4a3e6 100644 --- a/src/pages/account/ui/AccountPage.view.tsx +++ b/src/pages/account/ui/AccountPage.view.tsx @@ -8,6 +8,7 @@ import { formatSnapshotTime } from "@/entities/portfolio"; import { LoadingSkeleton, StateView } from "@/shared/ui"; import classes from "./AccountPage.module.css"; import type { + AccountPageIncomeSummary, AccountPagePositionSection, AccountPageRecentEvents, AccountPageRecentOperations, @@ -15,6 +16,7 @@ import type { import { AccountHeading, CashBalances, + IncomeSummary, PortfolioRefreshState, PortfolioTotals, PositionSection, @@ -34,6 +36,7 @@ type AccountPageEmptyProps = { retry?: () => void; recentOperations: AccountPageRecentOperations; recentEvents: AccountPageRecentEvents; + incomeSummary?: AccountPageIncomeSummary; }; type AccountPageLoadedProps = { @@ -45,6 +48,7 @@ type AccountPageLoadedProps = { retry?: () => void; recentOperations: AccountPageRecentOperations; recentEvents: AccountPageRecentEvents; + incomeSummary?: AccountPageIncomeSummary; }; const AccountPageLoading: FC = () => { @@ -117,6 +121,7 @@ const AccountPageEmpty: FC = (props) => { retry, recentOperations, recentEvents, + incomeSummary, } = props; const { t } = useTranslation(); @@ -134,6 +139,7 @@ const AccountPageEmpty: FC = (props) => { /> + {incomeSummary ? : null} ); }; @@ -148,6 +154,7 @@ const AccountPageLoaded: FC = (props) => { retry, recentOperations, recentEvents, + incomeSummary, } = props; const { t } = useTranslation(); @@ -175,6 +182,7 @@ const AccountPageLoaded: FC = (props) => { "cash", "recentOperations", "recentEvents", + "incomeSummary", ]} classNames={{ control: classes.accordionControl, @@ -211,6 +219,12 @@ const AccountPageLoaded: FC = (props) => { + + {t("income.summaryTitle")} + + {incomeSummary ? : null} + + ); diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts index da7a958..88914b1 100644 --- a/src/pages/account/ui/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage.vm.ts @@ -10,6 +10,7 @@ import { type BrokerageEvent, useBrokerageEvents, } from "@/entities/brokerage-event"; +import { type IncomeSummary, useIncomeSummary } from "@/entities/income"; import type { Portfolio } from "@/entities/portfolio"; import { formatMoney, @@ -57,6 +58,11 @@ type AccountPageRecentEvents = items: BrokerageEvent[]; }; +type AccountPageIncomeSummary = + | { state: "loading" | "empty"; to: string } + | { state: "error"; to: string; retry: () => void } + | { state: "loaded"; to: string; summary: IncomeSummary }; + type AccountPageContent = { account?: BrokerageAccount; accountId?: string; @@ -65,6 +71,7 @@ type AccountPageContent = { retry: () => void; recentOperations: AccountPageRecentOperations; recentEvents: AccountPageRecentEvents; + incomeSummary: AccountPageIncomeSummary; }; type AccountPageLoadedViewModel = AccountPageContent & { @@ -90,11 +97,13 @@ function useAccountPageViewModel(): AccountPageViewModel { limit: 5, }); const events = useBrokerageEvents(accountId); + const income = useIncomeSummary(accountId); const account = useQueryClient() .getQueryData(brokerageAccountsQueryKey) ?.find((item) => item.id === accountId); const recentOperations = toRecentOperations(accountId, operations); const recentEvents = toRecentEvents(accountId, events); + const incomeSummary = toIncomeSummary(accountId, income); if (!portfolio.data) { if (portfolio.isError) return { @@ -116,6 +125,7 @@ function useAccountPageViewModel(): AccountPageViewModel { accountId, recentOperations, recentEvents, + incomeSummary, ...refresh, }; return { @@ -126,10 +136,23 @@ function useAccountPageViewModel(): AccountPageViewModel { positionSections: toAccountPagePositionSections(portfolio.data), recentOperations, recentEvents, + incomeSummary, ...refresh, }; } +function toIncomeSummary( + accountId: string | undefined, + income: ReturnType, +): AccountPageIncomeSummary { + const to = `/accounts/${accountId}/income`; + if (income.isLoading) return { state: "loading", to }; + if (income.isError) + return { state: "error", to, retry: () => income.refetch() }; + if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; + return { state: "loaded", to, summary: income.data }; +} + function toRecentOperations( accountId: string | undefined, operations: ReturnType, @@ -223,6 +246,7 @@ function toPosition( } export { + type AccountPageIncomeSummary, type AccountPagePosition, type AccountPagePositionSection, type AccountPageRecentEvents, diff --git a/src/pages/account/ui/components/IncomeSummary.view.tsx b/src/pages/account/ui/components/IncomeSummary.view.tsx new file mode 100644 index 0000000..e9ab16d --- /dev/null +++ b/src/pages/account/ui/components/IncomeSummary.view.tsx @@ -0,0 +1,57 @@ +import { Button, Paper, Stack, Text } from "@mantine/core"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import type { IncomeSummary as IncomeSummaryData } from "@/entities/income"; +import { formatMoney } from "@/entities/portfolio"; +import { StateView } from "@/shared/ui"; + +type IncomeSummaryProps = + | { state: "loading"; to: string } + | { state: "error"; to: string; retry: () => void } + | { state: "empty"; to: string } + | { state: "loaded"; to: string; summary: IncomeSummaryData }; + +const IncomeSummary: FC = (props) => { + const { t } = useTranslation(); + if (props.state === "loading") + return {t("income.summaryLoading")}; + if (props.state === "error") + return ( + {t("income.summaryRetry")} + } + /> + ); + if (props.state === "empty") + return ( + + {t("income.summaryEmpty")} + + + ); + return ( + + {props.summary.totalsByCurrency.map((total) => ( + + {formatMoney(total.total)} + + {t("income.coupon")}: {formatMoney(total.coupon)} ·{" "} + {t("income.dividend")}: {formatMoney(total.dividend)} + + + ))} + + + ); +}; + +export { IncomeSummary }; diff --git a/src/pages/account/ui/components/index.ts b/src/pages/account/ui/components/index.ts index 90d7ece..831deba 100644 --- a/src/pages/account/ui/components/index.ts +++ b/src/pages/account/ui/components/index.ts @@ -1,5 +1,6 @@ export { AccountHeading } from "./AccountHeading.view"; export { CashBalances } from "./CashBalances.view"; +export { IncomeSummary } from "./IncomeSummary.view"; export { PortfolioRefreshState } from "./PortfolioRefreshState.view"; export { PortfolioTotals } from "./PortfolioTotals.view"; export { PositionSection } from "./PositionSection.view"; diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 7a06e7d..469804f 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -164,6 +164,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/brokerage-accounts/{accountId}/income-summary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["IncomeAnalyticsController_summary"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/brokerage-accounts/{accountId}/income-analytics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["IncomeAnalyticsController_analytics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -287,6 +319,66 @@ export interface components { /** Format: date-time */ calculatedAt: string; events: components["schemas"]["UpcomingEventDto"][]; + incomeTotalsByCurrency: components["schemas"]["MoneyDto"][]; + }; + IncomePeriodDto: { + /** Format: date */ + from: string; + /** Format: date */ + to: string; + }; + IncomeCurrencySummaryDto: { + currency: string; + coupon: components["schemas"]["MoneyDto"]; + dividend: components["schemas"]["MoneyDto"]; + total: components["schemas"]["MoneyDto"]; + }; + IncomeSummaryDto: { + accountId: string; + period: components["schemas"]["IncomePeriodDto"]; + totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][]; + /** Format: date-time */ + calculatedAt: string; + }; + 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"][]; + }; + IncomeInstrumentDto: { + key: string; + figi: string | null; + instrumentUid: string | null; + ticker: string | null; + instrumentType: string | null; + displayName: string; + coupon: components["schemas"]["MoneyDto"]; + dividend: components["schemas"]["MoneyDto"]; + total: components["schemas"]["MoneyDto"]; + receiptCount: number; + }; + IncomeAnalyticsDto: { + accountId: string; + period: components["schemas"]["IncomePeriodDto"]; + totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][]; + /** Format: date-time */ + calculatedAt: string; + previous: components["schemas"]["IncomeComparisonDto"]; + yearComparison: components["schemas"]["IncomeComparisonDto"] | null; + /** @enum {string} */ + bucketGranularity: "day" | "week" | "month"; + buckets: components["schemas"]["IncomeBucketDto"][]; + instruments: components["schemas"]["IncomeInstrumentDto"][]; + entries: components["schemas"]["BrokerageOperationDto"][]; }; }; responses: never; @@ -1085,4 +1177,139 @@ export interface operations { }; }; }; + IncomeAnalyticsController_summary: { + parameters: { + query?: never; + header?: never; + path: { + accountId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IncomeSummaryDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + IncomeAnalyticsController_analytics: { + parameters: { + query?: { + from?: string; + to?: string; + }; + header?: never; + path: { + accountId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IncomeAnalyticsDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; } diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index 50654ce..918fe85 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -990,6 +990,194 @@ "brokerage-accounts" ] } + }, + "/api/v1/brokerage-accounts/{accountId}/income-summary": { + "get": { + "operationId": "IncomeAnalyticsController_summary", + "parameters": [ + { + "name": "accountId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IncomeSummaryDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "brokerage-accounts" + ] + } + }, + "/api/v1/brokerage-accounts/{accountId}/income-analytics": { + "get": { + "operationId": "IncomeAnalyticsController_analytics", + "parameters": [ + { + "name": "accountId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "required": false, + "in": "query", + "schema": { + "format": "date", + "type": "string" + } + }, + { + "name": "to", + "required": false, + "in": "query", + "schema": { + "format": "date", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IncomeAnalyticsDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "brokerage-accounts" + ] + } } }, "info": { @@ -1507,14 +1695,269 @@ "items": { "$ref": "#/components/schemas/UpcomingEventDto" } + }, + "incomeTotalsByCurrency": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MoneyDto" + } } }, "required": [ "accountId", "calculatedAt", - "events" + "events", + "incomeTotalsByCurrency" + ] + }, + "IncomePeriodDto": { + "type": "object", + "properties": { + "from": { + "type": "string", + "format": "date" + }, + "to": { + "type": "string", + "format": "date" + } + }, + "required": [ + "from", + "to" + ] + }, + "IncomeCurrencySummaryDto": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "coupon": { + "$ref": "#/components/schemas/MoneyDto" + }, + "dividend": { + "$ref": "#/components/schemas/MoneyDto" + }, + "total": { + "$ref": "#/components/schemas/MoneyDto" + } + }, + "required": [ + "currency", + "coupon", + "dividend", + "total" + ] + }, + "IncomeSummaryDto": { + "type": "object", + "properties": { + "accountId": { + "type": "string" + }, + "period": { + "$ref": "#/components/schemas/IncomePeriodDto" + }, + "totalsByCurrency": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IncomeCurrencySummaryDto" + } + }, + "calculatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "accountId", + "period", + "totalsByCurrency", + "calculatedAt" + ] + }, + "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": { + "period": { + "$ref": "#/components/schemas/IncomePeriodDto" + }, + "totalsByCurrency": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IncomeCurrencySummaryDto" + } + } + }, + "required": [ + "period", + "totalsByCurrency" + ] + }, + "IncomeInstrumentDto": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "figi": { + "type": "string", + "nullable": true + }, + "instrumentUid": { + "type": "string", + "nullable": true + }, + "ticker": { + "type": "string", + "nullable": true + }, + "instrumentType": { + "type": "string", + "nullable": true + }, + "displayName": { + "type": "string" + }, + "coupon": { + "$ref": "#/components/schemas/MoneyDto" + }, + "dividend": { + "$ref": "#/components/schemas/MoneyDto" + }, + "total": { + "$ref": "#/components/schemas/MoneyDto" + }, + "receiptCount": { + "type": "number" + } + }, + "required": [ + "key", + "figi", + "instrumentUid", + "ticker", + "instrumentType", + "displayName", + "coupon", + "dividend", + "total", + "receiptCount" + ] + }, + "IncomeAnalyticsDto": { + "type": "object", + "properties": { + "accountId": { + "type": "string" + }, + "period": { + "$ref": "#/components/schemas/IncomePeriodDto" + }, + "totalsByCurrency": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IncomeCurrencySummaryDto" + } + }, + "calculatedAt": { + "type": "string", + "format": "date-time" + }, + "previous": { + "$ref": "#/components/schemas/IncomeComparisonDto" + }, + "yearComparison": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/IncomeComparisonDto" + } + ] + }, + "bucketGranularity": { + "type": "string", + "enum": [ + "day", + "week", + "month" + ] + }, + "buckets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IncomeBucketDto" + } + }, + "instruments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IncomeInstrumentDto" + } + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BrokerageOperationDto" + } + } + }, + "required": [ + "accountId", + "period", + "totalsByCurrency", + "calculatedAt", + "previous", + "yearComparison", + "bucketGranularity", + "buckets", + "instruments", + "entries" ] } } } -} +} \ No newline at end of file diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 6c10d2c..6764df3 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -7,6 +7,7 @@ const ru = { accounts: "Брокерские счета", accountFallback: "Счет", operations: "Операции", + income: "Пассивный доход", events: "Будущие события", settings: "Настройки", }, @@ -146,6 +147,48 @@ const ru = { offer: "Оферта", }, }, + income: { + title: "Пассивный доход", + open: "Открыть пассивный доход", + summaryTitle: "Получено купонами и дивидендами", + summaryLoading: "Загрузка дохода текущего месяца", + summaryError: "Не удалось загрузить доход текущего месяца", + summaryRetry: "Повторить загрузку дохода", + summaryEmpty: "В текущем месяце купоны и дивиденды не найддены.", + from: "С", + to: "По", + apply: "Применить", + reset: "Сбросить", + coupon: "Купоны", + dividend: "Дивиденды", + taxBoundary: "Налоговые операции не включены в этот результат.", + comparisons: "Сравнения", + previous: "Предыдущий период: {{from}} — {{to}}", + yearAgo: "Годом ранее: {{from}} — {{to}}", + yearUnavailable: "Сравнение с годом ранее недоступно.", + selected: "Выбранный период", + compared: "Сравниваемый период", + difference: "Разница", + total: "Итого", + trend: "Динамика", + currency: "Валюта графика", + instruments: "По инструментам", + emptyTitle: "Купоны и дивиденды не найдены", + emptyText: "За выбранный период подходящих исполненных выплат нет.", + errorTitle: "Не удалось рассчитать пассивный доход", + errorText: "Попробуйте загрузить результат еще раз.", + retry: "Повторить", + forecastTitle: "Ближайший прогноз", + forecastLoading: "Загрузка прогноза", + forecastError: "Не удалось загрузить прогноз", + forecastEmpty: "Ближайших купонов и дивидендов не найдено.", + forecastWarning: + "Это известный прогноз, а не гарантированное поступление.", + validation: { + date: "Введите существующую дату.", + period: "Укажите период до 366 дней без будущих дат.", + }, + }, settings: { title: "Настройки" }, tbank: { title: "T-Bank Invest",