feat: add stock pages and ticker navigation

Add stock search and detail pages with charts, dividends and shared event rows.

Use verified ticker links for portfolio names and logos, numeric dates and refreshed account cards.

Update the generated API contract.
This commit is contained in:
2026-09-10 21:23:41 +03:00
parent 0306526d33
commit 561222e468
56 changed files with 5051 additions and 190 deletions
+38 -10
View File
@@ -13,6 +13,7 @@ import {
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio";
import { formatShare, usePortfolio } from "@/entities/portfolio";
import { useStocks } from "@/entities/stock";
import { apiErrorCode } from "@/shared/api";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
@@ -24,6 +25,7 @@ type AccountPagePosition = Portfolio["positions"][number] & {
returnText: string | null;
returnTone?: AccountPageReturnTone;
logoSrc: string | null;
stockTo?: string;
};
type AccountPagePositionGroup = {
@@ -85,6 +87,13 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const portfolio = usePortfolio(accountId);
const stockDetails = useStocks(
portfolio.data?.positions.flatMap((position) =>
position.type === "STOCK" && position.instrumentUid
? [position.instrumentUid]
: [],
) ?? [],
);
const operations = useBrokerageOperations(accountId, {
limit: 5,
});
@@ -125,7 +134,10 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
account,
accountId,
portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(portfolio.data),
positionSections: toAccountPagePositionSections(
portfolio.data,
stockDetails,
),
recentOperations,
recentEvents,
incomeSummary,
@@ -170,6 +182,7 @@ function toRecentEvents(
function toAccountPagePositionSections(
portfolio: Portfolio,
stockDetails: ReturnType<typeof useStocks> = new Map(),
): AccountPagePositionSection[] {
const denominators = new Map<string, number>();
for (const position of portfolio.positions) {
@@ -180,9 +193,9 @@ function toAccountPagePositionSections(
(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);
const stocks = toSection("stocks", portfolio, denominators, stockDetails);
const bonds = toSection("bonds", portfolio, denominators, stockDetails);
const other = toSection("other", portfolio, denominators, stockDetails);
return [stocks, bonds, other].filter((section) => section.groups.length > 0);
}
@@ -190,6 +203,7 @@ function toSection(
kind: PositionSectionKind,
portfolio: Portfolio,
denominators: Map<string, number>,
stockDetails: ReturnType<typeof useStocks>,
): AccountPagePositionSection {
return {
kind,
@@ -204,7 +218,14 @@ function toSection(
position.type !== "CURRENCY"
);
})
.map((position) => toPosition(position, denominators, kind)),
.map((position) =>
toPosition(
position,
denominators,
kind,
stockDetails.get(position.instrumentUid ?? "")?.slug,
),
),
),
};
}
@@ -214,11 +235,13 @@ function groupPositions(
): 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 identity = position.instrumentUid?.trim()
? ["uid", position.instrumentUid.trim()]
: 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
@@ -242,6 +265,7 @@ function toPosition(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
kind: PositionSectionKind,
stockSlug: string | undefined,
): AccountPagePosition {
const expanded = kind !== "other";
const returnValue = position.absoluteReturn
@@ -255,6 +279,10 @@ function toPosition(
: "negative";
return {
...position,
stockTo:
kind === "stocks" && position.instrumentUid
? `/stocks/${encodeURIComponent(stockSlug ?? position.instrumentUid)}`
: undefined,
shareText: position.currentValue?.currency
? formatShare(position.currentValue, {
currency: position.currentValue.currency,