Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 8m54s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 9m59s
430 lines
12 KiB
TypeScript
430 lines
12 KiB
TypeScript
import { useParams } from "react-router";
|
|
|
|
import type { BrokerageOperation } from "@/entities/broker-operation";
|
|
import type { Portfolio } from "@/entities/portfolio";
|
|
|
|
import { useBrokerageOperations } from "@/entities/broker-operation";
|
|
import {
|
|
type BrokerageAccount,
|
|
useBrokerageAccounts,
|
|
} from "@/entities/brokerage-account";
|
|
import {
|
|
type BrokerageEventsPreviewState,
|
|
useBrokerageEvents,
|
|
} from "@/entities/brokerage-event";
|
|
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
|
import { formatShare, usePortfolio } from "@/entities/portfolio";
|
|
import { useStocks } from "@/entities/stock";
|
|
import { useScreenSync } from "@/features/tbank-connection";
|
|
import { apiErrorCode } from "@/shared/api";
|
|
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
|
|
|
const RECENT_EVENTS_LIMIT = 5;
|
|
|
|
type AccountPageContent = {
|
|
account?: BrokerageAccount;
|
|
accountId?: string;
|
|
incomeSummary: AccountPageIncomeSummary;
|
|
partial?: boolean;
|
|
recentEvents: AccountPageRecentEvents;
|
|
recentOperations: AccountPageRecentOperations;
|
|
refreshError: boolean;
|
|
refreshing: boolean;
|
|
retry: () => Promise<unknown> | undefined;
|
|
sourceError?: boolean;
|
|
};
|
|
|
|
type AccountPageEmptyViewModel = AccountPageContent & {
|
|
state: "empty";
|
|
};
|
|
|
|
type AccountPageIncomeSummary
|
|
= | {
|
|
lastSuccessfulAt?: null | string;
|
|
refreshError?: boolean;
|
|
stale?: boolean;
|
|
state: "loaded";
|
|
summary: IncomeSummary;
|
|
to: string;
|
|
}
|
|
| { retry: () => Promise<unknown> | undefined; state: "error"; to: string }
|
|
| { state: "empty"
|
|
| "loading"
|
|
| "partial"; to: string; };
|
|
|
|
type AccountPageLoadedViewModel = AccountPageContent & {
|
|
portfolio: Portfolio;
|
|
positionSections: AccountPagePositionSection[];
|
|
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 AccountPageRecentOperations
|
|
= | {
|
|
items: BrokerageOperation[];
|
|
lastSuccessfulAt?: null | string;
|
|
refreshError?: boolean;
|
|
stale?: boolean;
|
|
state: "loaded" | "partial";
|
|
to: string;
|
|
}
|
|
| {
|
|
state: "empty"
|
|
| "error"
|
|
| "loading";
|
|
to: string;
|
|
};
|
|
|
|
type AccountPageReturnTone = "negative" | "positive";
|
|
|
|
type AccountPageViewModel
|
|
= | AccountPageEmptyViewModel
|
|
| AccountPageLoadedViewModel
|
|
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
|
|
| { 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(
|
|
accountId: string | undefined,
|
|
income: ReturnType<typeof useIncomeSummary>,
|
|
): AccountPageIncomeSummary {
|
|
const to = `/accounts/${accountId}/income`;
|
|
if (!income.data && !income.isError && income.meta?.sync.state !== "failed")
|
|
return { state: "loading", to };
|
|
if (income.meta?.availability === "partial") return { state: "partial", to };
|
|
if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
|
|
return { retry: () => income.refetch(), state: "error", to };
|
|
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
|
|
|
|
return {
|
|
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
|
|
refreshError: income.isError || income.meta?.sync.state === "failed",
|
|
stale: income.meta?.freshness === "stale",
|
|
state: "loaded",
|
|
summary: income.data,
|
|
to,
|
|
};
|
|
}
|
|
|
|
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(
|
|
accountId: string | undefined,
|
|
events: ReturnType<typeof useBrokerageEvents>,
|
|
): AccountPageRecentEvents {
|
|
const to = `/accounts/${accountId}/events`;
|
|
if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
|
|
return { state: "loading", to };
|
|
if (events.meta?.availability === "partial")
|
|
return {
|
|
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
|
|
state: "partial",
|
|
to,
|
|
};
|
|
if (!events.data && (events.isError || events.meta?.sync.state === "failed"))
|
|
return { state: "error", to };
|
|
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
|
|
if (items.length === 0) return { state: "empty", to };
|
|
|
|
return {
|
|
items,
|
|
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
|
|
refreshError: events.isError || events.meta?.sync.state === "failed",
|
|
stale: events.meta?.freshness === "stale",
|
|
state: "loaded",
|
|
to,
|
|
};
|
|
}
|
|
|
|
function toRecentOperations(
|
|
accountId: string | undefined,
|
|
operations: ReturnType<typeof useBrokerageOperations>,
|
|
): AccountPageRecentOperations {
|
|
const to = `/accounts/${accountId}/operations`;
|
|
if (
|
|
!operations.data
|
|
&& !operations.isError
|
|
&& operations.meta?.sync.state !== "failed"
|
|
)
|
|
return { state: "loading", to };
|
|
if (operations.meta?.availability === "partial")
|
|
return { items: operations.data?.items ?? [], state: "partial", to };
|
|
if (
|
|
!operations.data
|
|
&& (operations.isError || operations.meta?.sync.state === "failed")
|
|
)
|
|
return { state: "error", to };
|
|
if (!operations.data?.items.length) return { state: "empty", to };
|
|
|
|
return {
|
|
items: operations.data.items,
|
|
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
|
|
refreshError:
|
|
operations.isError || operations.meta?.sync.state === "failed",
|
|
stale: operations.meta?.freshness === "stale",
|
|
state: "loaded",
|
|
to,
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
|
const { accountId: routeAccountId } = useParams();
|
|
const accountId = scopeId ?? routeAccountId;
|
|
const portfolio = usePortfolio(accountId);
|
|
|
|
const stocks = useStocks(
|
|
portfolio.data?.positions.flatMap(position =>
|
|
position.type === "STOCK" && position.instrumentUid
|
|
? [position.instrumentUid]
|
|
: [],
|
|
) ?? [],
|
|
);
|
|
|
|
const instrumentDetails = stocks;
|
|
|
|
const operations = useBrokerageOperations(accountId, {
|
|
limit: 5,
|
|
});
|
|
|
|
const events = useBrokerageEvents(accountId);
|
|
const income = useIncomeSummary(accountId);
|
|
const accounts = useBrokerageAccounts();
|
|
|
|
const sync = useScreenSync({ accountId, screen: "account" }, [
|
|
accounts,
|
|
portfolio,
|
|
operations,
|
|
events,
|
|
income,
|
|
]);
|
|
|
|
if (!portfolio.data) {
|
|
if (portfolio.isError || portfolio.meta?.sync.state === "failed")
|
|
return {
|
|
code: apiErrorCode(portfolio.error),
|
|
retry: sync.retry,
|
|
state: "error" as const,
|
|
};
|
|
|
|
return { state: "loading" as const };
|
|
}
|
|
|
|
const account = accounts.data?.find(item => item.id === accountId);
|
|
const recentOperations = toRecentOperations(accountId, operations);
|
|
const recentEvents = toRecentEvents(accountId, events);
|
|
const incomeSummary = toIncomeSummary(accountId, income);
|
|
|
|
const refresh = {
|
|
partial: portfolio.meta?.availability === "partial",
|
|
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
|
|
refreshing: portfolio.isFetching || sync.refreshing,
|
|
retry: sync.retry,
|
|
sourceError: sync.sourceError,
|
|
};
|
|
|
|
if (
|
|
portfolio.meta?.availability === "complete"
|
|
&& portfolio.data.positions.length === 0
|
|
&& portfolio.data.cashBalances.length === 0
|
|
)
|
|
return {
|
|
account,
|
|
accountId,
|
|
incomeSummary,
|
|
recentEvents,
|
|
recentOperations,
|
|
state: "empty" as const,
|
|
...refresh,
|
|
};
|
|
|
|
return {
|
|
account,
|
|
accountId,
|
|
incomeSummary,
|
|
portfolio: portfolio.data,
|
|
positionSections: toAccountPagePositionSections(
|
|
portfolio.data,
|
|
instrumentDetails,
|
|
),
|
|
recentEvents,
|
|
recentOperations,
|
|
state: "loaded" as const,
|
|
...refresh,
|
|
};
|
|
}
|
|
|
|
export {
|
|
type AccountPageIncomeSummary,
|
|
type AccountPagePosition,
|
|
type AccountPagePositionGroup,
|
|
type AccountPagePositionSection,
|
|
type AccountPageRecentEvents,
|
|
type AccountPageRecentOperations,
|
|
toAccountPagePositionSections,
|
|
useAccountPageViewModel,
|
|
};
|