255 lines
7.3 KiB
TypeScript
255 lines
7.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 BrokerageEvent,
|
|
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 AccountPagePositionSection = {
|
|
kind: PositionSectionKind;
|
|
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 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(): AccountPageViewModel {
|
|
const { accountId } = useParams();
|
|
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.positions.length > 0,
|
|
);
|
|
}
|
|
|
|
function toSection(
|
|
kind: PositionSectionKind,
|
|
portfolio: Portfolio,
|
|
denominators: Map<string, number>,
|
|
): AccountPagePositionSection {
|
|
return {
|
|
kind,
|
|
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" &&
|
|
position.type !== "CURRENCY"
|
|
);
|
|
})
|
|
.map((position) => toPosition(position, denominators, kind)),
|
|
};
|
|
}
|
|
|
|
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 AccountPagePositionSection,
|
|
type AccountPageRecentEvents,
|
|
type AccountPageRecentOperations,
|
|
toAccountPagePositionSections,
|
|
useAccountPageViewModel,
|
|
};
|