Files
frontend/src/pages/account/ui/AccountPage.vm.ts
T
ksv741 daf1dca666 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.
2026-09-09 20:36:49 +03:00

282 lines
8.3 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router";
import type { BrokerageOperation } from "@/entities/broker-operation";
import { useBrokerageOperations } from "@/entities/broker-operation";
import {
type BrokerageAccount,
brokerageAccountsQueryKey,
} from "@/entities/brokerage-account";
import {
type BrokerageEventsPreviewState,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio";
import { formatShare, usePortfolio } from "@/entities/portfolio";
import { apiErrorCode } from "@/shared/api";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
type PositionSectionKind = "stocks" | "bonds" | "other";
type AccountPageReturnTone = "positive" | "negative";
type AccountPagePosition = Portfolio["positions"][number] & {
shareText: string | null;
returnText: string | null;
returnTone?: AccountPageReturnTone;
logoSrc: string | null;
};
type AccountPagePositionGroup = {
key: string;
positions: AccountPagePosition[];
};
type AccountPagePositionSection = {
kind: PositionSectionKind;
groups: AccountPagePositionGroup[];
};
type AccountPageRecentOperations =
| {
state: "loading" | "error" | "empty";
to: string;
}
| {
state: "loaded";
to: string;
items: BrokerageOperation[];
};
type AccountPageRecentEvents = BrokerageEventsPreviewState;
type AccountPageIncomeSummary =
| { state: "loading" | "empty"; to: string }
| { state: "error"; to: string; retry: () => void }
| { state: "loaded"; to: string; summary: IncomeSummary };
type AccountPageContent = {
account?: BrokerageAccount;
accountId?: string;
refreshing: boolean;
refreshError: boolean;
retry: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary: AccountPageIncomeSummary;
};
type AccountPageLoadedViewModel = AccountPageContent & {
state: "loaded";
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
};
type AccountPageEmptyViewModel = AccountPageContent & {
state: "empty";
};
type AccountPageViewModel =
| { state: "loading" }
| { state: "error"; code?: string; retry: () => void }
| AccountPageEmptyViewModel
| AccountPageLoadedViewModel;
function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const portfolio = usePortfolio(accountId);
const operations = useBrokerageOperations(accountId, {
limit: 5,
});
const events = useBrokerageEvents(accountId);
const income = useIncomeSummary(accountId);
const account = useQueryClient()
.getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey)
?.find((item) => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations);
const recentEvents = toRecentEvents(accountId, events);
const incomeSummary = toIncomeSummary(accountId, income);
if (!portfolio.data) {
if (portfolio.isError)
return {
state: "error" as const,
code: apiErrorCode(portfolio.error),
retry: () => portfolio.refetch(),
};
return { state: "loading" as const };
}
const refresh = {
refreshing: portfolio.isFetching,
refreshError: portfolio.isError,
retry: () => portfolio.refetch(),
};
if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
return {
state: "empty" as const,
account,
accountId,
recentOperations,
recentEvents,
incomeSummary,
...refresh,
};
return {
state: "loaded" as const,
account,
accountId,
portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(portfolio.data),
recentOperations,
recentEvents,
incomeSummary,
...refresh,
};
}
function toIncomeSummary(
accountId: string | undefined,
income: ReturnType<typeof useIncomeSummary>,
): AccountPageIncomeSummary {
const to = `/accounts/${accountId}/income`;
if (income.isLoading) return { state: "loading", to };
if (income.isError)
return { state: "error", to, retry: () => income.refetch() };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
return { state: "loaded", to, summary: income.data };
}
function toRecentOperations(
accountId: string | undefined,
operations: ReturnType<typeof useBrokerageOperations>,
): AccountPageRecentOperations {
const to = `/accounts/${accountId}/operations`;
if (operations.isLoading) return { state: "loading", to };
if (operations.isError) return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to };
return { state: "loaded", to, items: operations.data.items };
}
function toRecentEvents(
accountId: string | undefined,
events: ReturnType<typeof useBrokerageEvents>,
): AccountPageRecentEvents {
const to = `/accounts/${accountId}/events`;
if (events.isLoading) return { state: "loading", to };
if (events.isError) return { state: "error", to };
const items = events.data?.events.slice(0, 5) ?? [];
if (!items.length) return { state: "empty", to };
return { state: "loaded", to, items };
}
function toAccountPagePositionSections(
portfolio: Portfolio,
): 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);
const bonds = toSection("bonds", portfolio, denominators);
const other = toSection("other", portfolio, denominators);
return [stocks, bonds, other].filter((section) => section.groups.length > 0);
}
function toSection(
kind: PositionSectionKind,
portfolio: Portfolio,
denominators: Map<string, number>,
): AccountPagePositionSection {
return {
kind,
groups: groupPositions(
portfolio.positions
.filter((position) => {
if (kind === "stocks") return position.type === "STOCK";
if (kind === "bonds") return position.type === "BOND";
return (
position.type !== "STOCK" &&
position.type !== "BOND" &&
position.type !== "CURRENCY"
);
})
.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(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
kind: PositionSectionKind,
): AccountPagePosition {
const expanded = kind !== "other";
const returnValue = position.absoluteReturn
? Number(position.absoluteReturn.value)
: null;
const returnTone =
returnValue === null || returnValue === 0
? undefined
: returnValue > 0
? "positive"
: "negative";
return {
...position,
shareText: position.currentValue?.currency
? formatShare(position.currentValue, {
currency: position.currentValue.currency,
value: String(denominators.get(position.currentValue.currency) ?? 0),
})
: null,
returnText: position.absoluteReturn
? `${expanded && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
: null,
returnTone,
logoSrc: expanded ? instrumentLogoUrl(position.logoName) : null,
};
}
export {
type AccountPageIncomeSummary,
type AccountPagePosition,
type AccountPagePositionGroup,
type AccountPagePositionSection,
type AccountPageRecentEvents,
type AccountPageRecentOperations,
toAccountPagePositionSections,
useAccountPageViewModel,
};