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 BrokerageEvent, useBrokerageEvents, } from "@/entities/brokerage-event"; import type { Portfolio } from "@/entities/portfolio"; import { formatMoney, formatShare, instrumentLogoUrl, portfolioErrorCode, usePortfolio, } from "@/entities/portfolio"; 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 AccountPagePositionSection = { kind: PositionSectionKind; wide: boolean; positions: AccountPagePosition[]; }; type AccountPageRecentOperations = | { state: "loading" | "error" | "empty"; to: string; } | { state: "loaded"; to: string; items: BrokerageOperation[]; }; type AccountPageRecentEvents = | { state: "loading" | "error" | "empty"; to: string; } | { state: "loaded"; to: string; items: BrokerageEvent[]; }; type AccountPageContent = { account?: BrokerageAccount; accountId?: string; refreshing: boolean; refreshError: boolean; retry: () => void; recentOperations: AccountPageRecentOperations; recentEvents: AccountPageRecentEvents; }; 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(): AccountPageViewModel { const { accountId } = useParams(); const portfolio = usePortfolio(accountId); const operations = useBrokerageOperations(accountId, { limit: 5, }); const events = useBrokerageEvents(accountId); const account = useQueryClient() .getQueryData(brokerageAccountsQueryKey) ?.find((item) => item.id === accountId); const recentOperations = toRecentOperations(accountId, operations); const recentEvents = toRecentEvents(accountId, events); if (!portfolio.data) { if (portfolio.isError) return { state: "error" as const, code: portfolioErrorCode(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, ...refresh, }; return { state: "loaded" as const, account, accountId, portfolio: portfolio.data, positionSections: toAccountPagePositionSections(portfolio.data), recentOperations, recentEvents, ...refresh, }; } function toRecentOperations( accountId: string | undefined, operations: ReturnType, ): 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, ): 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(); 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", true, portfolio, denominators); const bonds = toSection("bonds", true, portfolio, denominators); const other = toSection("other", false, portfolio, denominators); return [stocks, bonds, other].filter( (section) => section.positions.length > 0, ); } function toSection( kind: PositionSectionKind, wide: boolean, portfolio: Portfolio, denominators: Map, ): AccountPagePositionSection { return { kind, wide, positions: 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"; }) .map((position) => toPosition(position, denominators, wide)), }; } function toPosition( position: Portfolio["positions"][number], denominators: Map, wide: boolean, ): AccountPagePosition { 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 ? `${wide && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}` : null, returnTone, logoSrc: wide ? instrumentLogoUrl(position.logoName) : null, }; } export { type AccountPagePosition, type AccountPagePositionSection, type AccountPageRecentEvents, type AccountPageRecentOperations, toAccountPagePositionSections, useAccountPageViewModel, };