feat: add portfolio
This commit is contained in:
@@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+59
@@ -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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user