feat: add all-accounts portfolio pages

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.
This commit is contained in:
2026-09-09 20:36:49 +03:00
parent c906dfa2e2
commit daf1dca666
36 changed files with 1962 additions and 117 deletions
@@ -5,13 +5,16 @@ import { AccountEventsPageError } from "./components/AccountEventsPageError.view
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
const AccountEventsPage: FC = () => {
const viewModel = useAccountEventsPageViewModel();
type AccountEventsPageProps = { accountId?: string };
const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
if (viewModel.state === "error")
return <AccountEventsPageError retry={viewModel.retry} />;
if (viewModel.state === "empty") return <AccountEventsPageEmpty />;
return <AccountEventsPageLoaded events={viewModel.events} />;
if (viewModel.state === "empty")
return <AccountEventsPageEmpty {...viewModel} />;
return <AccountEventsPageLoaded {...viewModel} />;
};
export { AccountEventsPage };
@@ -4,20 +4,37 @@ import {
useBrokerageEvents,
} from "@/entities/brokerage-event";
type EventsRefreshState = {
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
};
type AccountEventsPageViewModel =
| { state: "loading" }
| { state: "empty" }
| ({ state: "empty" } & EventsRefreshState)
| { state: "error"; retry: () => void }
| { state: "loaded"; events: BrokerageEvent[] };
| ({ state: "loaded"; events: BrokerageEvent[] } & EventsRefreshState);
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const { accountId } = useParams();
function useAccountEventsPageViewModel(
scopeId?: string,
): AccountEventsPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const events = useBrokerageEvents(accountId);
if (events.isLoading) return { state: "loading" };
if (events.isError) return { state: "error", retry: () => events.refetch() };
if (events.isError && !events.data)
return { state: "error", retry: () => events.refetch() };
if (!events.data) return { state: "loading" };
if (!events.data.events.length) return { state: "empty" };
return { state: "loaded", events: events.data.events };
const refresh = {
refreshing: events.isFetching,
refreshError: events.isError,
retry: () => {
void events.refetch();
},
};
if (!events.data.events.length) return { state: "empty", ...refresh };
return { state: "loaded", events: events.data.events, ...refresh };
}
export { useAccountEventsPageViewModel };
export { type EventsRefreshState, useAccountEventsPageViewModel };
@@ -1,13 +1,25 @@
import { Stack, Text, Title } from "@mantine/core";
import { Alert, Button, Progress, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
import type { EventsRefreshState } from "../AccountEventsPage.vm";
const AccountEventsPageEmpty: FC = () => {
const AccountEventsPageEmpty: FC<EventsRefreshState> = (props) => {
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title>
{props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null}
{props.refreshError ? (
<Alert color="yellow">
{t("accounts.refreshError")}
<Button variant="subtle" onClick={props.retry}>
{t("accounts.retry")}
</Button>
</Alert>
) : null}
<Text c="dimmed">{t("events.hint")}</Text>
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
</Stack>
@@ -1,24 +1,46 @@
import { Stack, Text, Title } from "@mantine/core";
import { Alert, Button, Progress, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
type BrokerageEvent,
BrokerageEventCard,
} from "@/entities/brokerage-event";
import type { EventsRefreshState } from "../AccountEventsPage.vm";
type AccountEventsPageLoadedProps = { events: BrokerageEvent[] };
type AccountEventsPageLoadedProps = EventsRefreshState & {
events: BrokerageEvent[];
};
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title>
{props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null}
{props.refreshError ? (
<Alert color="yellow">
{t("accounts.refreshError")}
<Button variant="subtle" onClick={props.retry}>
{t("accounts.retry")}
</Button>
</Alert>
) : null}
<Text c="dimmed">{t("events.hint")}</Text>
<Stack gap="sm">
{props.events.map((event) => (
<BrokerageEventCard
event={event}
key={`${event.type}-${event.date}-${event.ticker}`}
key={JSON.stringify([
event.account?.id,
event.type,
event.date,
event.instrumentUid,
event.figi,
event.ticker,
event.logoName,
])}
/>
))}
</Stack>
@@ -4,8 +4,10 @@ import { AccountIncomePageError } from "./components/AccountIncomePageError.view
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
const AccountIncomePage: FC = () => {
const viewModel = useAccountIncomePageViewModel();
type AccountIncomePageProps = { accountId?: string };
const AccountIncomePage: FC<AccountIncomePageProps> = (props) => {
const viewModel = useAccountIncomePageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountIncomePageLoading />;
if (viewModel.state === "error")
return <AccountIncomePageError {...viewModel} />;
@@ -58,8 +58,11 @@ const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine(
{ path: ["to"], ...validationMessage("income.validation.period")({}) },
);
const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
const { accountId } = useParams();
const useAccountIncomePageViewModel = (
scopeId?: string,
): AccountIncomePageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const [searchParams, setSearchParams] = useSearchParams();
const requested = useMemo(
() => ({
@@ -4,8 +4,10 @@ import { AccountOperationsPageError } from "./components/AccountOperationsPageEr
import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view";
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
const AccountOperationsPage: FC = () => {
const viewModel = useAccountOperationsPageVm();
type AccountOperationsPageProps = { accountId?: string };
const AccountOperationsPage: FC<AccountOperationsPageProps> = (props) => {
const viewModel = useAccountOperationsPageVm(props.accountId);
if (viewModel.state === "loading") {
return <AccountOperationsPageLoading />;
@@ -1,18 +1,24 @@
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useParams } from "react-router";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import {
type BrokerageOperation,
brokerageOperationsQueryOptions,
type OperationCategory,
operationCategories,
useBrokerageOperations,
} from "@/entities/broker-operation";
import { apiErrorCode } from "@/shared/api";
type AccountOperationsPageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void }
| {
state: "loaded";
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
draftFrom: string | null;
draftTo: string | null;
draftCategories: OperationCategory[];
@@ -29,37 +35,76 @@ type AccountOperationsPageViewModel =
onPrevious: () => void;
};
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const { accountId } = useParams();
const useAccountOperationsPageVm = (
scopeId?: string,
): AccountOperationsPageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const queryClient = useQueryClient();
const [appliedFrom, setAppliedFrom] = useState<string | null>(null);
const [appliedTo, setAppliedTo] = useState<string | null>(null);
const [appliedCategories, setAppliedCategories] = useState<
OperationCategory[]
>([]);
const [draftFrom, setDraftFrom] = useState<string | null>(null);
const [draftTo, setDraftTo] = useState<string | null>(null);
const [draftCategories, setDraftCategories] = useState<OperationCategory[]>(
[],
const [searchParams, setSearchParams] = useSearchParams();
const appliedFrom = searchParams.get("from");
const appliedTo = searchParams.get("to");
const appliedCategories = operationCategories.filter((category) =>
(searchParams.get("categories") ?? "").split(",").includes(category),
);
const [cursors, setCursors] = useState<string[]>([]);
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
const [draftCategories, setDraftCategories] =
useState<OperationCategory[]>(appliedCategories);
const scopeKey = JSON.stringify([
accountId,
appliedFrom,
appliedTo,
appliedCategories,
]);
const [pagination, setPagination] = useState<{
scopeKey: string;
cursors: string[];
}>({ scopeKey, cursors: [] });
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
const cursor = cursors.at(-1);
const operations = useBrokerageOperations(accountId, {
useEffect(() => {
setDraftFrom(searchParams.get("from"));
setDraftTo(searchParams.get("to"));
setDraftCategories(
operationCategories.filter((category) =>
(searchParams.get("categories") ?? "").split(",").includes(category),
),
);
}, [searchParams]);
const firstPageQuery = {
from: dateStart(appliedFrom),
to: dateEnd(appliedTo),
...(appliedCategories.length ? { categories: appliedCategories } : {}),
...(cursor ? { cursor } : {}),
limit: 100,
};
const operations = useBrokerageOperations(accountId, {
...firstPageQuery,
...(cursor ? { cursor } : {}),
});
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
limit: 100,
}).queryKey;
const resetCursors = () => setCursors([]);
const resetCursors = () => setPagination({ scopeKey, cursors: [] });
const retry = () => {
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
void queryClient.invalidateQueries({
queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery)
.queryKey,
exact: true,
});
resetCursors();
} else void operations.refetch();
};
if (operations.isLoading) return { state: "loading" };
if (operations.isError)
return { state: "error", retry: () => operations.refetch() };
if (
operations.isError &&
(!operations.data ||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
)
return { state: "error", retry };
if (!operations.data) return { state: "loading" };
const nextCursor = operations.data.nextCursor;
@@ -70,6 +115,9 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
return {
state: "loaded",
refreshing: operations.isFetching,
refreshError: operations.isError,
retry,
draftFrom,
draftTo,
draftCategories,
@@ -81,25 +129,29 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
onDraftToChange: setDraftTo,
onDraftCategoriesChange: setDraftCategories,
onApply: () => {
setAppliedFrom(draftFrom);
setAppliedTo(draftTo);
setAppliedCategories(draftCategories);
setSearchParams({
...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}),
...(draftCategories.length
? { categories: draftCategories.join(",") }
: {}),
});
resetCursors();
},
onReset: () => {
setDraftFrom(null);
setDraftTo(null);
setDraftCategories([]);
setAppliedFrom(null);
setAppliedTo(null);
setAppliedCategories([]);
setSearchParams({});
resetCursors();
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
},
onNext: () => {
if (nextCursor) setCursors((value) => [...value, nextCursor]);
if (nextCursor)
setPagination({ scopeKey, cursors: [...cursors, nextCursor] });
},
onPrevious: () => setCursors((value) => value.slice(0, -1)),
onPrevious: () =>
setPagination({ scopeKey, cursors: cursors.slice(0, -1) }),
};
};
@@ -1,4 +1,12 @@
import { Button, Group, MultiSelect, Stack, Title } from "@mantine/core";
import {
Alert,
Button,
Group,
MultiSelect,
Progress,
Stack,
Title,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
@@ -67,6 +75,17 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("ledger.title")}</Title>
{props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null}
{props.refreshError ? (
<Alert color="yellow">
{t("accounts.refreshError")}
<Button variant="subtle" onClick={props.retry}>
{t("accounts.retry")}
</Button>
</Alert>
) : null}
<Group align="end">
<DateInput
label={t("ledger.from")}
@@ -103,7 +122,14 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
</Group>
{items.length ? (
items.map((operation) => (
<LedgerEntryRow key={operation.id} operation={operation} />
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
))
) : (
<StateView
@@ -124,3 +124,24 @@
padding-left: 0;
padding-right: 0;
}
.accountPositions {
list-style: none;
padding: 0;
margin: var(--mantine-spacing-sm) 0 0;
}
.accountPosition {
padding-block: var(--mantine-spacing-sm);
border-top: 1px solid var(--mantine-color-gray-3);
overflow-wrap: anywhere;
}
.accountPosition:last-child {
padding-bottom: 0;
}
.accountPositionName {
min-width: 0;
overflow-wrap: anywhere;
}
+6 -2
View File
@@ -5,14 +5,17 @@ import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading.view";
const AccountPage: FC = () => {
const viewModel = useAccountPageViewModel();
type AccountPageProps = { accountId?: string };
const AccountPage: FC<AccountPageProps> = (props) => {
const viewModel = useAccountPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountPageLoading />;
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
if (viewModel.state === "empty")
return (
<AccountPageEmpty
account={viewModel.account}
accountId={viewModel.accountId}
refreshing={viewModel.refreshing}
refreshError={viewModel.refreshError}
retry={viewModel.retry}
@@ -24,6 +27,7 @@ const AccountPage: FC = () => {
return (
<AccountPageLoaded
account={viewModel.account}
accountId={viewModel.accountId}
portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections}
refreshing={viewModel.refreshing}
+10 -5
View File
@@ -172,13 +172,13 @@ describe("useAccountPageViewModel", () => {
"bonds",
"other",
]);
expect(sections[2].positions.map((position) => position.figi)).toEqual([
expect(sections[2].groups.map((group) => group.positions[0].figi)).toEqual([
"FIGI-FUND",
...otherTypes.map((type) => `FIGI-${type}-NEXT`),
]);
expect(sections[0].positions[0].shareText).toBe("40 %");
expect(sections[1].positions[0].shareText).toBe("20 %");
expect(sections[2].positions[1].shareText).toBe("20 %");
expect(sections[0].groups[0].positions[0].shareText).toBe("40 %");
expect(sections[1].groups[0].positions[0].shareText).toBe("20 %");
expect(sections[2].groups[1].positions[0].shareText).toBe("20 %");
expect(snapshot).toEqual(original);
});
@@ -195,7 +195,12 @@ describe("useAccountPageViewModel", () => {
typeof result.current,
{ state: "loaded" }
>;
expect(loaded.positionSections).toMatchObject([
expect(
loaded.positionSections.map(({ kind, groups }) => ({
kind,
positions: groups.flatMap((group) => group.positions),
})),
).toMatchObject([
{
kind: "stocks",
positions: [
+53 -17
View File
@@ -26,9 +26,14 @@ type AccountPagePosition = Portfolio["positions"][number] & {
logoSrc: string | null;
};
type AccountPagePositionGroup = {
key: string;
positions: AccountPagePosition[];
};
type AccountPagePositionSection = {
kind: PositionSectionKind;
positions: AccountPagePosition[];
groups: AccountPagePositionGroup[];
};
type AccountPageRecentOperations =
@@ -76,8 +81,9 @@ type AccountPageViewModel =
| AccountPageEmptyViewModel
| AccountPageLoadedViewModel;
function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams();
function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const portfolio = usePortfolio(accountId);
const operations = useBrokerageOperations(accountId, {
limit: 5,
@@ -177,9 +183,7 @@ function toAccountPagePositionSections(
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,
);
return [stocks, bonds, other].filter((section) => section.groups.length > 0);
}
function toSection(
@@ -189,20 +193,51 @@ function toSection(
): 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)),
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>,
@@ -237,6 +272,7 @@ function toPosition(
export {
type AccountPageIncomeSummary,
type AccountPagePosition,
type AccountPagePositionGroup,
type AccountPagePositionSection,
type AccountPageRecentEvents,
type AccountPageRecentOperations,
@@ -6,6 +6,7 @@ import { accountTypeKey } from "@/entities/brokerage-account";
type AccountHeadingProps = {
account?: BrokerageAccount;
accountId?: string;
};
const AccountHeading: FC<AccountHeadingProps> = (props) => {
@@ -14,8 +15,16 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => {
return (
<Stack gap={2}>
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
{account ? (
<Title order={1}>
{props.accountId === "all"
? t("accounts.all")
: (account?.name ?? t("portfolio.title"))}
</Title>
{props.accountId === "all" ? (
<Text c="dimmed" size="sm">
{t("accounts.allDescription")}
</Text>
) : account ? (
<Text c="dimmed" size="sm">
{t(accountTypeKey(account.type))}
</Text>
@@ -16,6 +16,7 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = {
account?: BrokerageAccount;
accountId?: string;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
@@ -28,7 +29,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<AccountHeading account={props.account} />
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
refreshError={props.refreshError ?? false}
@@ -36,7 +37,11 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
/>
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
text={t(
props.accountId === "all"
? "portfolio.allEmptyText"
: "portfolio.emptyText",
)}
/>
<RecentOperations operations={props.recentOperations} />
<BrokerageEventsPreview events={props.recentEvents} />
@@ -21,6 +21,7 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = {
account?: BrokerageAccount;
accountId?: string;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
@@ -35,7 +36,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation();
return (
<Stack className={classes.page} gap="lg">
<AccountHeading account={props.account} />
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
refreshError={props.refreshError ?? false}
@@ -45,9 +46,14 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null}
<Text c="dimmed" size="sm">
{t("portfolio.updatedAt", {
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
})}
{t(
props.accountId === "all"
? "portfolio.allUpdatedAt"
: "portfolio.updatedAt",
{
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
},
)}
</Text>
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
<Accordion
@@ -1,6 +1,7 @@
import { Card, Group, Stack, Text } from "@mantine/core";
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type {
@@ -37,6 +38,15 @@ const PositionCard: FC<PositionCardProps> = (props) => {
]
.filter(Boolean)
.join(" · ");
const accountLink = position.account ? (
<Anchor
component={Link}
to={`/accounts/${encodeURIComponent(position.account.id)}`}
size="xs"
>
{position.account.name}
</Anchor>
) : null;
const secondaryText = [
position.averagePurchasePrice
? t("portfolio.avgPrice", {
@@ -63,6 +73,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
<Text fw={600} lineClamp={1}>
{position.displayName}
</Text>
{accountLink}
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
{identifierText}
</Text>
@@ -94,6 +105,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
<Text fw={700} lineClamp={1}>
{position.displayName}
</Text>
{accountLink}
{position.isin ? (
<Text c="dimmed" size="xs" className={classes.mono}>
{position.isin}
@@ -0,0 +1,107 @@
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type {
AccountPagePositionGroup,
AccountPagePositionSection,
} from "../AccountPage.vm";
import { PositionLogo } from "./PositionLogo.view";
type PositionGroupCardProps = {
group: AccountPagePositionGroup;
kind: AccountPagePositionSection["kind"];
};
const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
const { group, kind } = props;
const { t } = useTranslation();
const identity = group.positions[0];
return (
<Card className={classes.tile} withBorder>
<Group wrap="nowrap" gap="sm">
{kind !== "other" ? (
<PositionLogo
key={identity.logoSrc ?? "no-logo"}
src={identity.logoSrc}
label={identity.displayName}
/>
) : null}
<Stack gap={2} miw={0}>
<Text fw={600}>{identity.displayName}</Text>
<Text c="dimmed" size="xs">
{identity.ticker ?? identity.isin ?? identity.figi}
</Text>
</Stack>
</Group>
<Stack
component="ul"
gap={0}
className={classes.accountPositions}
aria-label={t("portfolio.byAccount")}
>
{group.positions.map((position) => (
<Stack
component="li"
key={position.account?.id}
gap={4}
className={classes.accountPosition}
>
<Group justify="space-between" align="flex-start" gap="xs">
{position.account ? (
<Anchor
component={Link}
to={`/accounts/${encodeURIComponent(position.account.id)}`}
size="sm"
className={classes.accountPositionName}
>
{position.account.name}
</Anchor>
) : null}
<Text size="sm" fw={600}>
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Group>
<Group justify="space-between" gap="xs">
{position.currentValue ? (
<Text size="sm">{formatMoney(position.currentValue)}</Text>
) : null}
{position.returnText ? (
<Text
size="sm"
className={
position.returnTone
? classes[position.returnTone]
: undefined
}
>
{kind === "other"
? t("portfolio.return", { value: position.returnText })
: position.returnText}
</Text>
) : null}
</Group>
<Group gap="xs">
{position.averagePurchasePrice ? (
<Text c="dimmed" size="xs">
{t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})}
</Text>
) : null}
{position.shareText ? (
<Text c="dimmed" size="xs">
{t("portfolio.share", { value: position.shareText })}
</Text>
) : null}
</Group>
</Stack>
))}
</Stack>
</Card>
);
};
export { PositionGroupCard };
@@ -2,6 +2,7 @@ import { SimpleGrid } from "@mantine/core";
import type { FC } from "react";
import type { AccountPagePositionSection } from "../AccountPage.vm";
import { PositionCard } from "./PositionCard.view";
import { PositionGroupCard } from "./PositionGroupCard.view";
type PositionSectionProps = {
section: AccountPagePositionSection;
@@ -13,13 +14,21 @@ const PositionSection: FC<PositionSectionProps> = (props) => {
return (
<SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}>
{section.positions.map((position) => (
<PositionCard
kind={section.kind}
key={`${position.figi}-${position.displayName}-${position.quantity}`}
position={position}
/>
))}
{section.groups.map((group) =>
group.positions.length > 1 ? (
<PositionGroupCard
key={group.key}
group={group}
kind={section.kind}
/>
) : (
<PositionCard
key={group.key}
position={group.positions[0]}
kind={section.kind}
/>
),
)}
</SimpleGrid>
);
};
@@ -31,7 +31,14 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
) : null}
{operations.state === "loaded"
? operations.items.map((operation) => (
<LedgerEntryRow key={operation.id} operation={operation} />
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
))
: null}
<Button component={Link} to={operations.to} variant="subtle">
@@ -0,0 +1,127 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { toAccountPagePositionSections } from "./AccountPage.vm";
import { PositionSection } from "./components/PositionSection.view";
const position: Portfolio["positions"][number] = {
account: { id: "a", name: "Основной" },
figi: "FIGI-SBER",
displayName: "Сбербанк",
type: "STOCK",
quantity: "1.25",
currentValue: { currency: "RUB", value: "375" },
averagePurchasePrice: { currency: "RUB", value: "280" },
absoluteReturn: { currency: "RUB", value: "25" },
isin: "RU0009029540",
ticker: "SBER",
logoName: null,
};
const second = {
...position,
account: { id: "b", name: "ИИС" },
quantity: "-2",
currentValue: null,
averagePurchasePrice: null,
absoluteReturn: null,
};
function sections(positions: Portfolio["positions"]) {
return toAccountPagePositionSections({
lastSuccessfulAt: "2026-09-09T12:00:00Z",
isStale: false,
totalsByCurrency: [],
cashBalances: [],
positions,
});
}
describe("Positions grouped by security", () => {
it("shows one security with exact per-account quantities and valuations", () => {
const section = sections([position, second])[0];
render(
<MantineProvider>
<MemoryRouter>
<PositionSection section={section} />
</MemoryRouter>
</MantineProvider>,
);
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
const rows = within(
screen.getByRole("list", { name: "Позиции по счетам" }),
).getAllByRole("listitem");
expect(rows).toHaveLength(2);
expect(
within(rows[0]).getByRole("link", { name: "Основной" }),
).toHaveAttribute("href", "/accounts/a");
expect(within(rows[0]).getByText("Количество: 1.25")).toBeInTheDocument();
expect(within(rows[0]).getByText("375 ₽")).toBeInTheDocument();
expect(
within(rows[0]).getByText("Средняя цена: 280 ₽"),
).toBeInTheDocument();
expect(within(rows[1]).getByRole("link", { name: "ИИС" })).toHaveAttribute(
"href",
"/accounts/b",
);
expect(within(rows[1]).getByText("Количество: -2")).toBeInTheDocument();
expect(rows[1]).not.toHaveTextContent("₽");
});
it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => {
expect(
sections([
{ ...position, figi: null },
{ ...second, figi: null },
])[0].groups,
).toHaveLength(1);
expect(
sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups,
).toHaveLength(2);
expect(
sections([
{ ...position, figi: null, isin: null },
{ ...second, figi: null, isin: null },
])[0].groups,
).toHaveLength(2);
});
it("preserves one-account cards and does not merge repeated rows on the same account", () => {
expect(
sections([position, { ...second, account: position.account }])[0].groups,
).toHaveLength(2);
expect(
sections([
{ ...position, account: undefined },
{ ...second, account: undefined },
])[0].groups,
).toHaveLength(2);
});
it("keeps currencies and purchase prices attached to their source positions", () => {
const source = [
position,
{
...second,
currentValue: { currency: "USD", value: "1.01" },
averagePurchasePrice: { currency: "USD", value: "0.99" },
},
];
const before = structuredClone(source);
const [group] = sections(source)[0].groups;
expect(
group.positions.map((item) => [
item.quantity,
item.currentValue,
item.averagePurchasePrice,
]),
).toEqual(
source.map((item) => [
item.quantity,
item.currentValue,
item.averagePurchasePrice,
]),
);
expect(source).toEqual(before);
});
});
@@ -1,6 +1,7 @@
import { Stack, Title } from "@mantine/core";
import { Button, Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
type BrokerageAccountCard,
BrokerageAccountList,
@@ -21,6 +22,14 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
<Stack>
<Title order={1}>{t("accounts.title")}</Title>
<AccountsRefreshState {...refresh} />
<Button
component={Link}
to="/accounts/all"
variant="light"
w="fit-content"
>
{t("accounts.openAll")}
</Button>
<BrokerageAccountList accounts={accounts} />
</Stack>
);