feat: add portfolio

This commit is contained in:
2026-08-13 22:45:00 +03:00
parent 6e89c1a8f7
commit 9156f29707
11 changed files with 594 additions and 12 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
export type { BrokerageAccount } from "./api/useBrokerageAccounts";
export { export {
brokerageAccountsQueryKey, brokerageAccountsQueryKey,
useBrokerageAccounts, useBrokerageAccounts,
} from "./api/useBrokerageAccounts"; } from "./api/useBrokerageAccounts";
export { errorCode } from "./model/display"; export { accountTypeKey, errorCode } from "./model/display";
export { BrokerageAccountList } from "./ui/BrokerageAccountList"; export { BrokerageAccountList } from "./ui/BrokerageAccountList";
@@ -0,0 +1,45 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
const { get } = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("../../../shared/api", () => ({ apiClient: { GET: get } }));
import { portfolioQueryPolicy, usePortfolio } from "./usePortfolio";
describe("usePortfolio", () => {
it("uses the account endpoint with a 30 second cache and no retry", async () => {
const client = new QueryClient();
get.mockResolvedValueOnce({
data: {
lastSuccessfulAt: "2026-08-13T10:00:00Z",
isStale: false,
totalsByCurrency: [],
positions: [],
cashBalances: [],
},
});
const wrapper = ({ children }: { children: ReactNode }) => (
<MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
const { result } = renderHook(() => usePortfolio("account-1", "token"), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(get).toHaveBeenCalledWith(
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{
params: { path: { accountId: "account-1" } },
headers: { Authorization: "Bearer token" },
},
);
expect(portfolioQueryPolicy).toEqual({ staleTime: 30_000, retry: false });
});
});
@@ -0,0 +1,36 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../../../shared/api";
import type { paths } from "../../../shared/api/generated/schema";
export const portfolioQueryKey = (accountId: string) =>
["portfolio", accountId] as const;
export const portfolioQueryPolicy = {
staleTime: 30_000,
retry: false,
} as const;
export type Portfolio =
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
export function usePortfolio(
accountId: string | undefined,
accessToken: string | null,
) {
return useQuery({
queryKey: portfolioQueryKey(accountId ?? ""),
enabled: Boolean(accountId && accessToken),
...portfolioQueryPolicy,
queryFn: async () => {
const { data, error } = await apiClient.GET(
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{
params: { path: { accountId: accountId ?? "" } },
headers: { Authorization: `Bearer ${accessToken}` },
},
);
if (error) throw error;
return data as Portfolio;
},
});
}
+12
View File
@@ -0,0 +1,12 @@
export type { Portfolio } from "./api/usePortfolio";
export {
portfolioQueryKey,
portfolioQueryPolicy,
usePortfolio,
} from "./api/usePortfolio";
export {
formatDecimal,
formatMoney,
formatSnapshotTime,
portfolioErrorCode,
} from "./model/display";
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { formatDecimal, formatMoney } from "./display";
describe("portfolio display helpers", () => {
it("formats decimal strings without financial arithmetic", () => {
expect(formatDecimal("-1234567.89")).toBe("-1 234 567,89");
expect(formatMoney({ value: "10", currency: "RUB" })).toBe("10 ₽");
expect(formatMoney({ value: "0.5", currency: "USD" })).toBe("0,5 USD");
expect(formatMoney(null)).toBe("—");
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { Portfolio } from "../api/usePortfolio";
type Money = Portfolio["totalsByCurrency"][number];
export function formatDecimal(value: string): string {
const [integer, fraction] = value.split(".");
const sign = integer.startsWith("-") ? "-" : "";
const grouped = (sign ? integer.slice(1) : integer).replace(
/\B(?=(\d{3})+(?!\d))/g,
" ",
);
return `${sign}${grouped}${fraction ? `,${fraction}` : ""}`;
}
export function formatMoney(money: Money | null): string {
if (!money) return "—";
return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`;
}
export function formatSnapshotTime(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
}).format(new Date(value));
}
export function portfolioErrorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String(error.code)
: undefined;
}
@@ -0,0 +1,31 @@
.page {
max-width: 960px;
}
.total,
.row,
.cash {
border-color: var(--mantine-color-gray-3);
background: var(--mantine-color-gray-0);
}
.total {
padding: var(--mantine-spacing-md);
border-top: 3px solid var(--mantine-color-blue-6);
}
.row,
.cash {
padding: var(--mantine-spacing-md);
}
.cash {
display: flex;
justify-content: space-between;
}
@media (max-width: 36em) {
.row :global(.mantine-Group-root) {
align-items: flex-start;
}
}
+117
View File
@@ -0,0 +1,117 @@
import { MantineProvider } from "@mantine/core";
import { cleanup, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Portfolio } from "../../../entities/portfolio";
import {
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
} from "./AccountPage";
function renderPage(ui: React.ReactNode) {
return render(
<MantineProvider>
<MemoryRouter>{ui}</MemoryRouter>
</MantineProvider>,
);
}
const portfolio: Portfolio = {
lastSuccessfulAt: "2026-08-13T10:00:00Z",
isStale: true,
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
positions: [
{
figi: "FIGI-2",
displayName: "Вторая",
type: "STOCK",
quantity: "2",
currentValue: null,
averagePurchasePrice: null,
absoluteReturn: null,
},
{
figi: "FIGI-1",
displayName: "Первая",
type: "BOND",
quantity: "1",
currentValue: { currency: "RUB", value: "10" },
averagePurchasePrice: null,
absoluteReturn: { currency: "RUB", value: "-1" },
},
],
cashBalances: [{ currency: "USD", value: "0.5" }],
};
describe("AccountPage states", () => {
afterEach(cleanup);
it("renders loading and empty states", () => {
renderPage(<AccountPageLoading />);
expect(screen.getByRole("status")).toBeInTheDocument();
renderPage(<AccountPageEmpty />);
expect(
screen.getByRole("heading", { name: "Портфель пока пуст" }),
).toBeInTheDocument();
});
it("renders allowed error actions", () => {
const retry = vi.fn();
renderPage(<AccountPageError code="NOT_FOUND" retry={retry} />);
expect(screen.getByRole("link", { name: "К счетам" })).toHaveAttribute(
"href",
"/accounts",
);
renderPage(<AccountPageError code="TBANK_NOT_CONNECTED" retry={retry} />);
expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings");
renderPage(<AccountPageError code="TBANK_UNAVAILABLE" retry={retry} />);
screen.getByRole("button", { name: "Повторить" }).click();
expect(retry).toHaveBeenCalledOnce();
});
it("renders a stale portfolio in upstream order with nullable values", () => {
renderPage(<AccountPageLoaded portfolio={portfolio} />);
expect(
screen.getByRole("heading", { level: 1, name: "Портфель" }),
).toBeInTheDocument();
expect(
screen.getByText(
"T-Bank временно недоступен. Показан последний успешный снимок.",
),
).toBeInTheDocument();
expect(screen.getByText("—")).toBeInTheDocument();
expect(screen.getByText("Доход: —")).toBeInTheDocument();
const positions = screen
.getAllByText(/Вторая|Первая/)
.map((element) => element.textContent);
expect(positions).toEqual(["Вторая", "Первая"]);
expect(screen.getByRole("heading", { name: "Акции" })).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Облигации" }),
).toBeInTheDocument();
});
it("uses the cached account title when available", () => {
renderPage(
<AccountPageLoaded
portfolio={portfolio}
account={{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: null,
}}
/>,
);
expect(
screen.getByRole("heading", { level: 1, name: "Основной" }),
).toBeInTheDocument();
});
});
+229 -11
View File
@@ -1,15 +1,54 @@
import { Stack, Title } from "@mantine/core"; import {
Alert,
Button,
Card,
Group,
Paper,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useParams } from "react-router";
import {
accountTypeKey,
type BrokerageAccount,
} from "../../../entities/brokerage-account";
import type { Portfolio } from "../../../entities/portfolio";
import {
formatMoney,
formatSnapshotTime,
portfolioErrorCode,
usePortfolio,
} from "../../../entities/portfolio";
import { useAuth } from "../../../features/auth";
import { StateView } from "../../../shared/ui/StateView"; import { StateView } from "../../../shared/ui/StateView";
import classes from "./AccountPage.module.css";
const accountsQueryKey = ["brokerage-accounts"] as const;
export function AccountPage() { export function AccountPage() {
const { t } = useTranslation(); const { accountId } = useParams();
return ( const { accessToken } = useAuth();
<Stack> const portfolio = usePortfolio(accountId, accessToken);
<Title order={1}>{t("account.title")}</Title> const account = useQueryClient()
<AccountPageEmpty /> .getQueryData<BrokerageAccount[]>(accountsQueryKey)
</Stack> ?.find((item) => item.id === accountId);
);
if (portfolio.isFetching) return <AccountPageLoading />;
if (portfolio.isError)
return (
<AccountPageError
code={portfolioErrorCode(portfolio.error)}
retry={() => portfolio.refetch()}
/>
);
if (!portfolio.data) return <AccountPageLoading />;
if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
return <AccountPageEmpty account={account} />;
return <AccountPageLoaded account={account} portfolio={portfolio.data} />;
} }
export function AccountPageLoading() { export function AccountPageLoading() {
@@ -23,20 +62,199 @@ export function AccountPageLoading() {
); );
} }
export function AccountPageError() { export function AccountPageError({
code,
retry,
}: {
code?: string;
retry: () => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
if (code === "NOT_FOUND")
return (
<StateView
error
title={t("portfolio.accountUnavailableTitle")}
text={t("portfolio.accountUnavailableText")}
action={
<Button component={Link} to="/accounts">
{t("portfolio.back")}
</Button>
}
/>
);
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("portfolio.connectionErrorTitle")}
text={t("portfolio.connectionErrorText")}
action={
<Button component={Link} to="/settings">
{t("accounts.openSettings")}
</Button>
}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
return ( return (
<StateView <StateView
error error
title={t("states.errorTitle")} title={t("states.errorTitle")}
text={t("states.errorText")} text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/> />
); );
} }
export function AccountPageEmpty() { export function AccountPageEmpty({ account }: { account?: BrokerageAccount }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<StateView title={t("states.emptyTitle")} text={t("states.emptyText")} /> <Stack>
<AccountHeading account={account} />
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
</Stack>
);
}
export function AccountPageLoaded({
account,
portfolio,
}: {
account?: BrokerageAccount;
portfolio: Portfolio;
}) {
const { t } = useTranslation();
const stocks = portfolio.positions.filter(
(position) => position.type === "STOCK",
);
const bonds = portfolio.positions.filter(
(position) => position.type === "BOND",
);
const otherPositions = portfolio.positions.filter(
(position) => position.type !== "STOCK" && position.type !== "BOND",
);
return (
<Stack className={classes.page} gap="lg">
<AccountHeading account={account} />
{portfolio.isStale ? (
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null}
<Text c="dimmed" size="sm">
{t("portfolio.updatedAt", {
date: formatSnapshotTime(portfolio.lastSuccessfulAt),
})}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
{portfolio.totalsByCurrency.map((total) => (
<Paper className={classes.total} key={total.currency} withBorder>
<Text c="dimmed" size="xs">
{total.currency}
</Text>
<Text fw={800} size="xl">
{formatMoney(total)}
</Text>
</Paper>
))}
</SimpleGrid>
{stocks.length ? (
<PositionSection title={t("portfolio.stocks")} positions={stocks} />
) : null}
{bonds.length ? (
<PositionSection title={t("portfolio.bonds")} positions={bonds} />
) : null}
{otherPositions.length ? (
<PositionSection
title={t("portfolio.otherPositions")}
positions={otherPositions}
/>
) : null}
<Section title={t("portfolio.cashBalances")}>
{portfolio.cashBalances.map((balance) => (
<Paper className={classes.cash} key={balance.currency} withBorder>
<Text>{balance.currency}</Text>
<Text fw={700}>{formatMoney(balance)}</Text>
</Paper>
))}
</Section>
</Stack>
);
}
function PositionSection({
title,
positions,
}: {
title: string;
positions: Portfolio["positions"];
}) {
const { t } = useTranslation();
return (
<Section title={title}>
{positions.map((position) => (
<Card
className={classes.row}
key={`${position.figi}-${position.displayName}-${position.quantity}`}
withBorder
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2}>
<Text fw={700}>{position.displayName}</Text>
<Text c="dimmed" size="sm">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text fw={700}>{formatMoney(position.currentValue)}</Text>
<Text c="dimmed" size="sm">
{t("portfolio.return", {
value: formatMoney(position.absoluteReturn),
})}
</Text>
</Stack>
</Group>
</Card>
))}
</Section>
);
}
function AccountHeading({ account }: { account?: BrokerageAccount }) {
const { t } = useTranslation();
return (
<Stack gap={2}>
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
{account ? (
<Text c="dimmed" size="sm">
{t(accountTypeKey(account.type))}
</Text>
) : null}
</Stack>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Stack gap="sm">
<Title order={2}>{title}</Title>
{children}
</Stack>
); );
} }
+59
View File
@@ -116,6 +116,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/brokerage-accounts/{accountId}/portfolio": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["PortfolioController_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
@@ -150,6 +166,28 @@ export interface components {
/** Format: date */ /** Format: date */
openedOn: string | null; openedOn: string | null;
}; };
MoneyDto: {
currency: string;
value: string;
};
PortfolioPositionDto: {
figi: string | null;
displayName: string;
/** @enum {string} */
type: "STOCK" | "BOND" | "FUND" | "CURRENCY" | "FUTURE" | "OPTION" | "STRUCTURED_NOTE" | "DIGITAL_FINANCIAL_ASSET" | "OTHER";
quantity: string;
currentValue: components["schemas"]["MoneyDto"] | null;
averagePurchasePrice: components["schemas"]["MoneyDto"] | null;
absoluteReturn: components["schemas"]["MoneyDto"] | null;
};
PortfolioDto: {
/** Format: date-time */
lastSuccessfulAt: string;
isStale: boolean;
totalsByCurrency: components["schemas"]["MoneyDto"][];
positions: components["schemas"]["PortfolioPositionDto"][];
cashBalances: components["schemas"]["MoneyDto"][];
};
}; };
responses: never; responses: never;
parameters: never; parameters: never;
@@ -338,4 +376,25 @@ export interface operations {
}; };
}; };
}; };
PortfolioController_get: {
parameters: {
query?: never;
header?: never;
path: {
accountId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PortfolioDto"];
};
};
};
};
} }
+20
View File
@@ -42,6 +42,26 @@ export const ru = {
}, },
}, },
account: { title: "Счет" }, account: { title: "Счет" },
portfolio: {
title: "Портфель",
emptyTitle: "Портфель пока пуст",
emptyText: "На счете нет позиций и доступных денежных остатков.",
accountUnavailableTitle: "Счет недоступен",
accountUnavailableText: "Вернитесь к списку и выберите доступный счет.",
connectionErrorTitle: "Проверьте подключение T-Bank",
connectionErrorText: "Настройте подключение, чтобы посмотреть портфель.",
back: "К счетам",
staleWarning:
"T-Bank временно недоступен. Показан последний успешный снимок.",
updatedAt: "Данные получены {{date}}",
positions: "Позиции",
stocks: "Акции",
bonds: "Облигации",
otherPositions: "Другие позиции",
cashBalances: "Денежные остатки",
quantity: "Количество: {{value}}",
return: "Доход: {{value}}",
},
settings: { title: "Настройки" }, settings: { title: "Настройки" },
tbank: { tbank: {
title: "T-Bank Invest", title: "T-Bank Invest",