feat(income): add account income analytics

This commit is contained in:
2026-08-28 01:25:52 +03:00
parent 71a214d7ad
commit 9364568202
18 changed files with 1746 additions and 7 deletions
+2
View File
@@ -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}
/>
);
};
+14
View File
@@ -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<AccountPageEmptyProps> = (props) => {
retry,
recentOperations,
recentEvents,
incomeSummary,
} = props;
const { t } = useTranslation();
@@ -134,6 +139,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
/>
<RecentOperations operations={recentOperations} />
<RecentEvents events={recentEvents} />
{incomeSummary ? <IncomeSummary {...incomeSummary} /> : null}
</Stack>
);
};
@@ -148,6 +154,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
retry,
recentOperations,
recentEvents,
incomeSummary,
} = props;
const { t } = useTranslation();
@@ -175,6 +182,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
]}
classNames={{
control: classes.accordionControl,
@@ -211,6 +219,12 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<RecentEvents events={recentEvents} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="incomeSummary">
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
<Accordion.Panel>
{incomeSummary ? <IncomeSummary {...incomeSummary} /> : null}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
+24
View File
@@ -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<BrokerageAccount[]>(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<typeof useIncomeSummary>,
): 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<typeof useBrokerageOperations>,
@@ -223,6 +246,7 @@ function toPosition(
}
export {
type AccountPageIncomeSummary,
type AccountPagePosition,
type AccountPagePositionSection,
type AccountPageRecentEvents,
@@ -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<IncomeSummaryProps> = (props) => {
const { t } = useTranslation();
if (props.state === "loading")
return <Text c="dimmed">{t("income.summaryLoading")}</Text>;
if (props.state === "error")
return (
<StateView
error
title={t("income.summaryError")}
text={t("income.errorText")}
action={
<Button onClick={props.retry}>{t("income.summaryRetry")}</Button>
}
/>
);
if (props.state === "empty")
return (
<Stack gap="xs">
<Text c="dimmed">{t("income.summaryEmpty")}</Text>
<Button component={Link} to={props.to} variant="subtle">
{t("income.open")}
</Button>
</Stack>
);
return (
<Stack gap="xs">
{props.summary.totalsByCurrency.map((total) => (
<Paper key={total.currency} p="sm" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
<Text c="dimmed" size="sm">
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(total.dividend)}
</Text>
</Paper>
))}
<Button component={Link} to={props.to} variant="subtle">
{t("income.open")}
</Button>
</Stack>
);
};
export { IncomeSummary };
+1
View File
@@ -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";