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.
282 lines
8.3 KiB
TypeScript
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,
|
|
};
|