feat: add all-accounts portfolio pages
Reuse account sections and group securities with per-account holdings. Use Mantine Select to preserve filters and reset pagination on scope changes. Document component reuse guardrails and cover routes, grouping and scope switching.
This commit is contained in:
@@ -2,8 +2,16 @@
|
|||||||
|
|
||||||
## Выбор компонента и state
|
## Выбор компонента и state
|
||||||
|
|
||||||
Используй существующий Mantine component и общую тему, если они выражают нужное
|
Сначала переиспользуй подходящий component проекта; иначе используй готовый
|
||||||
поведение. Wrapper нужен для проектной ответственности, а не ради нового имени
|
Mantine component и общую тему. Для выпадающего выбора используй `Select` или
|
||||||
|
`MultiSelect`, а не браузерный `<select>` / `NativeSelect` по умолчанию.
|
||||||
|
Нативный control или собственная реализация допустимы, когда готовые components
|
||||||
|
не покрывают конкретное продуктовое требование или доступность; укажи причину
|
||||||
|
в результате работы. Краткость кода и удобство тестирования не являются причиной:
|
||||||
|
ограничения JSDOM решай в test setup, сохраняя production UI.
|
||||||
|
Семантический HTML для структуры страницы остаётся уместным.
|
||||||
|
|
||||||
|
Wrapper нужен для проектной ответственности, а не ради нового имени
|
||||||
для Button. Локальный CSS module принадлежит component; общий token вводи для
|
для Button. Локальный CSS module принадлежит component; общий token вводи для
|
||||||
реально общей роли через [design system](../../../docs/development/design-system.md).
|
реально общей роли через [design system](../../../docs/development/design-system.md).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
act,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import { HttpResponse, http } from "msw";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { MemoryRouter, useLocation } from "react-router";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||||
|
import type { IncomeAnalytics } from "@/entities/income";
|
||||||
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
import { AuthProvider } from "@/features/auth";
|
||||||
|
import { createTestQueryClient } from "@/test/query";
|
||||||
|
import { server } from "@/test/server";
|
||||||
|
import { AppRoutes } from "./AppRoutes";
|
||||||
|
|
||||||
|
const accounts = [
|
||||||
|
{ id: "a", name: "Основной" },
|
||||||
|
{ id: "b", name: "ИИС" },
|
||||||
|
];
|
||||||
|
const position: Portfolio["positions"][number] = {
|
||||||
|
figi: "SBER",
|
||||||
|
displayName: "Сбербанк",
|
||||||
|
type: "STOCK",
|
||||||
|
quantity: "2",
|
||||||
|
currentValue: { currency: "RUB", value: "600" },
|
||||||
|
absoluteReturn: null,
|
||||||
|
averagePurchasePrice: null,
|
||||||
|
isin: null,
|
||||||
|
ticker: "SBER",
|
||||||
|
logoName: null,
|
||||||
|
};
|
||||||
|
const portfolio: Portfolio = {
|
||||||
|
lastSuccessfulAt: "2026-08-15T12:00:00Z",
|
||||||
|
isStale: false,
|
||||||
|
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
|
||||||
|
cashBalances: [],
|
||||||
|
positions: accounts.map((account) => ({ ...position, account })),
|
||||||
|
};
|
||||||
|
const receipt: BrokerageOperation = {
|
||||||
|
id: "same",
|
||||||
|
occurredAt: "2026-08-15T12:00:00Z",
|
||||||
|
type: "OPERATION_TYPE_COUPON",
|
||||||
|
category: "income",
|
||||||
|
description: "Выплата купона",
|
||||||
|
figi: null,
|
||||||
|
instrumentUid: null,
|
||||||
|
ticker: "BOND",
|
||||||
|
instrumentType: "bond",
|
||||||
|
logoName: null,
|
||||||
|
payment: { currency: "RUB", value: "1" },
|
||||||
|
quantity: null,
|
||||||
|
price: null,
|
||||||
|
accruedInterest: null,
|
||||||
|
commission: null,
|
||||||
|
};
|
||||||
|
const items = accounts.map((account) => ({ ...receipt, account }));
|
||||||
|
const analytics: IncomeAnalytics = {
|
||||||
|
accountId: "all",
|
||||||
|
period: { from: "2026-08-01", to: "2026-08-31" },
|
||||||
|
calculatedAt: "2026-09-01T12:00:00Z",
|
||||||
|
totalsByCurrency: [
|
||||||
|
{
|
||||||
|
currency: "RUB",
|
||||||
|
coupon: { currency: "RUB", value: "2" },
|
||||||
|
dividend: { currency: "RUB", value: "0" },
|
||||||
|
total: { currency: "RUB", value: "2" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
previous: {
|
||||||
|
period: { from: "2026-07-01", to: "2026-07-31" },
|
||||||
|
totalsByCurrency: [],
|
||||||
|
},
|
||||||
|
yearComparison: null,
|
||||||
|
bucketGranularity: "day",
|
||||||
|
buckets: [],
|
||||||
|
instruments: [
|
||||||
|
{
|
||||||
|
key: "bond:RUB",
|
||||||
|
figi: null,
|
||||||
|
instrumentUid: null,
|
||||||
|
ticker: "BOND",
|
||||||
|
instrumentType: "bond",
|
||||||
|
displayName: "Облигация",
|
||||||
|
coupon: { currency: "RUB", value: "2" },
|
||||||
|
dividend: { currency: "RUB", value: "0" },
|
||||||
|
total: { currency: "RUB", value: "2" },
|
||||||
|
receiptCount: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
entries: items.map((entry) => ({ ...entry, instrumentKey: "bond:RUB" })),
|
||||||
|
};
|
||||||
|
const CurrentLocation: FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
return (
|
||||||
|
<output aria-label="Текущий путь">
|
||||||
|
{location.pathname}
|
||||||
|
{location.search}
|
||||||
|
</output>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
function renderPage(path: string) {
|
||||||
|
const client = createTestQueryClient();
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<MantineProvider env="test">
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
<CurrentLocation />
|
||||||
|
</AuthProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
server.use(
|
||||||
|
http.post("*/api/v1/auth/refresh", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
accessToken: "test",
|
||||||
|
user: { id: "user", email: "user@example.com" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
accounts.map((account) => ({
|
||||||
|
...account,
|
||||||
|
type: "BROKERAGE",
|
||||||
|
openedOn: null,
|
||||||
|
portfolioSummary: null,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
|
HttpResponse.json(portfolio),
|
||||||
|
),
|
||||||
|
http.get(
|
||||||
|
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||||
|
({ params }) =>
|
||||||
|
HttpResponse.json({
|
||||||
|
accountId: params.accountId,
|
||||||
|
items,
|
||||||
|
hasNext: false,
|
||||||
|
nextCursor: null,
|
||||||
|
observedAt: receipt.occurredAt,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
|
||||||
|
HttpResponse.json(analytics),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
accountId: "all",
|
||||||
|
calculatedAt: receipt.occurredAt,
|
||||||
|
incomeTotalsByCurrency: [],
|
||||||
|
events: accounts.map((account) => ({
|
||||||
|
account,
|
||||||
|
type: "coupon",
|
||||||
|
date: "2026-09-15",
|
||||||
|
dateKind: "event",
|
||||||
|
figi: null,
|
||||||
|
instrumentUid: null,
|
||||||
|
ticker: "BOND",
|
||||||
|
displayName: "Облигация",
|
||||||
|
isin: null,
|
||||||
|
logoName: null,
|
||||||
|
projectedAmount: { currency: "RUB", value: "2" },
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("All accounts routes", () => {
|
||||||
|
it.each([
|
||||||
|
["/accounts/all", "Все счета"],
|
||||||
|
["/accounts/all/operations", "Журнал операций"],
|
||||||
|
["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"],
|
||||||
|
["/accounts/all/events", "Будущие события"],
|
||||||
|
])("opens %s directly", async (path, title) => {
|
||||||
|
renderPage(path);
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { level: 1, name: title }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("combobox", { name: "Счёт" })).toHaveValue(
|
||||||
|
"Все счета",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups the same security and links its accounts and overview sections", async () => {
|
||||||
|
renderPage("/accounts/all");
|
||||||
|
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
|
||||||
|
for (const account of accounts)
|
||||||
|
expect(screen.getByRole("link", { name: account.name })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
`/accounts/${account.id}`,
|
||||||
|
);
|
||||||
|
for (const suffix of ["operations", "income", "events"])
|
||||||
|
expect(
|
||||||
|
document.querySelector(`a[href="/accounts/all/${suffix}"]`),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches accounts within operations with applied filters and a fresh cursor", async () => {
|
||||||
|
const requests: URL[] = [];
|
||||||
|
server.use(
|
||||||
|
http.get(
|
||||||
|
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||||
|
({ request, params }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
requests.push(url);
|
||||||
|
return HttpResponse.json({
|
||||||
|
accountId: params.accountId,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
...receipt,
|
||||||
|
description: url.searchParams.has("cursor")
|
||||||
|
? "Вторая страница"
|
||||||
|
: "Первая страница",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hasNext: !url.searchParams.has("cursor"),
|
||||||
|
nextCursor: url.searchParams.has("cursor") ? null : "next",
|
||||||
|
observedAt: receipt.occurredAt,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPage(
|
||||||
|
"/accounts/all/operations?from=2026-08-01&to=2026-08-31&categories=income",
|
||||||
|
);
|
||||||
|
await screen.findByText("Первая страница");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||||||
|
await screen.findByText("Вторая страница");
|
||||||
|
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
requests.some((url) => url.pathname.includes("/b/operations")),
|
||||||
|
).toBe(true),
|
||||||
|
);
|
||||||
|
const selected = requests.find((url) =>
|
||||||
|
url.pathname.includes("/b/operations"),
|
||||||
|
);
|
||||||
|
expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z");
|
||||||
|
expect(selected?.searchParams.get("to")).toBe("2026-08-31T23:59:59.999Z");
|
||||||
|
expect(selected?.searchParams.get("categories")).toBe("income");
|
||||||
|
expect(selected?.searchParams.has("cursor")).toBe(false);
|
||||||
|
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
|
||||||
|
"/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts an expired aggregate cursor without losing filters", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) =>
|
||||||
|
new URL(request.url).searchParams.has("cursor")
|
||||||
|
? HttpResponse.json(
|
||||||
|
{ code: "INVALID_OPERATIONS_CURSOR" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
: HttpResponse.json({
|
||||||
|
accountId: "all",
|
||||||
|
items,
|
||||||
|
hasNext: true,
|
||||||
|
nextCursor: "expired",
|
||||||
|
observedAt: receipt.occurredAt,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
|
||||||
|
await screen.findAllByText("Выплата купона");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
|
||||||
|
expect(await screen.findAllByText("Выплата купона")).toHaveLength(2);
|
||||||
|
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
|
||||||
|
"from=2026-08-01&to=2026-08-31",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains the complete portfolio during a failed refresh", async () => {
|
||||||
|
const client = renderPage("/accounts/all");
|
||||||
|
await screen.findAllByText("Сбербанк");
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/all/portfolio", () =>
|
||||||
|
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await act(() =>
|
||||||
|
client.invalidateQueries({
|
||||||
|
predicate: ({ queryKey }) => String(queryKey[1]).endsWith("/portfolio"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("button", {
|
||||||
|
name: "Повторить",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,6 +85,38 @@ const AppRoutes: FC = () => (
|
|||||||
</LazyPage>
|
</LazyPage>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/accounts/all"
|
||||||
|
element={
|
||||||
|
<LazyPage variant="content">
|
||||||
|
<AccountPage accountId="all" />
|
||||||
|
</LazyPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/accounts/all/operations"
|
||||||
|
element={
|
||||||
|
<LazyPage variant="content">
|
||||||
|
<AccountOperationsPage accountId="all" />
|
||||||
|
</LazyPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/accounts/all/income"
|
||||||
|
element={
|
||||||
|
<LazyPage variant="content">
|
||||||
|
<AccountIncomePage accountId="all" />
|
||||||
|
</LazyPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/accounts/all/events"
|
||||||
|
element={
|
||||||
|
<LazyPage variant="content">
|
||||||
|
<AccountEventsPage accountId="all" />
|
||||||
|
</LazyPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/accounts/:accountId"
|
path="/accounts/:accountId"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => {
|
|||||||
const { data: accounts } = useBrokerageAccounts();
|
const { data: accounts } = useBrokerageAccounts();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const accountName =
|
const accountName =
|
||||||
accounts?.find((account) => account.id === accountId)?.name ??
|
accountId === "all"
|
||||||
t("breadcrumbs.accountFallback");
|
? t("accounts.all")
|
||||||
|
: (accounts?.find((account) => account.id === accountId)?.name ??
|
||||||
|
t("breadcrumbs.accountFallback"));
|
||||||
const accountPath = `/accounts/${accountId}`;
|
const accountPath = `/accounts/${accountId}`;
|
||||||
const items = [
|
const items = [
|
||||||
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
|
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Select } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation, useNavigate } from "react-router";
|
||||||
|
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||||
|
|
||||||
|
type AccountScopeSwitchProps = { accountId: string; section?: string };
|
||||||
|
|
||||||
|
const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
|
||||||
|
const { accountId, section } = props;
|
||||||
|
const { data: accounts } = useBrokerageAccounts();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { search } = useLocation();
|
||||||
|
const data = [
|
||||||
|
{ value: "all", label: t("accounts.all") },
|
||||||
|
...(accounts ?? []).map((account) => ({
|
||||||
|
value: account.id,
|
||||||
|
label: account.name,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
if (!data.some((item) => item.value === accountId))
|
||||||
|
data.push({ value: accountId, label: t("breadcrumbs.accountFallback") });
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
label={t("accounts.scope")}
|
||||||
|
value={accountId}
|
||||||
|
data={data}
|
||||||
|
allowDeselect={false}
|
||||||
|
maw={360}
|
||||||
|
w="100%"
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!value || value === accountId) return;
|
||||||
|
const filters = new URLSearchParams(search);
|
||||||
|
filters.delete("cursor");
|
||||||
|
navigate({
|
||||||
|
pathname: `/accounts/${encodeURIComponent(value)}${section ? `/${section}` : ""}`,
|
||||||
|
search: filters.toString(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { AccountScopeSwitch };
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { AppBreadcrumbs } from "./AppBreadcrumbs";
|
import { AppBreadcrumbs } from "./AppBreadcrumbs";
|
||||||
@@ -77,7 +77,9 @@ describe("AppBreadcrumbs", () => {
|
|||||||
it("uses the account fallback without exposing the identifier", () => {
|
it("uses the account fallback without exposing the identifier", () => {
|
||||||
renderBreadcrumbs("/accounts/account-1");
|
renderBreadcrumbs("/accounts/account-1");
|
||||||
|
|
||||||
expect(screen.getByText("Счет")).toHaveAttribute("aria-current", "page");
|
expect(
|
||||||
|
within(screen.getByRole("navigation")).getByText("Счет"),
|
||||||
|
).toHaveAttribute("aria-current", "page");
|
||||||
expect(screen.queryByText("account-1")).not.toBeInTheDocument();
|
expect(screen.queryByText("account-1")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { Stack } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMatch } from "react-router";
|
import { useMatch } from "react-router";
|
||||||
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
||||||
|
import { AccountScopeSwitch } from "./AccountScopeSwitch";
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
|
|
||||||
const AppBreadcrumbs: FC = () => {
|
const AppBreadcrumbs: FC = () => {
|
||||||
@@ -18,27 +20,50 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
|
|
||||||
if (operations)
|
if (operations)
|
||||||
return (
|
return (
|
||||||
<AccountBreadcrumbs
|
<Stack gap="sm" mb="lg">
|
||||||
accountId={operations.params.accountId ?? ""}
|
<AccountBreadcrumbs
|
||||||
section="operations"
|
accountId={operations.params.accountId ?? ""}
|
||||||
/>
|
section="operations"
|
||||||
|
/>
|
||||||
|
<AccountScopeSwitch
|
||||||
|
accountId={operations.params.accountId ?? ""}
|
||||||
|
section="operations"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
if (events)
|
if (events)
|
||||||
return (
|
return (
|
||||||
<AccountBreadcrumbs
|
<Stack gap="sm" mb="lg">
|
||||||
accountId={events.params.accountId ?? ""}
|
<AccountBreadcrumbs
|
||||||
section="events"
|
accountId={events.params.accountId ?? ""}
|
||||||
/>
|
section="events"
|
||||||
|
/>
|
||||||
|
<AccountScopeSwitch
|
||||||
|
accountId={events.params.accountId ?? ""}
|
||||||
|
section="events"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
if (income)
|
if (income)
|
||||||
return (
|
return (
|
||||||
<AccountBreadcrumbs
|
<Stack gap="sm" mb="lg">
|
||||||
accountId={income.params.accountId ?? ""}
|
<AccountBreadcrumbs
|
||||||
section="income"
|
accountId={income.params.accountId ?? ""}
|
||||||
/>
|
section="income"
|
||||||
|
/>
|
||||||
|
<AccountScopeSwitch
|
||||||
|
accountId={income.params.accountId ?? ""}
|
||||||
|
section="income"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
if (account)
|
if (account)
|
||||||
return <AccountBreadcrumbs accountId={account.params.accountId ?? ""} />;
|
return (
|
||||||
|
<Stack gap="sm" mb="lg">
|
||||||
|
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
|
||||||
|
<AccountScopeSwitch accountId={account.params.accountId ?? ""} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
if (accounts)
|
if (accounts)
|
||||||
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
||||||
if (settings)
|
if (settings)
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
|||||||
const instrument =
|
const instrument =
|
||||||
operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument");
|
operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument");
|
||||||
const payment = paymentPresentation(operation);
|
const payment = paymentPresentation(operation);
|
||||||
const metadata = [formatSnapshotTime(operation.occurredAt), instrument].join(
|
const metadata = [
|
||||||
" · ",
|
formatSnapshotTime(operation.occurredAt),
|
||||||
);
|
instrument,
|
||||||
|
operation.account?.name,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntryCard
|
<EntryCard
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
|
|||||||
date,
|
date,
|
||||||
event.dateKind === "registry" ? t("events.dateKind.registry") : null,
|
event.dateKind === "registry" ? t("events.dateKind.registry") : null,
|
||||||
event.isin,
|
event.isin,
|
||||||
|
event.account?.name,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
|
|||||||
@@ -36,7 +36,15 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
|||||||
{events.state === "loaded"
|
{events.state === "loaded"
|
||||||
? events.items.map((event) => (
|
? events.items.map((event) => (
|
||||||
<BrokerageEventCard
|
<BrokerageEventCard
|
||||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
key={JSON.stringify([
|
||||||
|
event.account?.id,
|
||||||
|
event.type,
|
||||||
|
event.date,
|
||||||
|
event.instrumentUid,
|
||||||
|
event.figi,
|
||||||
|
event.ticker,
|
||||||
|
event.logoName,
|
||||||
|
])}
|
||||||
event={event}
|
event={event}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import { AccountEventsPageError } from "./components/AccountEventsPageError.view
|
|||||||
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
|
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
|
||||||
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
|
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
|
||||||
|
|
||||||
const AccountEventsPage: FC = () => {
|
type AccountEventsPageProps = { accountId?: string };
|
||||||
const viewModel = useAccountEventsPageViewModel();
|
|
||||||
|
const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
|
||||||
|
const viewModel = useAccountEventsPageViewModel(props.accountId);
|
||||||
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
|
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
|
||||||
if (viewModel.state === "error")
|
if (viewModel.state === "error")
|
||||||
return <AccountEventsPageError retry={viewModel.retry} />;
|
return <AccountEventsPageError retry={viewModel.retry} />;
|
||||||
if (viewModel.state === "empty") return <AccountEventsPageEmpty />;
|
if (viewModel.state === "empty")
|
||||||
return <AccountEventsPageLoaded events={viewModel.events} />;
|
return <AccountEventsPageEmpty {...viewModel} />;
|
||||||
|
return <AccountEventsPageLoaded {...viewModel} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { AccountEventsPage };
|
export { AccountEventsPage };
|
||||||
|
|||||||
@@ -4,20 +4,37 @@ import {
|
|||||||
useBrokerageEvents,
|
useBrokerageEvents,
|
||||||
} from "@/entities/brokerage-event";
|
} from "@/entities/brokerage-event";
|
||||||
|
|
||||||
|
type EventsRefreshState = {
|
||||||
|
refreshing?: boolean;
|
||||||
|
refreshError?: boolean;
|
||||||
|
retry?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
type AccountEventsPageViewModel =
|
type AccountEventsPageViewModel =
|
||||||
| { state: "loading" }
|
| { state: "loading" }
|
||||||
| { state: "empty" }
|
| ({ state: "empty" } & EventsRefreshState)
|
||||||
| { state: "error"; retry: () => void }
|
| { state: "error"; retry: () => void }
|
||||||
| { state: "loaded"; events: BrokerageEvent[] };
|
| ({ state: "loaded"; events: BrokerageEvent[] } & EventsRefreshState);
|
||||||
|
|
||||||
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
function useAccountEventsPageViewModel(
|
||||||
const { accountId } = useParams();
|
scopeId?: string,
|
||||||
|
): AccountEventsPageViewModel {
|
||||||
|
const { accountId: routeAccountId } = useParams();
|
||||||
|
const accountId = scopeId ?? routeAccountId;
|
||||||
const events = useBrokerageEvents(accountId);
|
const events = useBrokerageEvents(accountId);
|
||||||
if (events.isLoading) return { state: "loading" };
|
if (events.isLoading) return { state: "loading" };
|
||||||
if (events.isError) return { state: "error", retry: () => events.refetch() };
|
if (events.isError && !events.data)
|
||||||
|
return { state: "error", retry: () => events.refetch() };
|
||||||
if (!events.data) return { state: "loading" };
|
if (!events.data) return { state: "loading" };
|
||||||
if (!events.data.events.length) return { state: "empty" };
|
const refresh = {
|
||||||
return { state: "loaded", events: events.data.events };
|
refreshing: events.isFetching,
|
||||||
|
refreshError: events.isError,
|
||||||
|
retry: () => {
|
||||||
|
void events.refetch();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!events.data.events.length) return { state: "empty", ...refresh };
|
||||||
|
return { state: "loaded", events: events.data.events, ...refresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useAccountEventsPageViewModel };
|
export { type EventsRefreshState, useAccountEventsPageViewModel };
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { Stack, Text, Title } from "@mantine/core";
|
import { Alert, Button, Progress, Stack, Text, Title } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { StateView } from "@/shared/ui";
|
import { StateView } from "@/shared/ui";
|
||||||
|
import type { EventsRefreshState } from "../AccountEventsPage.vm";
|
||||||
|
|
||||||
const AccountEventsPageEmpty: FC = () => {
|
const AccountEventsPageEmpty: FC<EventsRefreshState> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack maw={960} gap="lg">
|
<Stack maw={960} gap="lg">
|
||||||
<Title order={1}>{t("events.title")}</Title>
|
<Title order={1}>{t("events.title")}</Title>
|
||||||
|
{props.refreshing ? (
|
||||||
|
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
|
||||||
|
) : null}
|
||||||
|
{props.refreshError ? (
|
||||||
|
<Alert color="yellow">
|
||||||
|
{t("accounts.refreshError")}
|
||||||
|
<Button variant="subtle" onClick={props.retry}>
|
||||||
|
{t("accounts.retry")}
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
<Text c="dimmed">{t("events.hint")}</Text>
|
<Text c="dimmed">{t("events.hint")}</Text>
|
||||||
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
|
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,24 +1,46 @@
|
|||||||
import { Stack, Text, Title } from "@mantine/core";
|
import { Alert, Button, Progress, Stack, Text, Title } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
type BrokerageEvent,
|
type BrokerageEvent,
|
||||||
BrokerageEventCard,
|
BrokerageEventCard,
|
||||||
} from "@/entities/brokerage-event";
|
} from "@/entities/brokerage-event";
|
||||||
|
import type { EventsRefreshState } from "../AccountEventsPage.vm";
|
||||||
|
|
||||||
type AccountEventsPageLoadedProps = { events: BrokerageEvent[] };
|
type AccountEventsPageLoadedProps = EventsRefreshState & {
|
||||||
|
events: BrokerageEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack maw={960} gap="lg">
|
<Stack maw={960} gap="lg">
|
||||||
<Title order={1}>{t("events.title")}</Title>
|
<Title order={1}>{t("events.title")}</Title>
|
||||||
|
{props.refreshing ? (
|
||||||
|
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
|
||||||
|
) : null}
|
||||||
|
{props.refreshError ? (
|
||||||
|
<Alert color="yellow">
|
||||||
|
{t("accounts.refreshError")}
|
||||||
|
<Button variant="subtle" onClick={props.retry}>
|
||||||
|
{t("accounts.retry")}
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
<Text c="dimmed">{t("events.hint")}</Text>
|
<Text c="dimmed">{t("events.hint")}</Text>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{props.events.map((event) => (
|
{props.events.map((event) => (
|
||||||
<BrokerageEventCard
|
<BrokerageEventCard
|
||||||
event={event}
|
event={event}
|
||||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
key={JSON.stringify([
|
||||||
|
event.account?.id,
|
||||||
|
event.type,
|
||||||
|
event.date,
|
||||||
|
event.instrumentUid,
|
||||||
|
event.figi,
|
||||||
|
event.ticker,
|
||||||
|
event.logoName,
|
||||||
|
])}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { AccountIncomePageError } from "./components/AccountIncomePageError.view
|
|||||||
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
|
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
|
||||||
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
|
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
|
||||||
|
|
||||||
const AccountIncomePage: FC = () => {
|
type AccountIncomePageProps = { accountId?: string };
|
||||||
const viewModel = useAccountIncomePageViewModel();
|
|
||||||
|
const AccountIncomePage: FC<AccountIncomePageProps> = (props) => {
|
||||||
|
const viewModel = useAccountIncomePageViewModel(props.accountId);
|
||||||
if (viewModel.state === "loading") return <AccountIncomePageLoading />;
|
if (viewModel.state === "loading") return <AccountIncomePageLoading />;
|
||||||
if (viewModel.state === "error")
|
if (viewModel.state === "error")
|
||||||
return <AccountIncomePageError {...viewModel} />;
|
return <AccountIncomePageError {...viewModel} />;
|
||||||
|
|||||||
@@ -58,8 +58,11 @@ const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine(
|
|||||||
{ path: ["to"], ...validationMessage("income.validation.period")({}) },
|
{ path: ["to"], ...validationMessage("income.validation.period")({}) },
|
||||||
);
|
);
|
||||||
|
|
||||||
const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
const useAccountIncomePageViewModel = (
|
||||||
const { accountId } = useParams();
|
scopeId?: string,
|
||||||
|
): AccountIncomePageViewModel => {
|
||||||
|
const { accountId: routeAccountId } = useParams();
|
||||||
|
const accountId = scopeId ?? routeAccountId;
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const requested = useMemo(
|
const requested = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { AccountOperationsPageError } from "./components/AccountOperationsPageEr
|
|||||||
import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view";
|
import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view";
|
||||||
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
|
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
|
||||||
|
|
||||||
const AccountOperationsPage: FC = () => {
|
type AccountOperationsPageProps = { accountId?: string };
|
||||||
const viewModel = useAccountOperationsPageVm();
|
|
||||||
|
const AccountOperationsPage: FC<AccountOperationsPageProps> = (props) => {
|
||||||
|
const viewModel = useAccountOperationsPageVm(props.accountId);
|
||||||
|
|
||||||
if (viewModel.state === "loading") {
|
if (viewModel.state === "loading") {
|
||||||
return <AccountOperationsPageLoading />;
|
return <AccountOperationsPageLoading />;
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router";
|
import { useParams, useSearchParams } from "react-router";
|
||||||
import {
|
import {
|
||||||
type BrokerageOperation,
|
type BrokerageOperation,
|
||||||
brokerageOperationsQueryOptions,
|
brokerageOperationsQueryOptions,
|
||||||
type OperationCategory,
|
type OperationCategory,
|
||||||
|
operationCategories,
|
||||||
useBrokerageOperations,
|
useBrokerageOperations,
|
||||||
} from "@/entities/broker-operation";
|
} from "@/entities/broker-operation";
|
||||||
|
|
||||||
|
import { apiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
type AccountOperationsPageViewModel =
|
type AccountOperationsPageViewModel =
|
||||||
| { state: "loading" }
|
| { state: "loading" }
|
||||||
| { state: "error"; retry: () => void }
|
| { state: "error"; retry: () => void }
|
||||||
| {
|
| {
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
|
refreshing?: boolean;
|
||||||
|
refreshError?: boolean;
|
||||||
|
retry?: () => void;
|
||||||
draftFrom: string | null;
|
draftFrom: string | null;
|
||||||
draftTo: string | null;
|
draftTo: string | null;
|
||||||
draftCategories: OperationCategory[];
|
draftCategories: OperationCategory[];
|
||||||
@@ -29,37 +35,76 @@ type AccountOperationsPageViewModel =
|
|||||||
onPrevious: () => void;
|
onPrevious: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
const useAccountOperationsPageVm = (
|
||||||
const { accountId } = useParams();
|
scopeId?: string,
|
||||||
|
): AccountOperationsPageViewModel => {
|
||||||
|
const { accountId: routeAccountId } = useParams();
|
||||||
|
const accountId = scopeId ?? routeAccountId;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [appliedFrom, setAppliedFrom] = useState<string | null>(null);
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [appliedTo, setAppliedTo] = useState<string | null>(null);
|
const appliedFrom = searchParams.get("from");
|
||||||
const [appliedCategories, setAppliedCategories] = useState<
|
const appliedTo = searchParams.get("to");
|
||||||
OperationCategory[]
|
const appliedCategories = operationCategories.filter((category) =>
|
||||||
>([]);
|
(searchParams.get("categories") ?? "").split(",").includes(category),
|
||||||
const [draftFrom, setDraftFrom] = useState<string | null>(null);
|
|
||||||
const [draftTo, setDraftTo] = useState<string | null>(null);
|
|
||||||
const [draftCategories, setDraftCategories] = useState<OperationCategory[]>(
|
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
const [cursors, setCursors] = useState<string[]>([]);
|
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
|
||||||
|
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
|
||||||
|
const [draftCategories, setDraftCategories] =
|
||||||
|
useState<OperationCategory[]>(appliedCategories);
|
||||||
|
const scopeKey = JSON.stringify([
|
||||||
|
accountId,
|
||||||
|
appliedFrom,
|
||||||
|
appliedTo,
|
||||||
|
appliedCategories,
|
||||||
|
]);
|
||||||
|
const [pagination, setPagination] = useState<{
|
||||||
|
scopeKey: string;
|
||||||
|
cursors: string[];
|
||||||
|
}>({ scopeKey, cursors: [] });
|
||||||
|
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
|
||||||
const cursor = cursors.at(-1);
|
const cursor = cursors.at(-1);
|
||||||
const operations = useBrokerageOperations(accountId, {
|
useEffect(() => {
|
||||||
|
setDraftFrom(searchParams.get("from"));
|
||||||
|
setDraftTo(searchParams.get("to"));
|
||||||
|
setDraftCategories(
|
||||||
|
operationCategories.filter((category) =>
|
||||||
|
(searchParams.get("categories") ?? "").split(",").includes(category),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [searchParams]);
|
||||||
|
const firstPageQuery = {
|
||||||
from: dateStart(appliedFrom),
|
from: dateStart(appliedFrom),
|
||||||
to: dateEnd(appliedTo),
|
to: dateEnd(appliedTo),
|
||||||
...(appliedCategories.length ? { categories: appliedCategories } : {}),
|
...(appliedCategories.length ? { categories: appliedCategories } : {}),
|
||||||
...(cursor ? { cursor } : {}),
|
|
||||||
limit: 100,
|
limit: 100,
|
||||||
|
};
|
||||||
|
const operations = useBrokerageOperations(accountId, {
|
||||||
|
...firstPageQuery,
|
||||||
|
...(cursor ? { cursor } : {}),
|
||||||
});
|
});
|
||||||
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
|
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
|
||||||
limit: 100,
|
limit: 100,
|
||||||
}).queryKey;
|
}).queryKey;
|
||||||
|
|
||||||
const resetCursors = () => setCursors([]);
|
const resetCursors = () => setPagination({ scopeKey, cursors: [] });
|
||||||
|
const retry = () => {
|
||||||
|
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery)
|
||||||
|
.queryKey,
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
resetCursors();
|
||||||
|
} else void operations.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
if (operations.isLoading) return { state: "loading" };
|
if (operations.isLoading) return { state: "loading" };
|
||||||
if (operations.isError)
|
if (
|
||||||
return { state: "error", retry: () => operations.refetch() };
|
operations.isError &&
|
||||||
|
(!operations.data ||
|
||||||
|
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
|
||||||
|
)
|
||||||
|
return { state: "error", retry };
|
||||||
if (!operations.data) return { state: "loading" };
|
if (!operations.data) return { state: "loading" };
|
||||||
const nextCursor = operations.data.nextCursor;
|
const nextCursor = operations.data.nextCursor;
|
||||||
|
|
||||||
@@ -70,6 +115,9 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
state: "loaded",
|
state: "loaded",
|
||||||
|
refreshing: operations.isFetching,
|
||||||
|
refreshError: operations.isError,
|
||||||
|
retry,
|
||||||
draftFrom,
|
draftFrom,
|
||||||
draftTo,
|
draftTo,
|
||||||
draftCategories,
|
draftCategories,
|
||||||
@@ -81,25 +129,29 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
|||||||
onDraftToChange: setDraftTo,
|
onDraftToChange: setDraftTo,
|
||||||
onDraftCategoriesChange: setDraftCategories,
|
onDraftCategoriesChange: setDraftCategories,
|
||||||
onApply: () => {
|
onApply: () => {
|
||||||
setAppliedFrom(draftFrom);
|
setSearchParams({
|
||||||
setAppliedTo(draftTo);
|
...(draftFrom ? { from: draftFrom } : {}),
|
||||||
setAppliedCategories(draftCategories);
|
...(draftTo ? { to: draftTo } : {}),
|
||||||
|
...(draftCategories.length
|
||||||
|
? { categories: draftCategories.join(",") }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
resetCursors();
|
resetCursors();
|
||||||
},
|
},
|
||||||
onReset: () => {
|
onReset: () => {
|
||||||
setDraftFrom(null);
|
setDraftFrom(null);
|
||||||
setDraftTo(null);
|
setDraftTo(null);
|
||||||
setDraftCategories([]);
|
setDraftCategories([]);
|
||||||
setAppliedFrom(null);
|
setSearchParams({});
|
||||||
setAppliedTo(null);
|
|
||||||
setAppliedCategories([]);
|
|
||||||
resetCursors();
|
resetCursors();
|
||||||
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
|
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
|
||||||
},
|
},
|
||||||
onNext: () => {
|
onNext: () => {
|
||||||
if (nextCursor) setCursors((value) => [...value, nextCursor]);
|
if (nextCursor)
|
||||||
|
setPagination({ scopeKey, cursors: [...cursors, nextCursor] });
|
||||||
},
|
},
|
||||||
onPrevious: () => setCursors((value) => value.slice(0, -1)),
|
onPrevious: () =>
|
||||||
|
setPagination({ scopeKey, cursors: cursors.slice(0, -1) }),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { Button, Group, MultiSelect, Stack, Title } from "@mantine/core";
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
MultiSelect,
|
||||||
|
Progress,
|
||||||
|
Stack,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
import { DateInput } from "@mantine/dates";
|
import { DateInput } from "@mantine/dates";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -67,6 +75,17 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
|||||||
return (
|
return (
|
||||||
<Stack maw={960} gap="lg">
|
<Stack maw={960} gap="lg">
|
||||||
<Title order={1}>{t("ledger.title")}</Title>
|
<Title order={1}>{t("ledger.title")}</Title>
|
||||||
|
{props.refreshing ? (
|
||||||
|
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
|
||||||
|
) : null}
|
||||||
|
{props.refreshError ? (
|
||||||
|
<Alert color="yellow">
|
||||||
|
{t("accounts.refreshError")}
|
||||||
|
<Button variant="subtle" onClick={props.retry}>
|
||||||
|
{t("accounts.retry")}
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
<Group align="end">
|
<Group align="end">
|
||||||
<DateInput
|
<DateInput
|
||||||
label={t("ledger.from")}
|
label={t("ledger.from")}
|
||||||
@@ -103,7 +122,14 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
|||||||
</Group>
|
</Group>
|
||||||
{items.length ? (
|
{items.length ? (
|
||||||
items.map((operation) => (
|
items.map((operation) => (
|
||||||
<LedgerEntryRow key={operation.id} operation={operation} />
|
<LedgerEntryRow
|
||||||
|
key={JSON.stringify([
|
||||||
|
operation.account?.id,
|
||||||
|
operation.id,
|
||||||
|
operation.logoName,
|
||||||
|
])}
|
||||||
|
operation={operation}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<StateView
|
<StateView
|
||||||
|
|||||||
@@ -124,3 +124,24 @@
|
|||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.accountPositions {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: var(--mantine-spacing-sm) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accountPosition {
|
||||||
|
padding-block: var(--mantine-spacing-sm);
|
||||||
|
border-top: 1px solid var(--mantine-color-gray-3);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accountPosition:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accountPositionName {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ import { AccountPageError } from "./components/AccountPageError.view";
|
|||||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||||
import { AccountPageLoading } from "./components/AccountPageLoading.view";
|
import { AccountPageLoading } from "./components/AccountPageLoading.view";
|
||||||
|
|
||||||
const AccountPage: FC = () => {
|
type AccountPageProps = { accountId?: string };
|
||||||
const viewModel = useAccountPageViewModel();
|
|
||||||
|
const AccountPage: FC<AccountPageProps> = (props) => {
|
||||||
|
const viewModel = useAccountPageViewModel(props.accountId);
|
||||||
if (viewModel.state === "loading") return <AccountPageLoading />;
|
if (viewModel.state === "loading") return <AccountPageLoading />;
|
||||||
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
|
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
|
||||||
if (viewModel.state === "empty")
|
if (viewModel.state === "empty")
|
||||||
return (
|
return (
|
||||||
<AccountPageEmpty
|
<AccountPageEmpty
|
||||||
account={viewModel.account}
|
account={viewModel.account}
|
||||||
|
accountId={viewModel.accountId}
|
||||||
refreshing={viewModel.refreshing}
|
refreshing={viewModel.refreshing}
|
||||||
refreshError={viewModel.refreshError}
|
refreshError={viewModel.refreshError}
|
||||||
retry={viewModel.retry}
|
retry={viewModel.retry}
|
||||||
@@ -24,6 +27,7 @@ const AccountPage: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
account={viewModel.account}
|
account={viewModel.account}
|
||||||
|
accountId={viewModel.accountId}
|
||||||
portfolio={viewModel.portfolio}
|
portfolio={viewModel.portfolio}
|
||||||
positionSections={viewModel.positionSections}
|
positionSections={viewModel.positionSections}
|
||||||
refreshing={viewModel.refreshing}
|
refreshing={viewModel.refreshing}
|
||||||
|
|||||||
@@ -172,13 +172,13 @@ describe("useAccountPageViewModel", () => {
|
|||||||
"bonds",
|
"bonds",
|
||||||
"other",
|
"other",
|
||||||
]);
|
]);
|
||||||
expect(sections[2].positions.map((position) => position.figi)).toEqual([
|
expect(sections[2].groups.map((group) => group.positions[0].figi)).toEqual([
|
||||||
"FIGI-FUND",
|
"FIGI-FUND",
|
||||||
...otherTypes.map((type) => `FIGI-${type}-NEXT`),
|
...otherTypes.map((type) => `FIGI-${type}-NEXT`),
|
||||||
]);
|
]);
|
||||||
expect(sections[0].positions[0].shareText).toBe("40 %");
|
expect(sections[0].groups[0].positions[0].shareText).toBe("40 %");
|
||||||
expect(sections[1].positions[0].shareText).toBe("20 %");
|
expect(sections[1].groups[0].positions[0].shareText).toBe("20 %");
|
||||||
expect(sections[2].positions[1].shareText).toBe("20 %");
|
expect(sections[2].groups[1].positions[0].shareText).toBe("20 %");
|
||||||
expect(snapshot).toEqual(original);
|
expect(snapshot).toEqual(original);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,7 +195,12 @@ describe("useAccountPageViewModel", () => {
|
|||||||
typeof result.current,
|
typeof result.current,
|
||||||
{ state: "loaded" }
|
{ state: "loaded" }
|
||||||
>;
|
>;
|
||||||
expect(loaded.positionSections).toMatchObject([
|
expect(
|
||||||
|
loaded.positionSections.map(({ kind, groups }) => ({
|
||||||
|
kind,
|
||||||
|
positions: groups.flatMap((group) => group.positions),
|
||||||
|
})),
|
||||||
|
).toMatchObject([
|
||||||
{
|
{
|
||||||
kind: "stocks",
|
kind: "stocks",
|
||||||
positions: [
|
positions: [
|
||||||
|
|||||||
@@ -26,9 +26,14 @@ type AccountPagePosition = Portfolio["positions"][number] & {
|
|||||||
logoSrc: string | null;
|
logoSrc: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AccountPagePositionGroup = {
|
||||||
|
key: string;
|
||||||
|
positions: AccountPagePosition[];
|
||||||
|
};
|
||||||
|
|
||||||
type AccountPagePositionSection = {
|
type AccountPagePositionSection = {
|
||||||
kind: PositionSectionKind;
|
kind: PositionSectionKind;
|
||||||
positions: AccountPagePosition[];
|
groups: AccountPagePositionGroup[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type AccountPageRecentOperations =
|
type AccountPageRecentOperations =
|
||||||
@@ -76,8 +81,9 @@ type AccountPageViewModel =
|
|||||||
| AccountPageEmptyViewModel
|
| AccountPageEmptyViewModel
|
||||||
| AccountPageLoadedViewModel;
|
| AccountPageLoadedViewModel;
|
||||||
|
|
||||||
function useAccountPageViewModel(): AccountPageViewModel {
|
function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
||||||
const { accountId } = useParams();
|
const { accountId: routeAccountId } = useParams();
|
||||||
|
const accountId = scopeId ?? routeAccountId;
|
||||||
const portfolio = usePortfolio(accountId);
|
const portfolio = usePortfolio(accountId);
|
||||||
const operations = useBrokerageOperations(accountId, {
|
const operations = useBrokerageOperations(accountId, {
|
||||||
limit: 5,
|
limit: 5,
|
||||||
@@ -177,9 +183,7 @@ function toAccountPagePositionSections(
|
|||||||
const stocks = toSection("stocks", portfolio, denominators);
|
const stocks = toSection("stocks", portfolio, denominators);
|
||||||
const bonds = toSection("bonds", portfolio, denominators);
|
const bonds = toSection("bonds", portfolio, denominators);
|
||||||
const other = toSection("other", portfolio, denominators);
|
const other = toSection("other", portfolio, denominators);
|
||||||
return [stocks, bonds, other].filter(
|
return [stocks, bonds, other].filter((section) => section.groups.length > 0);
|
||||||
(section) => section.positions.length > 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSection(
|
function toSection(
|
||||||
@@ -189,20 +193,51 @@ function toSection(
|
|||||||
): AccountPagePositionSection {
|
): AccountPagePositionSection {
|
||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
positions: portfolio.positions
|
groups: groupPositions(
|
||||||
.filter((position) => {
|
portfolio.positions
|
||||||
if (kind === "stocks") return position.type === "STOCK";
|
.filter((position) => {
|
||||||
if (kind === "bonds") return position.type === "BOND";
|
if (kind === "stocks") return position.type === "STOCK";
|
||||||
return (
|
if (kind === "bonds") return position.type === "BOND";
|
||||||
position.type !== "STOCK" &&
|
return (
|
||||||
position.type !== "BOND" &&
|
position.type !== "STOCK" &&
|
||||||
position.type !== "CURRENCY"
|
position.type !== "BOND" &&
|
||||||
);
|
position.type !== "CURRENCY"
|
||||||
})
|
);
|
||||||
.map((position) => toPosition(position, denominators, kind)),
|
})
|
||||||
|
.map((position) => toPosition(position, denominators, kind)),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupPositions(
|
||||||
|
positions: AccountPagePosition[],
|
||||||
|
): AccountPagePositionGroup[] {
|
||||||
|
const groups = new Map<string, AccountPagePositionGroup>();
|
||||||
|
for (const [index, position] of positions.entries()) {
|
||||||
|
const identity = position.figi?.trim()
|
||||||
|
? ["figi", position.figi.trim()]
|
||||||
|
: position.isin?.trim()
|
||||||
|
? ["isin", position.isin.trim()]
|
||||||
|
: null;
|
||||||
|
const rowKey = JSON.stringify(["row", position.account?.id, index]);
|
||||||
|
const key =
|
||||||
|
position.account && identity
|
||||||
|
? JSON.stringify([position.type, ...identity])
|
||||||
|
: rowKey;
|
||||||
|
const group = groups.get(key);
|
||||||
|
if (
|
||||||
|
group &&
|
||||||
|
!group.positions.some((item) => item.account?.id === position.account?.id)
|
||||||
|
) {
|
||||||
|
group.positions.push(position);
|
||||||
|
} else {
|
||||||
|
const uniqueKey = group ? rowKey : key;
|
||||||
|
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...groups.values()];
|
||||||
|
}
|
||||||
|
|
||||||
function toPosition(
|
function toPosition(
|
||||||
position: Portfolio["positions"][number],
|
position: Portfolio["positions"][number],
|
||||||
denominators: Map<string, number>,
|
denominators: Map<string, number>,
|
||||||
@@ -237,6 +272,7 @@ function toPosition(
|
|||||||
export {
|
export {
|
||||||
type AccountPageIncomeSummary,
|
type AccountPageIncomeSummary,
|
||||||
type AccountPagePosition,
|
type AccountPagePosition,
|
||||||
|
type AccountPagePositionGroup,
|
||||||
type AccountPagePositionSection,
|
type AccountPagePositionSection,
|
||||||
type AccountPageRecentEvents,
|
type AccountPageRecentEvents,
|
||||||
type AccountPageRecentOperations,
|
type AccountPageRecentOperations,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { accountTypeKey } from "@/entities/brokerage-account";
|
|||||||
|
|
||||||
type AccountHeadingProps = {
|
type AccountHeadingProps = {
|
||||||
account?: BrokerageAccount;
|
account?: BrokerageAccount;
|
||||||
|
accountId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AccountHeading: FC<AccountHeadingProps> = (props) => {
|
const AccountHeading: FC<AccountHeadingProps> = (props) => {
|
||||||
@@ -14,8 +15,16 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
|
<Title order={1}>
|
||||||
{account ? (
|
{props.accountId === "all"
|
||||||
|
? t("accounts.all")
|
||||||
|
: (account?.name ?? t("portfolio.title"))}
|
||||||
|
</Title>
|
||||||
|
{props.accountId === "all" ? (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{t("accounts.allDescription")}
|
||||||
|
</Text>
|
||||||
|
) : account ? (
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
{t(accountTypeKey(account.type))}
|
{t(accountTypeKey(account.type))}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { RecentOperations } from "./RecentOperations.view";
|
|||||||
|
|
||||||
type AccountPageEmptyProps = {
|
type AccountPageEmptyProps = {
|
||||||
account?: BrokerageAccount;
|
account?: BrokerageAccount;
|
||||||
|
accountId?: string;
|
||||||
refreshing?: boolean;
|
refreshing?: boolean;
|
||||||
refreshError?: boolean;
|
refreshError?: boolean;
|
||||||
retry?: () => void;
|
retry?: () => void;
|
||||||
@@ -28,7 +29,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<AccountHeading account={props.account} />
|
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||||
<PortfolioRefreshState
|
<PortfolioRefreshState
|
||||||
refreshing={props.refreshing ?? false}
|
refreshing={props.refreshing ?? false}
|
||||||
refreshError={props.refreshError ?? false}
|
refreshError={props.refreshError ?? false}
|
||||||
@@ -36,7 +37,11 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
<StateView
|
<StateView
|
||||||
title={t("portfolio.emptyTitle")}
|
title={t("portfolio.emptyTitle")}
|
||||||
text={t("portfolio.emptyText")}
|
text={t(
|
||||||
|
props.accountId === "all"
|
||||||
|
? "portfolio.allEmptyText"
|
||||||
|
: "portfolio.emptyText",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<RecentOperations operations={props.recentOperations} />
|
<RecentOperations operations={props.recentOperations} />
|
||||||
<BrokerageEventsPreview events={props.recentEvents} />
|
<BrokerageEventsPreview events={props.recentEvents} />
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { RecentOperations } from "./RecentOperations.view";
|
|||||||
|
|
||||||
type AccountPageLoadedProps = {
|
type AccountPageLoadedProps = {
|
||||||
account?: BrokerageAccount;
|
account?: BrokerageAccount;
|
||||||
|
accountId?: string;
|
||||||
portfolio: Portfolio;
|
portfolio: Portfolio;
|
||||||
positionSections: AccountPagePositionSection[];
|
positionSections: AccountPagePositionSection[];
|
||||||
refreshing?: boolean;
|
refreshing?: boolean;
|
||||||
@@ -35,7 +36,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack className={classes.page} gap="lg">
|
<Stack className={classes.page} gap="lg">
|
||||||
<AccountHeading account={props.account} />
|
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||||
<PortfolioRefreshState
|
<PortfolioRefreshState
|
||||||
refreshing={props.refreshing ?? false}
|
refreshing={props.refreshing ?? false}
|
||||||
refreshError={props.refreshError ?? false}
|
refreshError={props.refreshError ?? false}
|
||||||
@@ -45,9 +46,14 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
|
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
{t("portfolio.updatedAt", {
|
{t(
|
||||||
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
|
props.accountId === "all"
|
||||||
})}
|
? "portfolio.allUpdatedAt"
|
||||||
|
: "portfolio.updatedAt",
|
||||||
|
{
|
||||||
|
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
|
||||||
|
},
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
||||||
<Accordion
|
<Accordion
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Card, Group, Stack, Text } from "@mantine/core";
|
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router";
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
import classes from "../AccountPage.module.css";
|
import classes from "../AccountPage.module.css";
|
||||||
import type {
|
import type {
|
||||||
@@ -37,6 +38,15 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
|
const accountLink = position.account ? (
|
||||||
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
to={`/accounts/${encodeURIComponent(position.account.id)}`}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{position.account.name}
|
||||||
|
</Anchor>
|
||||||
|
) : null;
|
||||||
const secondaryText = [
|
const secondaryText = [
|
||||||
position.averagePurchasePrice
|
position.averagePurchasePrice
|
||||||
? t("portfolio.avgPrice", {
|
? t("portfolio.avgPrice", {
|
||||||
@@ -63,6 +73,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
|||||||
<Text fw={600} lineClamp={1}>
|
<Text fw={600} lineClamp={1}>
|
||||||
{position.displayName}
|
{position.displayName}
|
||||||
</Text>
|
</Text>
|
||||||
|
{accountLink}
|
||||||
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
|
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
|
||||||
{identifierText}
|
{identifierText}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -94,6 +105,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
|||||||
<Text fw={700} lineClamp={1}>
|
<Text fw={700} lineClamp={1}>
|
||||||
{position.displayName}
|
{position.displayName}
|
||||||
</Text>
|
</Text>
|
||||||
|
{accountLink}
|
||||||
{position.isin ? (
|
{position.isin ? (
|
||||||
<Text c="dimmed" size="xs" className={classes.mono}>
|
<Text c="dimmed" size="xs" className={classes.mono}>
|
||||||
{position.isin}
|
{position.isin}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { formatMoney } from "@/shared/lib";
|
||||||
|
import classes from "../AccountPage.module.css";
|
||||||
|
import type {
|
||||||
|
AccountPagePositionGroup,
|
||||||
|
AccountPagePositionSection,
|
||||||
|
} from "../AccountPage.vm";
|
||||||
|
import { PositionLogo } from "./PositionLogo.view";
|
||||||
|
|
||||||
|
type PositionGroupCardProps = {
|
||||||
|
group: AccountPagePositionGroup;
|
||||||
|
kind: AccountPagePositionSection["kind"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
|
||||||
|
const { group, kind } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const identity = group.positions[0];
|
||||||
|
return (
|
||||||
|
<Card className={classes.tile} withBorder>
|
||||||
|
<Group wrap="nowrap" gap="sm">
|
||||||
|
{kind !== "other" ? (
|
||||||
|
<PositionLogo
|
||||||
|
key={identity.logoSrc ?? "no-logo"}
|
||||||
|
src={identity.logoSrc}
|
||||||
|
label={identity.displayName}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Stack gap={2} miw={0}>
|
||||||
|
<Text fw={600}>{identity.displayName}</Text>
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{identity.ticker ?? identity.isin ?? identity.figi}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<Stack
|
||||||
|
component="ul"
|
||||||
|
gap={0}
|
||||||
|
className={classes.accountPositions}
|
||||||
|
aria-label={t("portfolio.byAccount")}
|
||||||
|
>
|
||||||
|
{group.positions.map((position) => (
|
||||||
|
<Stack
|
||||||
|
component="li"
|
||||||
|
key={position.account?.id}
|
||||||
|
gap={4}
|
||||||
|
className={classes.accountPosition}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" gap="xs">
|
||||||
|
{position.account ? (
|
||||||
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
to={`/accounts/${encodeURIComponent(position.account.id)}`}
|
||||||
|
size="sm"
|
||||||
|
className={classes.accountPositionName}
|
||||||
|
>
|
||||||
|
{position.account.name}
|
||||||
|
</Anchor>
|
||||||
|
) : null}
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{t("portfolio.quantity", { value: position.quantity })}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between" gap="xs">
|
||||||
|
{position.currentValue ? (
|
||||||
|
<Text size="sm">{formatMoney(position.currentValue)}</Text>
|
||||||
|
) : null}
|
||||||
|
{position.returnText ? (
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
className={
|
||||||
|
position.returnTone
|
||||||
|
? classes[position.returnTone]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{kind === "other"
|
||||||
|
? t("portfolio.return", { value: position.returnText })
|
||||||
|
: position.returnText}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
{position.averagePurchasePrice ? (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("portfolio.avgPrice", {
|
||||||
|
value: formatMoney(position.averagePurchasePrice),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{position.shareText ? (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("portfolio.share", { value: position.shareText })}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { PositionGroupCard };
|
||||||
@@ -2,6 +2,7 @@ import { SimpleGrid } from "@mantine/core";
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import type { AccountPagePositionSection } from "../AccountPage.vm";
|
import type { AccountPagePositionSection } from "../AccountPage.vm";
|
||||||
import { PositionCard } from "./PositionCard.view";
|
import { PositionCard } from "./PositionCard.view";
|
||||||
|
import { PositionGroupCard } from "./PositionGroupCard.view";
|
||||||
|
|
||||||
type PositionSectionProps = {
|
type PositionSectionProps = {
|
||||||
section: AccountPagePositionSection;
|
section: AccountPagePositionSection;
|
||||||
@@ -13,13 +14,21 @@ const PositionSection: FC<PositionSectionProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}>
|
<SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}>
|
||||||
{section.positions.map((position) => (
|
{section.groups.map((group) =>
|
||||||
<PositionCard
|
group.positions.length > 1 ? (
|
||||||
kind={section.kind}
|
<PositionGroupCard
|
||||||
key={`${position.figi}-${position.displayName}-${position.quantity}`}
|
key={group.key}
|
||||||
position={position}
|
group={group}
|
||||||
/>
|
kind={section.kind}
|
||||||
))}
|
/>
|
||||||
|
) : (
|
||||||
|
<PositionCard
|
||||||
|
key={group.key}
|
||||||
|
position={group.positions[0]}
|
||||||
|
kind={section.kind}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,7 +31,14 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
|||||||
) : null}
|
) : null}
|
||||||
{operations.state === "loaded"
|
{operations.state === "loaded"
|
||||||
? operations.items.map((operation) => (
|
? operations.items.map((operation) => (
|
||||||
<LedgerEntryRow key={operation.id} operation={operation} />
|
<LedgerEntryRow
|
||||||
|
key={JSON.stringify([
|
||||||
|
operation.account?.id,
|
||||||
|
operation.id,
|
||||||
|
operation.logoName,
|
||||||
|
])}
|
||||||
|
operation={operation}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
<Button component={Link} to={operations.to} variant="subtle">
|
<Button component={Link} to={operations.to} variant="subtle">
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
import { toAccountPagePositionSections } from "./AccountPage.vm";
|
||||||
|
import { PositionSection } from "./components/PositionSection.view";
|
||||||
|
|
||||||
|
const position: Portfolio["positions"][number] = {
|
||||||
|
account: { id: "a", name: "Основной" },
|
||||||
|
figi: "FIGI-SBER",
|
||||||
|
displayName: "Сбербанк",
|
||||||
|
type: "STOCK",
|
||||||
|
quantity: "1.25",
|
||||||
|
currentValue: { currency: "RUB", value: "375" },
|
||||||
|
averagePurchasePrice: { currency: "RUB", value: "280" },
|
||||||
|
absoluteReturn: { currency: "RUB", value: "25" },
|
||||||
|
isin: "RU0009029540",
|
||||||
|
ticker: "SBER",
|
||||||
|
logoName: null,
|
||||||
|
};
|
||||||
|
const second = {
|
||||||
|
...position,
|
||||||
|
account: { id: "b", name: "ИИС" },
|
||||||
|
quantity: "-2",
|
||||||
|
currentValue: null,
|
||||||
|
averagePurchasePrice: null,
|
||||||
|
absoluteReturn: null,
|
||||||
|
};
|
||||||
|
function sections(positions: Portfolio["positions"]) {
|
||||||
|
return toAccountPagePositionSections({
|
||||||
|
lastSuccessfulAt: "2026-09-09T12:00:00Z",
|
||||||
|
isStale: false,
|
||||||
|
totalsByCurrency: [],
|
||||||
|
cashBalances: [],
|
||||||
|
positions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Positions grouped by security", () => {
|
||||||
|
it("shows one security with exact per-account quantities and valuations", () => {
|
||||||
|
const section = sections([position, second])[0];
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<PositionSection section={section} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
|
||||||
|
const rows = within(
|
||||||
|
screen.getByRole("list", { name: "Позиции по счетам" }),
|
||||||
|
).getAllByRole("listitem");
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
within(rows[0]).getByRole("link", { name: "Основной" }),
|
||||||
|
).toHaveAttribute("href", "/accounts/a");
|
||||||
|
expect(within(rows[0]).getByText("Количество: 1.25")).toBeInTheDocument();
|
||||||
|
expect(within(rows[0]).getByText("375 ₽")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(rows[0]).getByText("Средняя цена: 280 ₽"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(within(rows[1]).getByRole("link", { name: "ИИС" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/accounts/b",
|
||||||
|
);
|
||||||
|
expect(within(rows[1]).getByText("Количество: -2")).toBeInTheDocument();
|
||||||
|
expect(rows[1]).not.toHaveTextContent("₽");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => {
|
||||||
|
expect(
|
||||||
|
sections([
|
||||||
|
{ ...position, figi: null },
|
||||||
|
{ ...second, figi: null },
|
||||||
|
])[0].groups,
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups,
|
||||||
|
).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
sections([
|
||||||
|
{ ...position, figi: null, isin: null },
|
||||||
|
{ ...second, figi: null, isin: null },
|
||||||
|
])[0].groups,
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves one-account cards and does not merge repeated rows on the same account", () => {
|
||||||
|
expect(
|
||||||
|
sections([position, { ...second, account: position.account }])[0].groups,
|
||||||
|
).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
sections([
|
||||||
|
{ ...position, account: undefined },
|
||||||
|
{ ...second, account: undefined },
|
||||||
|
])[0].groups,
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps currencies and purchase prices attached to their source positions", () => {
|
||||||
|
const source = [
|
||||||
|
position,
|
||||||
|
{
|
||||||
|
...second,
|
||||||
|
currentValue: { currency: "USD", value: "1.01" },
|
||||||
|
averagePurchasePrice: { currency: "USD", value: "0.99" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const before = structuredClone(source);
|
||||||
|
const [group] = sections(source)[0].groups;
|
||||||
|
expect(
|
||||||
|
group.positions.map((item) => [
|
||||||
|
item.quantity,
|
||||||
|
item.currentValue,
|
||||||
|
item.averagePurchasePrice,
|
||||||
|
]),
|
||||||
|
).toEqual(
|
||||||
|
source.map((item) => [
|
||||||
|
item.quantity,
|
||||||
|
item.currentValue,
|
||||||
|
item.averagePurchasePrice,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(source).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Stack, Title } from "@mantine/core";
|
import { Button, Stack, Title } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router";
|
||||||
import {
|
import {
|
||||||
type BrokerageAccountCard,
|
type BrokerageAccountCard,
|
||||||
BrokerageAccountList,
|
BrokerageAccountList,
|
||||||
@@ -21,6 +22,14 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{t("accounts.title")}</Title>
|
<Title order={1}>{t("accounts.title")}</Title>
|
||||||
<AccountsRefreshState {...refresh} />
|
<AccountsRefreshState {...refresh} />
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/accounts/all"
|
||||||
|
variant="light"
|
||||||
|
w="fit-content"
|
||||||
|
>
|
||||||
|
{t("accounts.openAll")}
|
||||||
|
</Button>
|
||||||
<BrokerageAccountList accounts={accounts} />
|
<BrokerageAccountList accounts={accounts} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+417
@@ -116,6 +116,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/brokerage-accounts/all/operations": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["BrokerageOperationsController_all"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/brokerage-accounts/{accountId}/operations": {
|
"/api/v1/brokerage-accounts/{accountId}/operations": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -132,6 +148,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/brokerage-accounts/all/portfolio": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["PortfolioController_all"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/brokerage-accounts/{accountId}/portfolio": {
|
"/api/v1/brokerage-accounts/{accountId}/portfolio": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -148,6 +180,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/brokerage-accounts/all/events": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["UpcomingEventsController_all"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/brokerage-accounts/{accountId}/events": {
|
"/api/v1/brokerage-accounts/{accountId}/events": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -164,6 +212,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/brokerage-accounts/all/income-summary": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["IncomeAnalyticsController_allSummary"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/brokerage-accounts/all/income-analytics": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["IncomeAnalyticsController_allAnalytics"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/brokerage-accounts/{accountId}/income-summary": {
|
"/api/v1/brokerage-accounts/{accountId}/income-summary": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -253,7 +333,12 @@ export interface components {
|
|||||||
openedOn: string | null;
|
openedOn: string | null;
|
||||||
portfolioSummary: components["schemas"]["PortfolioSummaryDto"] | null;
|
portfolioSummary: components["schemas"]["PortfolioSummaryDto"] | null;
|
||||||
};
|
};
|
||||||
|
AccountReferenceDto: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
BrokerageOperationDto: {
|
BrokerageOperationDto: {
|
||||||
|
account?: components["schemas"]["AccountReferenceDto"];
|
||||||
id: string;
|
id: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
@@ -281,6 +366,7 @@ export interface components {
|
|||||||
observedAt: string;
|
observedAt: string;
|
||||||
};
|
};
|
||||||
PortfolioPositionDto: {
|
PortfolioPositionDto: {
|
||||||
|
account?: components["schemas"]["AccountReferenceDto"];
|
||||||
figi: string | null;
|
figi: string | null;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
@@ -302,6 +388,7 @@ export interface components {
|
|||||||
cashBalances: components["schemas"]["MoneyDto"][];
|
cashBalances: components["schemas"]["MoneyDto"][];
|
||||||
};
|
};
|
||||||
UpcomingEventDto: {
|
UpcomingEventDto: {
|
||||||
|
account?: components["schemas"]["AccountReferenceDto"];
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
type: "coupon" | "dividend" | "maturity" | "offer";
|
type: "coupon" | "dividend" | "maturity" | "offer";
|
||||||
/** Format: date */
|
/** Format: date */
|
||||||
@@ -369,6 +456,7 @@ export interface components {
|
|||||||
receiptCount: number;
|
receiptCount: number;
|
||||||
};
|
};
|
||||||
IncomeAnalyticsEntryDto: {
|
IncomeAnalyticsEntryDto: {
|
||||||
|
account?: components["schemas"]["AccountReferenceDto"];
|
||||||
id: string;
|
id: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
@@ -995,6 +1083,76 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
BrokerageOperationsController_all: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["BrokerageOperationsPageDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
BrokerageOperationsController_get: {
|
BrokerageOperationsController_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -1067,6 +1225,70 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
PortfolioController_all: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PortfolioDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
PortfolioController_get: {
|
PortfolioController_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1133,6 +1355,70 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
UpcomingEventsController_all: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UpcomingEventsDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
UpcomingEventsController_get: {
|
UpcomingEventsController_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1199,6 +1485,137 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
IncomeAnalyticsController_allSummary: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
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_allAnalytics: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
IncomeAnalyticsController_summary: {
|
IncomeAnalyticsController_summary: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -680,6 +680,139 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/brokerage-accounts/all/operations": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "BrokerageOperationsController_all",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "from",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "to",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "categories",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"trade",
|
||||||
|
"income",
|
||||||
|
"tax",
|
||||||
|
"fee",
|
||||||
|
"transfer",
|
||||||
|
"other"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cursor",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "limit",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 1000,
|
||||||
|
"default": 100,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BrokerageOperationsPageDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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}/operations": {
|
"/api/v1/brokerage-accounts/{accountId}/operations": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "BrokerageOperationsController_get",
|
"operationId": "BrokerageOperationsController_get",
|
||||||
@@ -821,6 +954,82 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/brokerage-accounts/all/portfolio": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "PortfolioController_all",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PortfolioDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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}/portfolio": {
|
"/api/v1/brokerage-accounts/{accountId}/portfolio": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "PortfolioController_get",
|
"operationId": "PortfolioController_get",
|
||||||
@@ -906,6 +1115,82 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/brokerage-accounts/all/events": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "UpcomingEventsController_all",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UpcomingEventsDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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}/events": {
|
"/api/v1/brokerage-accounts/{accountId}/events": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "UpcomingEventsController_get",
|
"operationId": "UpcomingEventsController_get",
|
||||||
@@ -991,6 +1276,177 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/brokerage-accounts/all/income-summary": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "IncomeAnalyticsController_allSummary",
|
||||||
|
"parameters": [],
|
||||||
|
"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/all/income-analytics": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "IncomeAnalyticsController_allAnalytics",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/brokerage-accounts/{accountId}/income-summary": {
|
"/api/v1/brokerage-accounts/{accountId}/income-summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "IncomeAnalyticsController_summary",
|
"operationId": "IncomeAnalyticsController_summary",
|
||||||
@@ -1367,9 +1823,27 @@
|
|||||||
"portfolioSummary"
|
"portfolioSummary"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"AccountReferenceDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
},
|
||||||
"BrokerageOperationDto": {
|
"BrokerageOperationDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"account": {
|
||||||
|
"$ref": "#/components/schemas/AccountReferenceDto"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1508,6 +1982,9 @@
|
|||||||
"PortfolioPositionDto": {
|
"PortfolioPositionDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"account": {
|
||||||
|
"$ref": "#/components/schemas/AccountReferenceDto"
|
||||||
|
},
|
||||||
"figi": {
|
"figi": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
@@ -1625,6 +2102,9 @@
|
|||||||
"UpcomingEventDto": {
|
"UpcomingEventDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"account": {
|
||||||
|
"$ref": "#/components/schemas/AccountReferenceDto"
|
||||||
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -1900,6 +2380,9 @@
|
|||||||
"IncomeAnalyticsEntryDto": {
|
"IncomeAnalyticsEntryDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"account": {
|
||||||
|
"$ref": "#/components/schemas/AccountReferenceDto"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ const ru = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
|
all: "Все счета",
|
||||||
|
openAll: "Портфель по всем счетам",
|
||||||
|
scope: "Счёт",
|
||||||
|
allDescription: "Портфель по всем доступным инвестиционным счетам",
|
||||||
title: "Счета",
|
title: "Счета",
|
||||||
emptyTitle: "Нет инвестиционных счетов",
|
emptyTitle: "Нет инвестиционных счетов",
|
||||||
emptyText:
|
emptyText:
|
||||||
@@ -62,6 +66,9 @@ const ru = {
|
|||||||
},
|
},
|
||||||
account: { title: "Счет" },
|
account: { title: "Счет" },
|
||||||
portfolio: {
|
portfolio: {
|
||||||
|
byAccount: "Позиции по счетам",
|
||||||
|
allEmptyText: "На доступных счетах нет позиций и денежных остатков.",
|
||||||
|
allUpdatedAt: "Самые ранние данные в сводке: {{date}}",
|
||||||
currencies: {
|
currencies: {
|
||||||
RUB: "Российский рубль",
|
RUB: "Российский рубль",
|
||||||
USD: "Доллар США",
|
USD: "Доллар США",
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ class ResizeObserver {
|
|||||||
|
|
||||||
window.ResizeObserver = ResizeObserver;
|
window.ResizeObserver = ResizeObserver;
|
||||||
|
|
||||||
|
// JSDOM has no layout/scrolling; Mantine Combobox scrolls the active option.
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||||
|
configurable: true,
|
||||||
|
value: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
Object.defineProperty(window, "matchMedia", {
|
Object.defineProperty(window, "matchMedia", {
|
||||||
writable: true,
|
writable: true,
|
||||||
value: (query: string) => ({
|
value: (query: string) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user