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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user