refactor: clarify frontend model ownership
This commit is contained in:
@@ -238,6 +238,34 @@ describe("All accounts routes", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["operations", "events", "income"])(
|
||||||
|
"restores applied dates when switching accounts in %s",
|
||||||
|
async (section) => {
|
||||||
|
const search = "?from=2026-08-01&to=2026-08-31";
|
||||||
|
renderPage(`/accounts/all/${section}${search}`);
|
||||||
|
fireEvent.click(await screen.findByLabelText("Период"));
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /10 August 2026/i }),
|
||||||
|
);
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /31 August 2026/i }),
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||||
|
"10.08.2026 – 31.08.2026",
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||||
|
"01.08.2026 – 31.08.2026",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
|
||||||
|
`/accounts/b/${section}${search}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("groups the same security and links its accounts and overview sections", async () => {
|
it("groups the same security and links its accounts and overview sections", async () => {
|
||||||
renderPage("/accounts/all");
|
renderPage("/accounts/all");
|
||||||
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
|
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
|
||||||
@@ -312,6 +340,11 @@ describe("All accounts routes", () => {
|
|||||||
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
|
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
|
||||||
"/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income",
|
"/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income",
|
||||||
);
|
);
|
||||||
|
await screen.findByText("Первая страница");
|
||||||
|
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "Все счета" }));
|
||||||
|
await waitFor(() => expect(screen.getByText("Первая страница")).toBeVisible());
|
||||||
|
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restarts an expired aggregate cursor without losing filters", async () => {
|
it("restarts an expired aggregate cursor without losing filters", async () => {
|
||||||
|
|||||||
@@ -200,42 +200,6 @@ const AppRoutes: FC = () => (
|
|||||||
path="/accounts"
|
path="/accounts"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
|
||||||
element={(
|
|
||||||
<LazyPage variant="content">
|
|
||||||
<AccountPage accountId="all" />
|
|
||||||
</LazyPage>
|
|
||||||
)}
|
|
||||||
path="/accounts/all"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
element={(
|
|
||||||
<LazyPage variant="content">
|
|
||||||
<AccountOperationsPage accountId="all" />
|
|
||||||
</LazyPage>
|
|
||||||
)}
|
|
||||||
path="/accounts/all/operations"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
element={(
|
|
||||||
<LazyPage variant="content">
|
|
||||||
<AccountIncomePage accountId="all" />
|
|
||||||
</LazyPage>
|
|
||||||
)}
|
|
||||||
path="/accounts/all/income"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
element={(
|
|
||||||
<LazyPage variant="content">
|
|
||||||
<AccountEventsPage accountId="all" />
|
|
||||||
</LazyPage>
|
|
||||||
)}
|
|
||||||
path="/accounts/all/events"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
element={(
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
|||||||
|
|
||||||
const MIN_BOND_SEARCH_LENGTH = 2;
|
const MIN_BOND_SEARCH_LENGTH = 2;
|
||||||
|
|
||||||
|
type BondCoupons = NonNullable<paths["/api/v1/bonds/{uid}/coupons"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||||
|
|
||||||
|
type BondMetrics = NonNullable<paths["/api/v1/bonds/{uid}/metrics"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||||
|
|
||||||
type BondPeriod = NonNullable<
|
type BondPeriod = NonNullable<
|
||||||
NonNullable<
|
NonNullable<
|
||||||
paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"]
|
paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"]
|
||||||
@@ -104,6 +108,8 @@ function useBondSearch(query: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
type BondCoupons,
|
||||||
|
type BondMetrics,
|
||||||
type BondPeriod,
|
type BondPeriod,
|
||||||
MIN_BOND_SEARCH_LENGTH,
|
MIN_BOND_SEARCH_LENGTH,
|
||||||
useBond,
|
useBond,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
|
type BondCoupons,
|
||||||
|
type BondMetrics,
|
||||||
type BondPeriod,
|
type BondPeriod,
|
||||||
MIN_BOND_SEARCH_LENGTH,
|
MIN_BOND_SEARCH_LENGTH,
|
||||||
useBond,
|
useBond,
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ function useBrokerageOperations(
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
type BrokerageOperation,
|
type BrokerageOperation,
|
||||||
|
type BrokerageOperationsQuery,
|
||||||
brokerageOperationsQueryOptions,
|
brokerageOperationsQueryOptions,
|
||||||
brokerageOperationsQueryParameters as brokerageOperationsQueryParams,
|
brokerageOperationsQueryParameters as brokerageOperationsQueryParams,
|
||||||
operationCategories,
|
operationCategories,
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export {
|
|||||||
type OperationCategory,
|
type OperationCategory,
|
||||||
useBrokerageOperations,
|
useBrokerageOperations,
|
||||||
} from "./api/useBrokerageOperations";
|
} from "./api/useBrokerageOperations";
|
||||||
|
export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory";
|
||||||
export { LedgerEntryRow } from "./ui/LedgerEntryRow";
|
export { LedgerEntryRow } from "./ui/LedgerEntryRow";
|
||||||
|
|||||||
+19
-16
@@ -1,19 +1,20 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { hashKey, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import {
|
|
||||||
brokerageOperationsQueryOptions,
|
|
||||||
type OperationCategory,
|
|
||||||
useBrokerageOperations,
|
|
||||||
} from "@/entities/broker-operation";
|
|
||||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
function useInstrumentOperations(
|
import {
|
||||||
uid: string,
|
type BrokerageOperationsQuery,
|
||||||
filter: string,
|
brokerageOperationsQueryOptions,
|
||||||
categories: OperationCategory[],
|
useBrokerageOperations,
|
||||||
|
} from "../api/useBrokerageOperations";
|
||||||
|
|
||||||
|
function useBrokerageOperationHistory(
|
||||||
|
accountId: string | undefined,
|
||||||
|
firstPage: Omit<BrokerageOperationsQuery, "cursor">,
|
||||||
) {
|
) {
|
||||||
const scope = `${uid}:${filter}`;
|
const { queryKey } = brokerageOperationsQueryOptions(accountId, firstPage);
|
||||||
|
const scope = hashKey(queryKey);
|
||||||
|
|
||||||
const [pagination, setPagination] = useState<{
|
const [pagination, setPagination] = useState<{
|
||||||
cursors: string[];
|
cursors: string[];
|
||||||
@@ -22,13 +23,14 @@ function useInstrumentOperations(
|
|||||||
|
|
||||||
const cursors = pagination.scope === scope ? pagination.cursors : [];
|
const cursors = pagination.scope === scope ? pagination.cursors : [];
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const firstPage = { categories, instrumentUid: uid, limit: 20 };
|
|
||||||
|
|
||||||
const operations = useBrokerageOperations(uid ? "all" : undefined, {
|
const operations = useBrokerageOperations(accountId, {
|
||||||
...firstPage,
|
...firstPage,
|
||||||
cursor: cursors.at(-1),
|
cursor: cursors.at(-1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reset = () => setPagination({ cursors: [], scope });
|
||||||
|
|
||||||
const retry = (retryScreen: () => unknown) => {
|
const retry = (retryScreen: () => unknown) => {
|
||||||
if (
|
if (
|
||||||
isApiErrorCode(
|
isApiErrorCode(
|
||||||
@@ -36,10 +38,10 @@ function useInstrumentOperations(
|
|||||||
ApiErrorCode.InvalidOperationsCursor,
|
ApiErrorCode.InvalidOperationsCursor,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
setPagination({ cursors: [], scope });
|
reset();
|
||||||
void client.invalidateQueries({
|
void client.invalidateQueries({
|
||||||
exact: true,
|
exact: true,
|
||||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
queryKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else void retryScreen();
|
else void retryScreen();
|
||||||
@@ -56,8 +58,9 @@ function useInstrumentOperations(
|
|||||||
},
|
},
|
||||||
onPrevious: () => setPagination({ cursors: cursors.slice(0, -1), scope }),
|
onPrevious: () => setPagination({ cursors: cursors.slice(0, -1), scope }),
|
||||||
operations,
|
operations,
|
||||||
|
reset,
|
||||||
retry,
|
retry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useInstrumentOperations };
|
export { useBrokerageOperationHistory };
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BrokerageAccount,
|
BrokerageAccount,
|
||||||
useBrokerageAccounts,
|
|
||||||
} from "../api/useBrokerageAccounts";
|
} from "../api/useBrokerageAccounts";
|
||||||
|
|
||||||
const VISIBLE_CURRENCY_LIMIT = 2;
|
const VISIBLE_CURRENCY_LIMIT = 2;
|
||||||
@@ -9,11 +10,7 @@ type BrokerageAccountCard = {
|
|||||||
account: BrokerageAccount;
|
account: BrokerageAccount;
|
||||||
positionCounts: null
|
positionCounts: null
|
||||||
| { bonds: number; stocks: number };
|
| { bonds: number; stocks: number };
|
||||||
source?: NonNullable<
|
source?: NonNullable<InvestmentResourceMeta["dependencies"]>[number];
|
||||||
ReturnType<typeof useBrokerageAccounts>["meta"]
|
|
||||||
>["dependencies"] extends (infer T)[] | undefined
|
|
||||||
? T
|
|
||||||
: never;
|
|
||||||
summary:
|
summary:
|
||||||
| { remainingCurrencyCount: number; state: "available"; totals: Money[] }
|
| { remainingCurrencyCount: number; state: "available"; totals: Money[] }
|
||||||
| { state: "empty" }
|
| { state: "empty" }
|
||||||
@@ -30,9 +27,7 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
|
|||||||
|
|
||||||
function brokerageAccountCards(
|
function brokerageAccountCards(
|
||||||
accounts: BrokerageAccount[],
|
accounts: BrokerageAccount[],
|
||||||
dependencies: NonNullable<
|
dependencies: InvestmentResourceMeta["dependencies"] = [],
|
||||||
ReturnType<typeof useBrokerageAccounts>["meta"]
|
|
||||||
>["dependencies"] = [],
|
|
||||||
): BrokerageAccountCard[] {
|
): BrokerageAccountCard[] {
|
||||||
return accounts.map((account) => {
|
return accounts.map((account) => {
|
||||||
const source = dependencies.find(
|
const source = dependencies.find(
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ type IncomeAnalytics = NonNullable<
|
|||||||
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type IncomeAnalyticsMeta
|
||||||
|
= paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["meta"];
|
||||||
|
|
||||||
type IncomeSummary = NonNullable<
|
type IncomeSummary = NonNullable<
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||||
>;
|
>;
|
||||||
@@ -44,6 +47,7 @@ function useIncomeSummary(accountId?: string) {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
type IncomeAnalytics,
|
type IncomeAnalytics,
|
||||||
|
type IncomeAnalyticsMeta,
|
||||||
type IncomeSummary,
|
type IncomeSummary,
|
||||||
useIncomeAnalytics,
|
useIncomeAnalytics,
|
||||||
useIncomeSummary,
|
useIncomeSummary,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
type IncomeAnalytics,
|
type IncomeAnalytics,
|
||||||
|
type IncomeAnalyticsMeta,
|
||||||
type IncomeSummary,
|
type IncomeSummary,
|
||||||
useIncomeAnalytics,
|
useIncomeAnalytics,
|
||||||
useIncomeSummary,
|
useIncomeSummary,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export { section } from "./model/section";
|
export { section, type SectionState } from "./model/section";
|
||||||
export { useInstrumentOperations } from "./model/useInstrumentOperations";
|
|
||||||
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view";
|
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view";
|
||||||
export { InstrumentEvents } from "./ui/InstrumentEvents.view";
|
export { InstrumentEvents } from "./ui/InstrumentEvents.view";
|
||||||
export { InstrumentPosition } from "./ui/InstrumentPosition.view";
|
export { InstrumentPosition } from "./ui/InstrumentPosition.view";
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import type { paths } from "@/shared/api";
|
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||||
|
|
||||||
type Meta
|
|
||||||
= paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
|
|
||||||
|
|
||||||
type SectionState = {
|
type SectionState = {
|
||||||
error: boolean;
|
error: boolean;
|
||||||
@@ -20,7 +17,7 @@ function section(
|
|||||||
data: unknown;
|
data: unknown;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
meta?: Meta;
|
meta?: InvestmentResourceMeta;
|
||||||
refetch: () => unknown;
|
refetch: () => unknown;
|
||||||
},
|
},
|
||||||
retry?: () => unknown,
|
retry?: () => unknown,
|
||||||
|
|||||||
@@ -6,18 +6,15 @@ import {
|
|||||||
apiErrorCode,
|
apiErrorCode,
|
||||||
apiQueryClient,
|
apiQueryClient,
|
||||||
invalidateResourceAccess,
|
invalidateResourceAccess,
|
||||||
|
type InvestmentResourceMeta,
|
||||||
isApiErrorCode,
|
isApiErrorCode,
|
||||||
type paths,
|
|
||||||
useAuthenticatedQueries,
|
useAuthenticatedQueries,
|
||||||
} from "@/shared/api";
|
} from "@/shared/api";
|
||||||
|
|
||||||
import { type InvestmentScreen, useTbankSync } from "./sync";
|
import { type InvestmentScreen, useTbankSync } from "./sync";
|
||||||
|
|
||||||
type ResourceMeta
|
|
||||||
= paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
|
|
||||||
|
|
||||||
type ScreenResource = {
|
type ScreenResource = {
|
||||||
meta?: ResourceMeta;
|
meta?: InvestmentResourceMeta;
|
||||||
observe: (version?: string) => Promise<unknown>;
|
observe: (version?: string) => Promise<unknown>;
|
||||||
queryKey: QueryKey;
|
queryKey: QueryKey;
|
||||||
transport: {
|
transport: {
|
||||||
@@ -34,7 +31,7 @@ const STATUS_INTERVAL_MS = 1000;
|
|||||||
|
|
||||||
function observeResource(
|
function observeResource(
|
||||||
resource: ScreenResource,
|
resource: ScreenResource,
|
||||||
latest: Map<string, ResourceMeta>,
|
latest: Map<string, InvestmentResourceMeta>,
|
||||||
completedId: string | undefined,
|
completedId: string | undefined,
|
||||||
observed: Map<string, string>,
|
observed: Map<string, string>,
|
||||||
) {
|
) {
|
||||||
@@ -143,7 +140,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
|||||||
invalidateResourceAccess(client, "brokerage", status.error);
|
invalidateResourceAccess(client, "brokerage", status.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const latest = new Map<string, ResourceMeta>();
|
const latest = new Map<string, InvestmentResourceMeta>();
|
||||||
|
|
||||||
const orderedStatuses = statuses.toSorted(
|
const orderedStatuses = statuses.toSorted(
|
||||||
(a, b) => a.dataUpdatedAt - b.dataUpdatedAt,
|
(a, b) => a.dataUpdatedAt - b.dataUpdatedAt,
|
||||||
|
|||||||
@@ -146,12 +146,14 @@ describe("Upcoming event date filters", () => {
|
|||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useAccountEventsPageViewModel("all"),
|
() => useAccountEventsPageViewModel(),
|
||||||
{
|
{
|
||||||
wrapper: ({ children }) => (
|
wrapper: ({ children }) => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter initialEntries={[`?${search}`]}>
|
<MemoryRouter initialEntries={[`/accounts/all/events?${search}`]}>
|
||||||
{children}
|
<Routes>
|
||||||
|
<Route element={children} path="/accounts/:accountId/events" />
|
||||||
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
),
|
),
|
||||||
@@ -326,10 +328,14 @@ it("retains the previous complete forecast and timestamp when its source refresh
|
|||||||
);
|
);
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountEventsPageViewModel("all"), {
|
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
|
||||||
wrapper: ({ children }) => (
|
wrapper: ({ children }) => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter>{children}</MemoryRouter>
|
<MemoryRouter initialEntries={["/accounts/all/events"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route element={children} path="/accounts/:accountId/events" />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ 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";
|
||||||
|
|
||||||
type AccountEventsPageProps = { accountId?: string };
|
const AccountEventsPage: FC = () => {
|
||||||
|
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 />;
|
||||||
|
|
||||||
return viewModel.state === "error"
|
return viewModel.state === "error"
|
||||||
|
|||||||
@@ -27,11 +27,8 @@ type AccountEventsPageViewModel
|
|||||||
| { onReset: () => void; retry: () => void; state: "error" }
|
| { onReset: () => void; retry: () => void; state: "error" }
|
||||||
| { state: "loading" };
|
| { state: "loading" };
|
||||||
|
|
||||||
function useAccountEventsPageViewModel(
|
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
||||||
scopeId?: string,
|
const { accountId = "" } = useParams();
|
||||||
): AccountEventsPageViewModel {
|
|
||||||
const { accountId: routeAccountId } = useParams();
|
|
||||||
const accountId = scopeId ?? routeAccountId ?? "";
|
|
||||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||||
const urlFrom = searchParameters.get("from");
|
const urlFrom = searchParameters.get("from");
|
||||||
const urlTo = searchParameters.get("to");
|
const urlTo = searchParameters.get("to");
|
||||||
@@ -59,13 +56,11 @@ function useAccountEventsPageViewModel(
|
|||||||
setSearchParameters(normalized, { replace: true });
|
setSearchParameters(normalized, { replace: true });
|
||||||
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
|
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
|
||||||
|
|
||||||
const [previousPeriod, setPreviousPeriod] = useState([
|
const scope = JSON.stringify([accountId, appliedFrom, appliedTo]);
|
||||||
appliedFrom,
|
const [previousScope, setPreviousScope] = useState(scope);
|
||||||
appliedTo,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) {
|
if (previousScope !== scope) {
|
||||||
setPreviousPeriod([appliedFrom, appliedTo]);
|
setPreviousScope(scope);
|
||||||
setDraftPeriod([appliedFrom, appliedTo]);
|
setDraftPeriod([appliedFrom, appliedTo]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
|
import type { IncomeAnalytics, IncomeAnalyticsMeta } from "@/entities/income";
|
||||||
|
|
||||||
import { formatDate } from "@/shared/lib";
|
import { formatDate } from "@/shared/lib";
|
||||||
|
|
||||||
@@ -29,8 +29,6 @@ type IncomeDisplayModel = {
|
|||||||
yearNotice: ComparisonNotice;
|
yearNotice: ComparisonNotice;
|
||||||
};
|
};
|
||||||
|
|
||||||
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
|
|
||||||
|
|
||||||
function comparisonDisplay(
|
function comparisonDisplay(
|
||||||
period: IncomePeriod,
|
period: IncomePeriod,
|
||||||
comparison: IncomeAnalytics["yearComparison"],
|
comparison: IncomeAnalytics["yearComparison"],
|
||||||
@@ -63,7 +61,7 @@ function comparisonDisplay(
|
|||||||
|
|
||||||
function comparisonNotice(
|
function comparisonNotice(
|
||||||
dependency:
|
dependency:
|
||||||
NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number] | undefined,
|
IncomeAnalyticsMeta["dependencies"][number] | undefined,
|
||||||
unavailable: "previousUnavailable" | "yearUnavailable",
|
unavailable: "previousUnavailable" | "yearUnavailable",
|
||||||
): ComparisonNotice {
|
): ComparisonNotice {
|
||||||
if (!dependency || dependency.unavailableReason) return unavailable;
|
if (!dependency || dependency.unavailableReason) return unavailable;
|
||||||
@@ -98,7 +96,7 @@ function formatIncomeChange(
|
|||||||
|
|
||||||
function toIncomeDisplayModel(
|
function toIncomeDisplayModel(
|
||||||
analytics: IncomeAnalytics,
|
analytics: IncomeAnalytics,
|
||||||
meta?: IncomeMeta,
|
meta?: IncomeAnalyticsMeta,
|
||||||
): IncomeDisplayModel | null {
|
): IncomeDisplayModel | null {
|
||||||
const instruments = new Map<
|
const instruments = new Map<
|
||||||
string,
|
string,
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ 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";
|
||||||
|
|
||||||
type AccountIncomePageProps = { accountId?: string };
|
const AccountIncomePage: FC = () => {
|
||||||
|
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 />;
|
||||||
|
|
||||||
return viewModel.state === "error"
|
return viewModel.state === "error"
|
||||||
|
|||||||
@@ -44,11 +44,8 @@ type AccountIncomePageViewModel
|
|||||||
}
|
}
|
||||||
| { state: "loading" };
|
| { state: "loading" };
|
||||||
|
|
||||||
const useAccountIncomePageViewModel = (
|
const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
||||||
scopeId?: string,
|
const { accountId = "" } = useParams();
|
||||||
): AccountIncomePageViewModel => {
|
|
||||||
const { accountId: routeAccountId } = useParams();
|
|
||||||
const accountId = scopeId ?? routeAccountId ?? "";
|
|
||||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||||
|
|
||||||
const requested = useMemo(
|
const requested = useMemo(
|
||||||
@@ -83,7 +80,7 @@ const useAccountIncomePageViewModel = (
|
|||||||
}, [period, requested, setSearchParameters]);
|
}, [period, requested, setSearchParameters]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset(draftPeriod(period));
|
form.reset(draftPeriod(period));
|
||||||
}, [form, period]);
|
}, [accountId, form, period]);
|
||||||
|
|
||||||
if (analytics.isLoading || !analytics.data) {
|
if (analytics.isLoading || !analytics.data) {
|
||||||
return analytics.isError || analytics.meta?.sync.state === "failed"
|
return analytics.isError || analytics.meta?.sync.state === "failed"
|
||||||
@@ -117,7 +114,7 @@ const useAccountIncomePageViewModel = (
|
|||||||
analytics: analytics.data,
|
analytics: analytics.data,
|
||||||
display,
|
display,
|
||||||
form,
|
form,
|
||||||
formRevision: `${period.from}:${period.to}:${resetRevision}`,
|
formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`,
|
||||||
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
|
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
|
||||||
onReset,
|
onReset,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ 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";
|
||||||
|
|
||||||
type AccountOperationsPageProps = { accountId?: string };
|
const AccountOperationsPage: FC = () => {
|
||||||
|
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 />;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
brokerageOperationsQueryOptions,
|
brokerageOperationsQueryOptions,
|
||||||
operationCategories,
|
operationCategories,
|
||||||
type OperationCategory,
|
type OperationCategory,
|
||||||
useBrokerageOperations,
|
useBrokerageOperationHistory,
|
||||||
} from "@/entities/broker-operation";
|
} from "@/entities/broker-operation";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
@@ -41,11 +41,8 @@ type AccountOperationsPageViewModel
|
|||||||
| { onReset: () => void; retry: () => void; state: "error" }
|
| { onReset: () => void; retry: () => void; state: "error" }
|
||||||
| { state: "loading" };
|
| { state: "loading" };
|
||||||
|
|
||||||
const useAccountOperationsPageVm = (
|
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||||
scopeId?: string,
|
const { accountId } = useParams();
|
||||||
): AccountOperationsPageViewModel => {
|
|
||||||
const { accountId: routeAccountId } = useParams();
|
|
||||||
const accountId = scopeId ?? routeAccountId;
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||||
const urlFrom = searchParameters.get("from");
|
const urlFrom = searchParameters.get("from");
|
||||||
@@ -74,25 +71,6 @@ const useAccountOperationsPageVm = (
|
|||||||
const [draftCategories, setDraftCategories]
|
const [draftCategories, setDraftCategories]
|
||||||
= useState<OperationCategory[]>(appliedCategories);
|
= useState<OperationCategory[]>(appliedCategories);
|
||||||
|
|
||||||
const scopeKey = JSON.stringify([
|
|
||||||
accountId,
|
|
||||||
urlFrom,
|
|
||||||
urlTo,
|
|
||||||
appliedCategories,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [pagination, setPagination] = useState<{
|
|
||||||
cursors: string[];
|
|
||||||
scopeKey: string;
|
|
||||||
}>({ cursors: [], scopeKey });
|
|
||||||
|
|
||||||
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
|
|
||||||
const cursor = cursors.at(-1);
|
|
||||||
|
|
||||||
if (pagination.scopeKey !== scopeKey) {
|
|
||||||
setPagination({ cursors: [], scopeKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isValidUrlPeriod) return;
|
if (isValidUrlPeriod) return;
|
||||||
const normalized = new URLSearchParams(searchParameters);
|
const normalized = new URLSearchParams(searchParameters);
|
||||||
@@ -100,13 +78,6 @@ const useAccountOperationsPageVm = (
|
|||||||
normalized.delete("to");
|
normalized.delete("to");
|
||||||
setSearchParameters(normalized, { replace: true });
|
setSearchParameters(normalized, { replace: true });
|
||||||
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
|
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
|
||||||
const [previousSearch, setPreviousSearch] = useState(searchParameters);
|
|
||||||
|
|
||||||
if (previousSearch !== searchParameters) {
|
|
||||||
setPreviousSearch(searchParameters);
|
|
||||||
setDraftPeriod([appliedFrom, appliedTo]);
|
|
||||||
setDraftCategories(appliedCategories);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstPageQuery = {
|
const firstPageQuery = {
|
||||||
from: dateStart(appliedFrom),
|
from: dateStart(appliedFrom),
|
||||||
@@ -115,10 +86,17 @@ const useAccountOperationsPageVm = (
|
|||||||
limit: 100,
|
limit: 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
const operations = useBrokerageOperations(accountId, {
|
const history = useBrokerageOperationHistory(accountId, firstPageQuery);
|
||||||
...firstPageQuery,
|
const { operations } = history;
|
||||||
...(cursor && { cursor }),
|
const scope = JSON.stringify([accountId, searchParameters.toString()]);
|
||||||
});
|
const [previousScope, setPreviousScope] = useState(scope);
|
||||||
|
|
||||||
|
if (previousScope !== scope) {
|
||||||
|
setPreviousScope(scope);
|
||||||
|
setDraftPeriod([appliedFrom, appliedTo]);
|
||||||
|
setDraftCategories(appliedCategories);
|
||||||
|
history.reset();
|
||||||
|
}
|
||||||
|
|
||||||
const sync = useScreenSync(
|
const sync = useScreenSync(
|
||||||
{ accountId, screen: "operations", ...firstPageQuery },
|
{ accountId, screen: "operations", ...firstPageQuery },
|
||||||
@@ -131,30 +109,13 @@ const useAccountOperationsPageVm = (
|
|||||||
limit: 100,
|
limit: 100,
|
||||||
}).queryKey;
|
}).queryKey;
|
||||||
|
|
||||||
const resetCursors = () => setPagination({ cursors: [], scopeKey });
|
const retry = () => history.retry(sync.retry);
|
||||||
|
|
||||||
const retry = () => {
|
|
||||||
if (
|
|
||||||
isApiErrorCode(
|
|
||||||
apiErrorCode(operations.error),
|
|
||||||
ApiErrorCode.InvalidOperationsCursor,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
exact: true,
|
|
||||||
queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery)
|
|
||||||
.queryKey,
|
|
||||||
});
|
|
||||||
resetCursors();
|
|
||||||
}
|
|
||||||
else void sync.retry();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onReset = () => {
|
const onReset = () => {
|
||||||
setDraftPeriod([null, null]);
|
setDraftPeriod([null, null]);
|
||||||
setDraftCategories([]);
|
setDraftCategories([]);
|
||||||
setSearchParameters({});
|
setSearchParameters({});
|
||||||
resetCursors();
|
history.reset();
|
||||||
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
|
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -180,7 +141,7 @@ const useAccountOperationsPageVm = (
|
|||||||
draftCategories,
|
draftCategories,
|
||||||
draftPeriod,
|
draftPeriod,
|
||||||
hasNext: Boolean(nextCursor && operations.data.hasNext),
|
hasNext: Boolean(nextCursor && operations.data.hasNext),
|
||||||
hasPrevious: cursors.length > 0,
|
hasPrevious: history.hasPrevious,
|
||||||
historyLoading:
|
historyLoading:
|
||||||
operations.meta?.coverage?.historyState === "loading"
|
operations.meta?.coverage?.historyState === "loading"
|
||||||
|| operations.meta?.coverage?.historyState === "partial",
|
|| operations.meta?.coverage?.historyState === "partial",
|
||||||
@@ -195,20 +156,16 @@ const useAccountOperationsPageVm = (
|
|||||||
categories: draftCategories.join(","),
|
categories: draftCategories.join(","),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
resetCursors();
|
history.reset();
|
||||||
},
|
},
|
||||||
onCategoriesChange: values => setDraftCategories(
|
onCategoriesChange: values => setDraftCategories(
|
||||||
values.filter((value): value is OperationCategory =>
|
values.filter((value): value is OperationCategory =>
|
||||||
operationCategorySet.has(value),
|
operationCategorySet.has(value),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onNext: () => {
|
onNext: history.onNext,
|
||||||
if (nextCursor)
|
|
||||||
setPagination({ cursors: [...cursors, nextCursor], scopeKey });
|
|
||||||
},
|
|
||||||
onPeriodChange: setDraftPeriod,
|
onPeriodChange: setDraftPeriod,
|
||||||
onPrevious: () =>
|
onPrevious: history.onPrevious,
|
||||||
setPagination({ cursors: cursors.slice(0, -1), scopeKey }),
|
|
||||||
onReset,
|
onReset,
|
||||||
partial: operations.meta?.availability !== "complete",
|
partial: operations.meta?.availability !== "complete",
|
||||||
refreshError: operations.isError || sync.sourceError || sync.transportError,
|
refreshError: operations.isError || sync.sourceError || sync.transportError,
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { formatShare, type Portfolio } from "@/entities/portfolio";
|
||||||
|
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
||||||
|
|
||||||
|
type AccountPagePosition = Portfolio["positions"][number] & {
|
||||||
|
instrumentTo?: string;
|
||||||
|
logoSrc: null | string;
|
||||||
|
returnText: null | string;
|
||||||
|
returnTone?: AccountPageReturnTone;
|
||||||
|
shareText: null | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountPagePositionGroup = {
|
||||||
|
key: string;
|
||||||
|
positions: AccountPagePosition[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountPagePositionSection = {
|
||||||
|
groups: AccountPagePositionGroup[];
|
||||||
|
kind: PositionSectionKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountPageReturnTone = "negative" | "positive";
|
||||||
|
|
||||||
|
type PositionSectionKind = "bonds"
|
||||||
|
| "other"
|
||||||
|
| "stocks";
|
||||||
|
|
||||||
|
function groupPositions(
|
||||||
|
positions: AccountPagePosition[],
|
||||||
|
): AccountPagePositionGroup[] {
|
||||||
|
const groups = new Map<string, AccountPagePositionGroup>();
|
||||||
|
|
||||||
|
for (const [index, position] of positions.entries()) {
|
||||||
|
const identity = positionIdentity(position);
|
||||||
|
const rowKey = JSON.stringify(["row", position.account?.id, index]);
|
||||||
|
|
||||||
|
const key
|
||||||
|
= identity && position.account
|
||||||
|
? JSON.stringify([position.type, ...identity])
|
||||||
|
: rowKey;
|
||||||
|
|
||||||
|
const group = groups.get(key);
|
||||||
|
|
||||||
|
if (
|
||||||
|
group
|
||||||
|
&& group.positions.every(
|
||||||
|
item => item.account?.id !== position.account?.id,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
group.positions.push(position);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const uniqueKey = group ? rowKey : key;
|
||||||
|
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline.
|
||||||
|
return [...groups.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionIdentity(position: AccountPagePosition) {
|
||||||
|
if (position.instrumentUid?.trim())
|
||||||
|
return ["uid", position.instrumentUid.trim()];
|
||||||
|
if (position.figi?.trim()) return ["figi", position.figi.trim()];
|
||||||
|
|
||||||
|
return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAccountPagePositionSections(
|
||||||
|
portfolio: Portfolio,
|
||||||
|
instrumentDetails: Map<string, undefined
|
||||||
|
| { slug: string }> = new Map(),
|
||||||
|
): AccountPagePositionSection[] {
|
||||||
|
const denominators = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const position of portfolio.positions) {
|
||||||
|
if (!position.currentValue) continue;
|
||||||
|
const currency = position.currentValue.currency;
|
||||||
|
denominators.set(
|
||||||
|
currency,
|
||||||
|
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stocks = toSection(
|
||||||
|
"stocks",
|
||||||
|
portfolio,
|
||||||
|
denominators,
|
||||||
|
instrumentDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bonds = toSection("bonds", portfolio, denominators, instrumentDetails);
|
||||||
|
const other = toSection("other", portfolio, denominators, instrumentDetails);
|
||||||
|
|
||||||
|
return [stocks, bonds, other].filter(section => section.groups.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPosition(
|
||||||
|
position: Portfolio["positions"][number],
|
||||||
|
denominators: Map<string, number>,
|
||||||
|
kind: PositionSectionKind,
|
||||||
|
instrumentSlug?: string,
|
||||||
|
): AccountPagePosition {
|
||||||
|
const isExpanded = kind !== "other";
|
||||||
|
|
||||||
|
const returnValue = position.absoluteReturn
|
||||||
|
? Number(position.absoluteReturn.value)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let returnTone: AccountPageReturnTone | undefined;
|
||||||
|
if (returnValue !== null && returnValue !== 0)
|
||||||
|
returnTone = returnValue > 0 ? "positive" : "negative";
|
||||||
|
|
||||||
|
const returnPrefix
|
||||||
|
= isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
...position,
|
||||||
|
instrumentTo:
|
||||||
|
(kind === "stocks" || kind === "bonds") && position.instrumentUid
|
||||||
|
? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}`
|
||||||
|
: undefined,
|
||||||
|
logoSrc: isExpanded
|
||||||
|
? instrumentLogoUrl(position.logoName)
|
||||||
|
: null,
|
||||||
|
returnText: position.absoluteReturn
|
||||||
|
? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
|
||||||
|
: null,
|
||||||
|
returnTone,
|
||||||
|
shareText: position.currentValue?.currency
|
||||||
|
? formatShare(position.currentValue, {
|
||||||
|
currency: position.currentValue.currency,
|
||||||
|
value: String(denominators.get(position.currentValue.currency) ?? 0),
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSection(
|
||||||
|
kind: PositionSectionKind,
|
||||||
|
portfolio: Portfolio,
|
||||||
|
denominators: Map<string, number>,
|
||||||
|
instrumentDetails: Map<string, undefined
|
||||||
|
| { slug: string }>,
|
||||||
|
): AccountPagePositionSection {
|
||||||
|
return {
|
||||||
|
groups: groupPositions(
|
||||||
|
portfolio.positions
|
||||||
|
.filter((position) => {
|
||||||
|
if (kind === "stocks") return position.type === "STOCK";
|
||||||
|
|
||||||
|
return kind === "bonds"
|
||||||
|
? position.type === "BOND"
|
||||||
|
: position.type !== "STOCK"
|
||||||
|
&& position.type !== "BOND"
|
||||||
|
&& position.type !== "CURRENCY";
|
||||||
|
})
|
||||||
|
.map(position =>
|
||||||
|
toPosition(
|
||||||
|
position,
|
||||||
|
denominators,
|
||||||
|
kind,
|
||||||
|
instrumentDetails.get(position.instrumentUid ?? "")?.slug,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type AccountPagePosition,
|
||||||
|
type AccountPagePositionGroup,
|
||||||
|
type AccountPagePositionSection,
|
||||||
|
toAccountPagePositionSections,
|
||||||
|
};
|
||||||
@@ -24,8 +24,8 @@ import type {
|
|||||||
AccountPageRecentOperations,
|
AccountPageRecentOperations,
|
||||||
} from "./AccountPage.vm";
|
} from "./AccountPage.vm";
|
||||||
|
|
||||||
|
import { toAccountPagePositionSections } from "../../model/positions";
|
||||||
import { AccountPage } from "./AccountPage";
|
import { AccountPage } from "./AccountPage";
|
||||||
import { toAccountPagePositionSections } from "./AccountPage.vm";
|
|
||||||
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
|
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
|
||||||
import { AccountPageError } from "./components/AccountPageError.view";
|
import { AccountPageError } from "./components/AccountPageError.view";
|
||||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||||
@@ -39,13 +39,13 @@ function configureTestAuth() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAccountRoute(queryClient: QueryClient) {
|
function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
|
||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter initialEntries={["/accounts/account-1"]}>
|
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AccountPage />} path="/accounts/:accountId" />
|
<Route element={<AccountPage />} path="/accounts/:accountId" />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -202,7 +202,7 @@ describe("AccountPage states", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
renderPage(<AccountPage accountId={accountId} />);
|
renderAccountRoute(createTestQueryClient(), accountId);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const links = screen.getAllByRole("link", {
|
const links = screen.getAllByRole("link", {
|
||||||
name: position.displayName,
|
name: position.displayName,
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import { AccountPageError } from "./components/AccountPageError.view";
|
|||||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||||
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
|
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
|
||||||
|
|
||||||
type AccountPageProps = { accountId?: string };
|
const AccountPage: FC = () => {
|
||||||
|
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")
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ import { createQueryWrapper } from "@/test/query";
|
|||||||
import { resource } from "@/test/resource";
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
import {
|
import { toAccountPagePositionSections } from "../../model/positions";
|
||||||
toAccountPagePositionSections,
|
import { useAccountPageViewModel } from "./AccountPage.vm";
|
||||||
useAccountPageViewModel,
|
|
||||||
} from "./AccountPage.vm";
|
|
||||||
|
|
||||||
const portfolio: Portfolio
|
const portfolio: Portfolio
|
||||||
= {
|
= {
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import {
|
|||||||
useBrokerageEvents,
|
useBrokerageEvents,
|
||||||
} from "@/entities/brokerage-event";
|
} from "@/entities/brokerage-event";
|
||||||
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
||||||
import { formatShare, usePortfolio } from "@/entities/portfolio";
|
import { usePortfolio } from "@/entities/portfolio";
|
||||||
import { useStocks } from "@/entities/stock";
|
import { useStocks } from "@/entities/stock";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
|
||||||
|
import { type AccountPagePositionSection, toAccountPagePositionSections } from "../../model/positions";
|
||||||
|
|
||||||
const RECENT_EVENTS_LIMIT = 5;
|
const RECENT_EVENTS_LIMIT = 5;
|
||||||
|
|
||||||
@@ -58,24 +59,6 @@ type AccountPageLoadedViewModel = AccountPageContent & {
|
|||||||
state: "loaded";
|
state: "loaded";
|
||||||
};
|
};
|
||||||
|
|
||||||
type AccountPagePosition = Portfolio["positions"][number] & {
|
|
||||||
instrumentTo?: string;
|
|
||||||
logoSrc: null | string;
|
|
||||||
returnText: null | string;
|
|
||||||
returnTone?: AccountPageReturnTone;
|
|
||||||
shareText: null | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AccountPagePositionGroup = {
|
|
||||||
key: string;
|
|
||||||
positions: AccountPagePosition[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type AccountPagePositionSection = {
|
|
||||||
groups: AccountPagePositionGroup[];
|
|
||||||
kind: PositionSectionKind;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AccountPageRecentEvents = BrokerageEventsPreviewState;
|
type AccountPageRecentEvents = BrokerageEventsPreviewState;
|
||||||
|
|
||||||
type AccountPageRecentOperations
|
type AccountPageRecentOperations
|
||||||
@@ -94,89 +77,12 @@ type AccountPageRecentOperations
|
|||||||
to: string;
|
to: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AccountPageReturnTone = "negative" | "positive";
|
|
||||||
|
|
||||||
type AccountPageViewModel
|
type AccountPageViewModel
|
||||||
= | AccountPageEmptyViewModel
|
= | AccountPageEmptyViewModel
|
||||||
| AccountPageLoadedViewModel
|
| AccountPageLoadedViewModel
|
||||||
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
|
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
|
||||||
| { state: "loading" };
|
| { state: "loading" };
|
||||||
|
|
||||||
type PositionSectionKind = "bonds"
|
|
||||||
| "other"
|
|
||||||
| "stocks";
|
|
||||||
|
|
||||||
function groupPositions(
|
|
||||||
positions: AccountPagePosition[],
|
|
||||||
): AccountPagePositionGroup[] {
|
|
||||||
const groups = new Map<string, AccountPagePositionGroup>();
|
|
||||||
|
|
||||||
for (const [index, position] of positions.entries()) {
|
|
||||||
const identity = positionIdentity(position);
|
|
||||||
const rowKey = JSON.stringify(["row", position.account?.id, index]);
|
|
||||||
|
|
||||||
const key
|
|
||||||
= identity && position.account
|
|
||||||
? JSON.stringify([position.type, ...identity])
|
|
||||||
: rowKey;
|
|
||||||
|
|
||||||
const group = groups.get(key);
|
|
||||||
|
|
||||||
if (
|
|
||||||
group
|
|
||||||
&& group.positions.every(
|
|
||||||
item => item.account?.id !== position.account?.id,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
group.positions.push(position);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const uniqueKey = group ? rowKey : key;
|
|
||||||
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline.
|
|
||||||
return [...groups.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
function positionIdentity(position: AccountPagePosition) {
|
|
||||||
if (position.instrumentUid?.trim())
|
|
||||||
return ["uid", position.instrumentUid.trim()];
|
|
||||||
if (position.figi?.trim()) return ["figi", position.figi.trim()];
|
|
||||||
|
|
||||||
return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toAccountPagePositionSections(
|
|
||||||
portfolio: Portfolio,
|
|
||||||
instrumentDetails: Map<string, undefined
|
|
||||||
| { slug: string }> = new Map(),
|
|
||||||
): AccountPagePositionSection[] {
|
|
||||||
const denominators = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const position of portfolio.positions) {
|
|
||||||
if (!position.currentValue) continue;
|
|
||||||
const currency = position.currentValue.currency;
|
|
||||||
denominators.set(
|
|
||||||
currency,
|
|
||||||
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stocks = toSection(
|
|
||||||
"stocks",
|
|
||||||
portfolio,
|
|
||||||
denominators,
|
|
||||||
instrumentDetails,
|
|
||||||
);
|
|
||||||
|
|
||||||
const bonds = toSection("bonds", portfolio, denominators, instrumentDetails);
|
|
||||||
const other = toSection("other", portfolio, denominators, instrumentDetails);
|
|
||||||
|
|
||||||
return [stocks, bonds, other].filter(section => section.groups.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toIncomeSummary(
|
function toIncomeSummary(
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
income: ReturnType<typeof useIncomeSummary>,
|
income: ReturnType<typeof useIncomeSummary>,
|
||||||
@@ -199,47 +105,6 @@ function toIncomeSummary(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPosition(
|
|
||||||
position: Portfolio["positions"][number],
|
|
||||||
denominators: Map<string, number>,
|
|
||||||
kind: PositionSectionKind,
|
|
||||||
instrumentSlug?: string,
|
|
||||||
): AccountPagePosition {
|
|
||||||
const isExpanded = kind !== "other";
|
|
||||||
|
|
||||||
const returnValue = position.absoluteReturn
|
|
||||||
? Number(position.absoluteReturn.value)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
let returnTone: AccountPageReturnTone | undefined;
|
|
||||||
if (returnValue !== null && returnValue !== 0)
|
|
||||||
returnTone = returnValue > 0 ? "positive" : "negative";
|
|
||||||
|
|
||||||
const returnPrefix
|
|
||||||
= isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
...position,
|
|
||||||
instrumentTo:
|
|
||||||
(kind === "stocks" || kind === "bonds") && position.instrumentUid
|
|
||||||
? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}`
|
|
||||||
: undefined,
|
|
||||||
logoSrc: isExpanded
|
|
||||||
? instrumentLogoUrl(position.logoName)
|
|
||||||
: null,
|
|
||||||
returnText: position.absoluteReturn
|
|
||||||
? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
|
|
||||||
: null,
|
|
||||||
returnTone,
|
|
||||||
shareText: position.currentValue?.currency
|
|
||||||
? formatShare(position.currentValue, {
|
|
||||||
currency: position.currentValue.currency,
|
|
||||||
value: String(denominators.get(position.currentValue.currency) ?? 0),
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRecentEvents(
|
function toRecentEvents(
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
events: ReturnType<typeof useBrokerageEvents>,
|
events: ReturnType<typeof useBrokerageEvents>,
|
||||||
@@ -299,41 +164,8 @@ function toRecentOperations(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSection(
|
function useAccountPageViewModel(): AccountPageViewModel {
|
||||||
kind: PositionSectionKind,
|
const { accountId } = useParams();
|
||||||
portfolio: Portfolio,
|
|
||||||
denominators: Map<string, number>,
|
|
||||||
instrumentDetails: Map<string, undefined
|
|
||||||
| { slug: string }>,
|
|
||||||
): AccountPagePositionSection {
|
|
||||||
return {
|
|
||||||
groups: groupPositions(
|
|
||||||
portfolio.positions
|
|
||||||
.filter((position) => {
|
|
||||||
if (kind === "stocks") return position.type === "STOCK";
|
|
||||||
|
|
||||||
return kind === "bonds"
|
|
||||||
? position.type === "BOND"
|
|
||||||
: position.type !== "STOCK"
|
|
||||||
&& position.type !== "BOND"
|
|
||||||
&& position.type !== "CURRENCY";
|
|
||||||
})
|
|
||||||
.map(position =>
|
|
||||||
toPosition(
|
|
||||||
position,
|
|
||||||
denominators,
|
|
||||||
kind,
|
|
||||||
instrumentDetails.get(position.instrumentUid ?? "")?.slug,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
kind,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
|
||||||
const { accountId: routeAccountId } = useParams();
|
|
||||||
const accountId = scopeId ?? routeAccountId;
|
|
||||||
const portfolio = usePortfolio(accountId);
|
const portfolio = usePortfolio(accountId);
|
||||||
|
|
||||||
const stocks = useStocks(
|
const stocks = useStocks(
|
||||||
@@ -344,8 +176,6 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
|||||||
) ?? [],
|
) ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const instrumentDetails = stocks;
|
|
||||||
|
|
||||||
const operations = useBrokerageOperations(accountId, {
|
const operations = useBrokerageOperations(accountId, {
|
||||||
limit: 5,
|
limit: 5,
|
||||||
});
|
});
|
||||||
@@ -408,7 +238,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
|||||||
portfolio: portfolio.data,
|
portfolio: portfolio.data,
|
||||||
positionSections: toAccountPagePositionSections(
|
positionSections: toAccountPagePositionSections(
|
||||||
portfolio.data,
|
portfolio.data,
|
||||||
instrumentDetails,
|
stocks,
|
||||||
),
|
),
|
||||||
recentEvents,
|
recentEvents,
|
||||||
recentOperations,
|
recentOperations,
|
||||||
@@ -419,11 +249,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
type AccountPageIncomeSummary,
|
type AccountPageIncomeSummary,
|
||||||
type AccountPagePosition,
|
|
||||||
type AccountPagePositionGroup,
|
|
||||||
type AccountPagePositionSection,
|
|
||||||
type AccountPageRecentEvents,
|
type AccountPageRecentEvents,
|
||||||
type AccountPageRecentOperations,
|
type AccountPageRecentOperations,
|
||||||
toAccountPagePositionSections,
|
|
||||||
useAccountPageViewModel,
|
useAccountPageViewModel,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,12 +9,8 @@ import type { Portfolio } from "@/entities/portfolio";
|
|||||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
|
|
||||||
import type {
|
import type { AccountPagePositionSection } from "../../../model/positions";
|
||||||
AccountPageIncomeSummary,
|
import type { AccountPageIncomeSummary, AccountPageRecentEvents, AccountPageRecentOperations } from "../AccountPage.vm";
|
||||||
AccountPagePositionSection,
|
|
||||||
AccountPageRecentEvents,
|
|
||||||
AccountPageRecentOperations,
|
|
||||||
} from "../AccountPage.vm";
|
|
||||||
|
|
||||||
import classes from "../AccountPage.module.css";
|
import classes from "../AccountPage.module.css";
|
||||||
import { AccountHeading } from "./AccountHeading.view";
|
import { AccountHeading } from "./AccountHeading.view";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
|
||||||
import type { AccountPagePosition } from "../../AccountPage.vm";
|
import type { AccountPagePosition } from "../../../../model/positions";
|
||||||
|
|
||||||
import { PositionCard } from "./PositionCard.view";
|
import { PositionCard } from "./PositionCard.view";
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ import { Link } from "react-router";
|
|||||||
|
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
|
|
||||||
import type {
|
import type { AccountPagePosition, AccountPagePositionSection } from "../../../../model/positions";
|
||||||
AccountPagePosition,
|
|
||||||
AccountPagePositionSection,
|
|
||||||
} from "../../AccountPage.vm";
|
|
||||||
|
|
||||||
import classes from "../../AccountPage.module.css";
|
import classes from "../../AccountPage.module.css";
|
||||||
import { PositionLogo } from "../PositionLogo.view";
|
import { PositionLogo } from "../PositionLogo.view";
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ import { Link } from "react-router";
|
|||||||
|
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
|
|
||||||
import type {
|
import type { AccountPagePositionGroup, AccountPagePositionSection } from "../../../model/positions";
|
||||||
AccountPagePositionGroup,
|
|
||||||
AccountPagePositionSection,
|
|
||||||
} from "../AccountPage.vm";
|
|
||||||
|
|
||||||
import classes from "../AccountPage.module.css";
|
import classes from "../AccountPage.module.css";
|
||||||
import { PositionLogo } from "./PositionLogo.view";
|
import { PositionLogo } from "./PositionLogo.view";
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { Anchor, Avatar } from "@mantine/core";
|
import { Anchor, Avatar } from "@mantine/core";
|
||||||
|
import { useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { useImageFallback } from "@/shared/lib";
|
|
||||||
|
|
||||||
type PositionLogoProps = {
|
type PositionLogoProps = {
|
||||||
label: string;
|
label: string;
|
||||||
src: null | string;
|
src: null | string;
|
||||||
@@ -13,7 +12,9 @@ type PositionLogoProps = {
|
|||||||
|
|
||||||
const PositionLogo: FC<PositionLogoProps> = (props) => {
|
const PositionLogo: FC<PositionLogoProps> = (props) => {
|
||||||
const { label, src } = props;
|
const { label, src } = props;
|
||||||
const { failed, onError } = useImageFallback();
|
const [failed, setFailed] = useState(false);
|
||||||
|
// eslint-disable-next-line frontend/readability -- Rendering fallback belongs to this keyed logo, without page data or actions.
|
||||||
|
const onError = () => setFailed(true);
|
||||||
|
|
||||||
const logo
|
const logo
|
||||||
= src && !failed
|
= src && !failed
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { FC } from "react";
|
|||||||
|
|
||||||
import { SimpleGrid } from "@mantine/core";
|
import { SimpleGrid } from "@mantine/core";
|
||||||
|
|
||||||
import type { AccountPagePositionSection } from "../AccountPage.vm";
|
import type { AccountPagePositionSection } from "../../../model/positions";
|
||||||
|
|
||||||
import { PositionCard } from "./PositionCard/PositionCard.view";
|
import { PositionCard } from "./PositionCard/PositionCard.view";
|
||||||
import { PositionGroupCard } from "./PositionGroupCard.view";
|
import { PositionGroupCard } from "./PositionGroupCard.view";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
|
||||||
import { toAccountPagePositionSections } from "./AccountPage.vm";
|
import { toAccountPagePositionSections } from "../../model/positions";
|
||||||
import { PositionSection } from "./components/PositionSection.view";
|
import { PositionSection } from "./components/PositionSection.view";
|
||||||
|
|
||||||
const position: Portfolio["positions"][number]
|
const position: Portfolio["positions"][number]
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import { cleanup, render, screen } from "@testing-library/react";
|
|||||||
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||||||
import { afterEach, expect, it } from "vitest";
|
import { afterEach, expect, it } from "vitest";
|
||||||
|
|
||||||
import type {
|
import type { AccountPagePosition, AccountPagePositionSection } from "../../model/positions";
|
||||||
AccountPagePosition,
|
|
||||||
AccountPagePositionSection,
|
|
||||||
} from "./AccountPage.vm";
|
|
||||||
|
|
||||||
import { PositionSection } from "./components/PositionSection.view";
|
import { PositionSection } from "./components/PositionSection.view";
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Accordion,
|
|
||||||
Alert,
|
Alert,
|
||||||
Anchor,
|
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -21,7 +19,6 @@ import {
|
|||||||
InstrumentChart,
|
InstrumentChart,
|
||||||
InstrumentEvents,
|
InstrumentEvents,
|
||||||
InstrumentPosition,
|
InstrumentPosition,
|
||||||
InstrumentSection,
|
|
||||||
} from "@/features/instrument-details";
|
} from "@/features/instrument-details";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
@@ -33,6 +30,9 @@ import {
|
|||||||
import type { BondPageVm } from "./BondPage.vm";
|
import type { BondPageVm } from "./BondPage.vm";
|
||||||
|
|
||||||
import classes from "./BondPage.module.css";
|
import classes from "./BondPage.module.css";
|
||||||
|
import { BondCoupons } from "./components/BondCoupons.view";
|
||||||
|
import { BondMetrics } from "./components/BondMetrics.view";
|
||||||
|
import { Metric } from "./components/Metric.view";
|
||||||
|
|
||||||
type BondPageViewProps = { vm: BondPageVm };
|
type BondPageViewProps = { vm: BondPageVm };
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<BondMetrics vm={vm} />
|
<BondMetrics metrics={vm.metrics} state={vm.metricsState} />
|
||||||
|
|
||||||
<InstrumentChart priceUnit="percent" vm={vm} />
|
<InstrumentChart priceUnit="percent" vm={vm} />
|
||||||
|
|
||||||
@@ -236,7 +236,12 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
|||||||
vm={vm}
|
vm={vm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BondCoupons vm={vm} />
|
<BondCoupons
|
||||||
|
coupons={vm.coupons}
|
||||||
|
pastCoupons={vm.pastCoupons}
|
||||||
|
state={vm.couponsState}
|
||||||
|
upcoming={vm.upcoming}
|
||||||
|
/>
|
||||||
|
|
||||||
<InstrumentEvents
|
<InstrumentEvents
|
||||||
historyNote={t("bond.historyNote")}
|
historyNote={t("bond.historyNote")}
|
||||||
@@ -248,283 +253,4 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const BondMetrics: FC<BondPageViewProps> = (props) => {
|
|
||||||
const { vm } = props;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const creditRating = vm.metrics?.creditRating;
|
|
||||||
|
|
||||||
let rating = t(
|
|
||||||
vm.metrics?.unavailable.includes("rating")
|
|
||||||
? "bond.ratingUnavailable"
|
|
||||||
: "bond.ratingMissing",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (creditRating) {
|
|
||||||
rating
|
|
||||||
= creditRating.status === "withdrawn"
|
|
||||||
? t("bond.ratingWithdrawn")
|
|
||||||
: (creditRating.value ?? t("bond.unknown"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const couponDate = vm.metrics?.couponDate
|
|
||||||
? formatDate(vm.metrics?.couponDate)
|
|
||||||
: t("bond.unknown");
|
|
||||||
|
|
||||||
const effectiveYield = vm.metrics?.effectiveYield
|
|
||||||
? `${formatDecimal(vm.metrics?.effectiveYield.value)} %`
|
|
||||||
: t("bond.unknown");
|
|
||||||
|
|
||||||
const couponYield
|
|
||||||
= vm.metrics?.couponYieldPercent == undefined
|
|
||||||
? t("bond.unknown")
|
|
||||||
: `${formatDecimal(vm.metrics?.couponYieldPercent)} %`;
|
|
||||||
|
|
||||||
let yieldContent;
|
|
||||||
|
|
||||||
if (vm.metrics?.effectiveYield) {
|
|
||||||
yieldContent = (
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("bond.yieldSource", {
|
|
||||||
at: formatSnapshotTime(vm.metrics?.effectiveYield.tradeAt),
|
|
||||||
date: formatDate(vm.metrics?.effectiveYield.date),
|
|
||||||
price: formatDecimal(
|
|
||||||
vm.metrics?.effectiveYield.pricePercent,
|
|
||||||
),
|
|
||||||
target: t(
|
|
||||||
`bond.yieldTargets.${vm.metrics?.effectiveYield.dateType}`,
|
|
||||||
{ defaultValue: t("bond.yieldTargets.unknown") },
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
yieldContent = (
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("bond.yieldUnavailable")}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InstrumentSection state={vm.metricsState} title={t("bond.metrics")}>
|
|
||||||
{!!vm.metrics && (
|
|
||||||
<Stack gap="md">
|
|
||||||
<div className={classes.companyMetrics}>
|
|
||||||
<Metric label={t("bond.creditRating")} value={rating} />
|
|
||||||
|
|
||||||
<Metric
|
|
||||||
label={t("bond.couponAmount")}
|
|
||||||
value={formatMoney(vm.metrics.coupon)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Metric
|
|
||||||
label={t("bond.couponType")}
|
|
||||||
value={t(`bond.couponTypes.${vm.metrics.couponType}`, {
|
|
||||||
defaultValue: t("bond.unknown"),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Metric label={t("bond.couponDate")} value={couponDate} />
|
|
||||||
|
|
||||||
<Metric label={t("bond.effectiveYield")} value={effectiveYield} />
|
|
||||||
|
|
||||||
<Metric label={t("bond.couponYield")} value={couponYield} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!!vm.metrics.creditRating && (
|
|
||||||
<Group gap="xs">
|
|
||||||
<Anchor
|
|
||||||
href={vm.metrics.creditRating.sourceUrl}
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
size="sm"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
{t("bond.ratingSource", {
|
|
||||||
date: formatDate(vm.metrics.creditRating.date),
|
|
||||||
})}
|
|
||||||
</Anchor>
|
|
||||||
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("bond.ratingChecked", {
|
|
||||||
date: formatSnapshotTime(vm.metrics.creditRating.observedAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("bond.ratingCoverage")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{yieldContent}
|
|
||||||
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("bond.couponYieldNote")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{!!vm.metrics?.couponYieldPriceAt && (
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("bond.couponYieldPriceAt", {
|
|
||||||
date: formatSnapshotTime(vm.metrics?.couponYieldPriceAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{vm.metrics.unavailable.length > 0 && (
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("bond.metricsPartial")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</InstrumentSection>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const BondCoupons: FC<BondPageViewProps> = (props) => {
|
|
||||||
const { vm } = props;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InstrumentSection
|
|
||||||
state={vm.couponsState}
|
|
||||||
title={t("bond.upcomingCoupons")}
|
|
||||||
>
|
|
||||||
{!!vm.coupons && (
|
|
||||||
<Stack gap="md">
|
|
||||||
{!!(vm.coupons.estimateUnavailable || vm.coupons.positionIsStale) && (
|
|
||||||
<Alert aria-live="polite" color="orange" role="status">
|
|
||||||
{t("stock.estimateUnavailable")}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{vm.upcoming.length > 0
|
|
||||||
? (
|
|
||||||
vm.upcoming.map(coupon => (
|
|
||||||
<Coupon
|
|
||||||
coupon={coupon}
|
|
||||||
key={`${coupon.number}:${coupon.paymentDate}`}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
: <Text c="dimmed">{t("bond.noCoupons")}</Text>}
|
|
||||||
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("bond.couponWindow", {
|
|
||||||
from: formatDate(vm.coupons.from),
|
|
||||||
to: formatDate(vm.coupons.to),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("bond.couponNote")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{!!vm.coupons.positionAt && (
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("stock.snapshotAt", {
|
|
||||||
date: formatSnapshotTime(vm.coupons.positionAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Accordion radius="md" variant="contained">
|
|
||||||
<Accordion.Item value="history">
|
|
||||||
<Accordion.Control>{t("bond.couponHistory")}</Accordion.Control>
|
|
||||||
|
|
||||||
<Accordion.Panel>
|
|
||||||
<Stack gap="sm">
|
|
||||||
{vm.pastCoupons.length > 0
|
|
||||||
? (
|
|
||||||
vm.pastCoupons.map(coupon => (
|
|
||||||
<Coupon
|
|
||||||
coupon={coupon}
|
|
||||||
key={`${coupon.number}:${coupon.paymentDate}`}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
: <Text c="dimmed">{t("bond.noCoupons")}</Text>}
|
|
||||||
</Stack>
|
|
||||||
</Accordion.Panel>
|
|
||||||
</Accordion.Item>
|
|
||||||
</Accordion>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</InstrumentSection>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type MetricProps = { label: string; value: string };
|
|
||||||
|
|
||||||
const Metric: FC<MetricProps> = props => (
|
|
||||||
<div>
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{props.label}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text fw={600} size="lg">
|
|
||||||
{props.value}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
type CouponProps = {
|
|
||||||
coupon: NonNullable<BondPageVm["coupons"]>["coupons"][number];
|
|
||||||
};
|
|
||||||
|
|
||||||
const Coupon: FC<CouponProps> = (props) => {
|
|
||||||
const { coupon } = props;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper p="md" radius="md" withBorder>
|
|
||||||
<Group align="start" justify="space-between">
|
|
||||||
<div>
|
|
||||||
<Text fw={600}>
|
|
||||||
{coupon.paymentDate
|
|
||||||
? formatDate(coupon.paymentDate)
|
|
||||||
: t("stock.dateUnknown")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("bond.couponNumber", {
|
|
||||||
number: coupon.number ?? t("bond.unknown"),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
|
|
||||||
|
|
||||||
{!!coupon.estimatedAmount && (
|
|
||||||
<Metric
|
|
||||||
label={t("stock.yourEstimate")}
|
|
||||||
value={formatMoney(coupon.estimatedAmount)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap="md" mt="sm">
|
|
||||||
<Badge color="gray" variant="light">
|
|
||||||
{t(`bond.couponTypes.${coupon.couponType}`, {
|
|
||||||
defaultValue: t("bond.unknown"),
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{coupon.periodDays != undefined && (
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("bond.couponDays", { count: coupon.periodDays })}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!!coupon.recordDate && (
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("stock.record")}: {formatDate(coupon.recordDate)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { BondPageView };
|
export { BondPageView };
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useParams, useSearchParams } from "react-router";
|
import { useParams, useSearchParams } from "react-router";
|
||||||
|
|
||||||
import type { OperationCategory } from "@/entities/broker-operation";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type BondPeriod,
|
type BondPeriod,
|
||||||
useBond,
|
useBond,
|
||||||
@@ -11,10 +9,8 @@ import {
|
|||||||
useBondPosition,
|
useBondPosition,
|
||||||
useBondQuote,
|
useBondQuote,
|
||||||
} from "@/entities/bond";
|
} from "@/entities/bond";
|
||||||
import {
|
import { type OperationCategory, useBrokerageOperationHistory } from "@/entities/broker-operation";
|
||||||
section,
|
import { section } from "@/features/instrument-details";
|
||||||
useInstrumentOperations,
|
|
||||||
} from "@/features/instrument-details";
|
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { instrumentLogoUrl } from "@/shared/lib";
|
import { instrumentLogoUrl } from "@/shared/lib";
|
||||||
@@ -51,12 +47,12 @@ function useBondPageVm() {
|
|||||||
const position = useBondPosition(uid);
|
const position = useBondPosition(uid);
|
||||||
const coupons = useBondCoupons(uid);
|
const coupons = useBondCoupons(uid);
|
||||||
|
|
||||||
const history = useInstrumentOperations(
|
const history = useBrokerageOperationHistory(uid ? "all" : undefined, {
|
||||||
uid,
|
|
||||||
filter,
|
|
||||||
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
|
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
|
||||||
categories[filter],
|
categories: categories[filter],
|
||||||
);
|
instrumentUid: uid,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
const { operations } = history;
|
const { operations } = history;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import { Accordion, Alert, Badge, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { BondCoupons as Coupons } from "@/entities/bond";
|
||||||
|
|
||||||
|
import { InstrumentSection, type SectionState } from "@/features/instrument-details";
|
||||||
|
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||||
|
|
||||||
|
import { Metric } from "./Metric.view";
|
||||||
|
|
||||||
|
type BondCouponsProps = {
|
||||||
|
coupons?: Coupons;
|
||||||
|
pastCoupons: Coupons["coupons"];
|
||||||
|
state: SectionState;
|
||||||
|
upcoming: Coupons["coupons"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const BondCoupons: FC<BondCouponsProps> = (props) => {
|
||||||
|
const { coupons, pastCoupons, state, upcoming } = props;
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InstrumentSection
|
||||||
|
state={state}
|
||||||
|
title={t("bond.upcomingCoupons")}
|
||||||
|
>
|
||||||
|
{!!coupons && (
|
||||||
|
<Stack gap="md">
|
||||||
|
{!!(coupons.estimateUnavailable || coupons.positionIsStale) && (
|
||||||
|
<Alert aria-live="polite" color="orange" role="status">
|
||||||
|
{t("stock.estimateUnavailable")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{upcoming.length > 0
|
||||||
|
? (
|
||||||
|
upcoming.map(coupon => (
|
||||||
|
<Coupon
|
||||||
|
coupon={coupon}
|
||||||
|
key={`${coupon.number}:${coupon.paymentDate}`}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
: <Text c="dimmed">{t("bond.noCoupons")}</Text>}
|
||||||
|
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("bond.couponWindow", {
|
||||||
|
from: formatDate(coupons.from),
|
||||||
|
to: formatDate(coupons.to),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("bond.couponNote")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{!!coupons.positionAt && (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("stock.snapshotAt", {
|
||||||
|
date: formatSnapshotTime(coupons.positionAt),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Accordion radius="md" variant="contained">
|
||||||
|
<Accordion.Item value="history">
|
||||||
|
<Accordion.Control>{t("bond.couponHistory")}</Accordion.Control>
|
||||||
|
|
||||||
|
<Accordion.Panel>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{pastCoupons.length > 0
|
||||||
|
? (
|
||||||
|
pastCoupons.map(coupon => (
|
||||||
|
<Coupon
|
||||||
|
coupon={coupon}
|
||||||
|
key={`${coupon.number}:${coupon.paymentDate}`}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
: <Text c="dimmed">{t("bond.noCoupons")}</Text>}
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</InstrumentSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type CouponProps = {
|
||||||
|
coupon: Coupons["coupons"][number];
|
||||||
|
};
|
||||||
|
|
||||||
|
const Coupon: FC<CouponProps> = (props) => {
|
||||||
|
const { coupon } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper p="md" radius="md" withBorder>
|
||||||
|
<Group align="start" justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Text fw={600}>
|
||||||
|
{coupon.paymentDate
|
||||||
|
? formatDate(coupon.paymentDate)
|
||||||
|
: t("stock.dateUnknown")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("bond.couponNumber", {
|
||||||
|
number: coupon.number ?? t("bond.unknown"),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
|
||||||
|
|
||||||
|
{!!coupon.estimatedAmount && (
|
||||||
|
<Metric
|
||||||
|
label={t("stock.yourEstimate")}
|
||||||
|
value={formatMoney(coupon.estimatedAmount)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="md" mt="sm">
|
||||||
|
<Badge color="gray" variant="light">
|
||||||
|
{t(`bond.couponTypes.${coupon.couponType}`, {
|
||||||
|
defaultValue: t("bond.unknown"),
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{coupon.periodDays != undefined && (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("bond.couponDays", { count: coupon.periodDays })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!coupon.recordDate && (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("stock.record")}: {formatDate(coupon.recordDate)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { BondCoupons };
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import { Anchor, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { BondMetrics as Metrics } from "@/entities/bond";
|
||||||
|
|
||||||
|
import { InstrumentSection, type SectionState } from "@/features/instrument-details";
|
||||||
|
import { formatDate, formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||||
|
|
||||||
|
import classes from "../BondPage.module.css";
|
||||||
|
import { Metric } from "./Metric.view";
|
||||||
|
|
||||||
|
type BondMetricsProps = { metrics?: Metrics; state: SectionState };
|
||||||
|
|
||||||
|
const BondMetrics: FC<BondMetricsProps> = (props) => {
|
||||||
|
const { metrics, state } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const creditRating = metrics?.creditRating;
|
||||||
|
|
||||||
|
let rating = t(
|
||||||
|
metrics?.unavailable.includes("rating")
|
||||||
|
? "bond.ratingUnavailable"
|
||||||
|
: "bond.ratingMissing",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (creditRating) {
|
||||||
|
rating
|
||||||
|
= creditRating.status === "withdrawn"
|
||||||
|
? t("bond.ratingWithdrawn")
|
||||||
|
: (creditRating.value ?? t("bond.unknown"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const couponDate = metrics?.couponDate
|
||||||
|
? formatDate(metrics?.couponDate)
|
||||||
|
: t("bond.unknown");
|
||||||
|
|
||||||
|
const effectiveYield = metrics?.effectiveYield
|
||||||
|
? `${formatDecimal(metrics?.effectiveYield.value)} %`
|
||||||
|
: t("bond.unknown");
|
||||||
|
|
||||||
|
const couponYield
|
||||||
|
= metrics?.couponYieldPercent == undefined
|
||||||
|
? t("bond.unknown")
|
||||||
|
: `${formatDecimal(metrics?.couponYieldPercent)} %`;
|
||||||
|
|
||||||
|
let yieldContent;
|
||||||
|
|
||||||
|
if (metrics?.effectiveYield) {
|
||||||
|
yieldContent = (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("bond.yieldSource", {
|
||||||
|
at: formatSnapshotTime(metrics?.effectiveYield.tradeAt),
|
||||||
|
date: formatDate(metrics?.effectiveYield.date),
|
||||||
|
price: formatDecimal(
|
||||||
|
metrics?.effectiveYield.pricePercent,
|
||||||
|
),
|
||||||
|
target: t(
|
||||||
|
`bond.yieldTargets.${metrics?.effectiveYield.dateType}`,
|
||||||
|
{ defaultValue: t("bond.yieldTargets.unknown") },
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
yieldContent = (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("bond.yieldUnavailable")}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InstrumentSection state={state} title={t("bond.metrics")}>
|
||||||
|
{!!metrics && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<div className={classes.companyMetrics}>
|
||||||
|
<Metric label={t("bond.creditRating")} value={rating} />
|
||||||
|
|
||||||
|
<Metric
|
||||||
|
label={t("bond.couponAmount")}
|
||||||
|
value={formatMoney(metrics.coupon)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Metric
|
||||||
|
label={t("bond.couponType")}
|
||||||
|
value={t(`bond.couponTypes.${metrics.couponType}`, {
|
||||||
|
defaultValue: t("bond.unknown"),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Metric label={t("bond.couponDate")} value={couponDate} />
|
||||||
|
|
||||||
|
<Metric label={t("bond.effectiveYield")} value={effectiveYield} />
|
||||||
|
|
||||||
|
<Metric label={t("bond.couponYield")} value={couponYield} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!!metrics.creditRating && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Anchor
|
||||||
|
href={metrics.creditRating.sourceUrl}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="sm"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{t("bond.ratingSource", {
|
||||||
|
date: formatDate(metrics.creditRating.date),
|
||||||
|
})}
|
||||||
|
</Anchor>
|
||||||
|
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("bond.ratingChecked", {
|
||||||
|
date: formatSnapshotTime(metrics.creditRating.observedAt),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("bond.ratingCoverage")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{yieldContent}
|
||||||
|
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("bond.couponYieldNote")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{!!metrics?.couponYieldPriceAt && (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("bond.couponYieldPriceAt", {
|
||||||
|
date: formatSnapshotTime(metrics?.couponYieldPriceAt),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metrics.unavailable.length > 0 && (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("bond.metricsPartial")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</InstrumentSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { BondMetrics };
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
|
||||||
|
type MetricProps = { label: string; value: string };
|
||||||
|
|
||||||
|
const Metric: FC<MetricProps> = props => (
|
||||||
|
<div>
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{props.label}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fw={600} size="lg">
|
||||||
|
{props.value}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { Metric };
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useParams, useSearchParams } from "react-router";
|
import { useParams, useSearchParams } from "react-router";
|
||||||
|
|
||||||
import type { OperationCategory } from "@/entities/broker-operation";
|
import { type OperationCategory, useBrokerageOperationHistory } from "@/entities/broker-operation";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type StockPeriod,
|
type StockPeriod,
|
||||||
useStock,
|
useStock,
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
} from "@/entities/stock";
|
} from "@/entities/stock";
|
||||||
import {
|
import {
|
||||||
section,
|
section,
|
||||||
useInstrumentOperations,
|
|
||||||
} from "@/features/instrument-details";
|
} from "@/features/instrument-details";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
@@ -51,12 +49,12 @@ function useStockPageVm() {
|
|||||||
const company = useStockCompany(uid);
|
const company = useStockCompany(uid);
|
||||||
const dividends = useStockDividends(uid);
|
const dividends = useStockDividends(uid);
|
||||||
|
|
||||||
const history = useInstrumentOperations(
|
const history = useBrokerageOperationHistory(uid ? "all" : undefined, {
|
||||||
uid,
|
|
||||||
filter,
|
|
||||||
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
|
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
|
||||||
categories[filter],
|
categories: categories[filter],
|
||||||
);
|
instrumentUid: uid,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
const { operations } = history;
|
const { operations } = history;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
invalidateResourceAccess,
|
invalidateResourceAccess,
|
||||||
|
type InvestmentResourceMeta,
|
||||||
unwrapResourceResult,
|
unwrapResourceResult,
|
||||||
useResourceQuery,
|
useResourceQuery,
|
||||||
} from "./resource-query";
|
} from "./resource-query";
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
|||||||
|
|
||||||
const accessEpochs = new WeakMap<QueryClient, Map<string, number>>();
|
const accessEpochs = new WeakMap<QueryClient, Map<string, number>>();
|
||||||
|
|
||||||
|
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
|
||||||
|
|
||||||
type Resource = {
|
type Resource = {
|
||||||
data: unknown;
|
data: unknown;
|
||||||
meta: components["schemas"]["InvestmentResourceMetaDto"];
|
meta: InvestmentResourceMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
function invalidateResourceAccess(
|
function invalidateResourceAccess(
|
||||||
@@ -183,4 +185,9 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery };
|
export {
|
||||||
|
invalidateResourceAccess,
|
||||||
|
type InvestmentResourceMeta,
|
||||||
|
unwrapResourceResult,
|
||||||
|
useResourceQuery,
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,4 +8,3 @@ export {
|
|||||||
formatSnapshotTime,
|
formatSnapshotTime,
|
||||||
instrumentLogoUrl,
|
instrumentLogoUrl,
|
||||||
} from "./display";
|
} from "./display";
|
||||||
export { useImageFallback } from "./useImageFallback";
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
function useImageFallback() {
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const onError = () => setFailed(true);
|
|
||||||
|
|
||||||
return { failed, onError };
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useImageFallback };
|
|
||||||
Reference in New Issue
Block a user