feat: replace account page accordion with tabs and show open position result (#112)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 20s
Frontend verify / verify (push) Failing after 6m36s

This commit was merged in pull request #113.
This commit is contained in:
2026-10-26 20:30:35 +03:00
parent 1a92f023c1
commit 3075081df1
18 changed files with 630 additions and 131 deletions
@@ -1,6 +1,12 @@
import type { FC } from "react";
import { Accordion, Alert, Stack } from "@mantine/core";
import {
Alert,
Group,
SegmentedControl,
Stack,
Tabs,
Text,
} from "@mantine/core";
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
@@ -8,6 +14,7 @@ import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import { PortfolioStructure } from "@/widgets/portfolio-structure";
@@ -35,18 +42,27 @@ type AccountPageLoadedProps = {
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
source?: InvestmentResourceMeta;
unrealizedReturns: Portfolio["totalsByCurrency"];
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation();
const [filter, setFilter] = useState<string>();
const defaultValue = [
...props.positionSections.map(section => section.kind),
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
];
const activeSection = props.positionSections.find(
section => section.kind === filter,
) ?? props.positionSections[0];
const positionKindLabels: Record<AccountPagePositionSection["kind"], string> = {
bonds: t("portfolio.positionKinds.bonds"),
other: t("portfolio.positionKinds.other"),
stocks: t("portfolio.positionKinds.stocks"),
};
const filterOptions = props.positionSections.map(section => ({
label: positionKindLabels[section.kind],
value: section.kind,
}));
return (
<Stack gap="lg">
@@ -62,6 +78,26 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
? <Alert role="status">{t("investment.partial")}</Alert>
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
{!props.partial && props.unrealizedReturns.length > 0 && (
<Stack
aria-label={t("portfolio.unrealizedResult")}
component="section"
gap={4}
>
<Text fw={600}>{t("portfolio.unrealizedResult")}</Text>
<Group gap="md">
{props.unrealizedReturns.map(total => (
<Text key={total.currency}>{formatMoney(total)}</Text>
))}
</Group>
<Text c="dimmed" size="xs">
{t("portfolio.unrealizedResultCaption")}
</Text>
</Stack>
)}
{!props.partial && (
<PortfolioStructure
key={props.accountId}
@@ -69,70 +105,73 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
/>
)}
<Accordion
classNames={{
chevron: classes.accordionChevron,
control: classes.accordionControl,
label: classes.accordionLabel,
panel: classes.accordionPanel,
}}
defaultValue={defaultValue}
multiple
<Tabs
classNames={{ list: classes.tabsList }}
defaultValue="positions"
keepMounted={false}
>
{props.positionSections.map(section => (
<Accordion.Item key={section.kind} value={section.kind}>
<Accordion.Control>
{t(positionSectionTitleKey(section.kind))}
</Accordion.Control>
<Tabs.List aria-label={t("portfolio.sections")} mb="md">
<Tabs.Tab value="positions">
{t("portfolio.tabs.positions")}
</Tabs.Tab>
<Accordion.Panel>
<PositionSection section={section} />
</Accordion.Panel>
</Accordion.Item>
))}
<Tabs.Tab value="cash">{t("portfolio.tabs.cash")}</Tabs.Tab>
<Accordion.Item value="cash">
<Accordion.Control>{t("portfolio.cashBalances")}</Accordion.Control>
<Tabs.Tab value="operations">
{t("portfolio.tabs.operations")}
</Tabs.Tab>
<Accordion.Panel>
<CurrencyCards amounts={props.portfolio.cashBalances} />
</Accordion.Panel>
</Accordion.Item>
<Tabs.Tab value="events">{t("portfolio.tabs.events")}</Tabs.Tab>
<Accordion.Item value="recentOperations">
<Accordion.Control>{t("ledger.recentTitle")}</Accordion.Control>
<Tabs.Tab value="income">{t("portfolio.tabs.income")}</Tabs.Tab>
</Tabs.List>
<Accordion.Panel>
<RecentOperations operations={props.recentOperations} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentEvents">
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<BrokerageEventsPreview events={props.recentEvents} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="incomeSummary">
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
<Accordion.Panel>
{!!props.incomeSummary && (
<IncomeSummary {...props.incomeSummary} />
<Tabs.Panel value="positions">
<Stack gap="md">
{props.positionSections.length > 1 && (
<div className={classes.positionFilter}>
<SegmentedControl
aria-label={t("portfolio.positionFilter")}
data={filterOptions}
onChange={setFilter}
value={activeSection?.kind}
/>
</div>
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{activeSection && <PositionSection section={activeSection} />}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="cash">
<CurrencyCards amounts={props.portfolio.cashBalances} />
</Tabs.Panel>
<Tabs.Panel value="operations">
<RecentOperations
moreLabel={t("portfolio.more")}
operations={props.recentOperations}
/>
</Tabs.Panel>
<Tabs.Panel value="events">
<BrokerageEventsPreview
events={props.recentEvents}
moreLabel={t("portfolio.more")}
/>
</Tabs.Panel>
<Tabs.Panel value="income">
{!!props.incomeSummary && (
<IncomeSummary
{...props.incomeSummary}
moreLabel={t("portfolio.more")}
/>
)}
</Tabs.Panel>
</Tabs>
</Stack>
);
};
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks";
return kind === "bonds" ? "portfolio.bonds" : "portfolio.otherPositions";
}
export { AccountPageLoaded };
@@ -34,7 +34,9 @@ const AccountPageLoading: FC = () => {
<Skeleton height={260} radius="sm" />
{["first", "second", "third"].map(placeholder => <Skeleton height={52} key={placeholder} radius="sm" />)}
<Skeleton height={40} radius="sm" />
<Skeleton height={220} radius="sm" />
</LoadingSkeleton>
);
};
@@ -9,8 +9,8 @@ import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
type IncomeSummaryProps
= | {
type IncomeSummaryProps = { moreLabel?: string } & (
| {
state: "empty";
to: string;
}
@@ -27,7 +27,8 @@ type IncomeSummaryProps
| {
state: "partial";
to: string;
};
}
);
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation();
@@ -56,7 +57,7 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
<Text c="dimmed">{t("income.summaryEmpty")}</Text>
<Button component={Link} to={props.to} variant="subtle">
{t("income.open")}
{props.moreLabel ?? t("income.open")}
</Button>
</Stack>
);
@@ -75,7 +76,7 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
))}
<Button component={Link} to={props.to} variant="subtle">
{t("income.open")}
{props.moreLabel ?? t("income.open")}
</Button>
</Stack>
);
@@ -15,6 +15,8 @@ const syntheticPosition
logoName: null,
logoSrc: null,
quantity: "100",
returnPercent: "25",
returnPercentText: "+25,00 %",
returnText: "+5 000 ₽",
returnTone: "positive",
shareText: "20%",
@@ -73,6 +75,8 @@ export const BondLoss: Story
"Учебные облигации для долгосрочных накоплений, выпуск 2030",
isin: "DEMO-BOND-2030",
quantity: "10",
returnPercent: "-5",
returnPercentText: "-5,00 %",
returnText: "−500 ₽",
returnTone: "negative",
shareText: "7,6%",
@@ -123,6 +127,8 @@ export const MissingValuation: Story
averagePurchasePrice: null,
currentValue: null,
displayName: "Учебная позиция без текущей оценки",
returnPercent: null,
returnPercentText: null,
returnText: null,
returnTone: undefined,
shareText: null,
@@ -6,7 +6,10 @@ import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import type { AccountPagePosition, AccountPagePositionSection } from "../../../../model/positions";
import type {
AccountPagePosition,
AccountPagePositionSection,
} from "../../../../model/positions";
import classes from "../../AccountPage.module.css";
import { PositionLogo } from "../PositionLogo.view";
@@ -26,8 +29,15 @@ const PositionCard: FC<PositionCardProps> = (props) => {
: undefined;
let returnText = position.returnText;
if (returnText && !isExpanded)
if (returnText && position.returnPercentText) {
returnText += ` · ${position.returnPercentText}`;
}
if (returnText && !isExpanded) {
returnText = t("portfolio.return", { value: returnText });
}
const instrumentTo = position.instrumentTo;
const identifier
@@ -6,7 +6,10 @@ import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import type { AccountPagePositionGroup, AccountPagePositionSection } from "../../../model/positions";
import type {
AccountPagePositionGroup,
AccountPagePositionSection,
} from "../../../model/positions";
import classes from "../AccountPage.module.css";
import { PositionLogo } from "./PositionLogo.view";
@@ -60,6 +63,16 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
? classes[position.returnTone]
: undefined;
const returnPercent = position.returnPercentText
? ` · ${position.returnPercentText}`
: "";
const returnValue = `${position.returnText ?? ""}${returnPercent}`;
const returnText = kind === "other"
? t("portfolio.return", { value: returnValue })
: returnValue;
return (
<Stack
className={classes.accountPosition}
@@ -89,9 +102,7 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
{!!position.returnText && (
<Text className={appearanceClass} size="sm">
{kind === "other"
? t("portfolio.return", { value: position.returnText })
: position.returnText}
{returnText}
</Text>
)}
</Group>
@@ -10,6 +10,7 @@ import { LedgerEntryRow } from "@/entities/broker-operation";
import type { AccountPageRecentOperations } from "../../../model/previews";
type RecentOperationsProps = {
moreLabel?: string;
operations: AccountPageRecentOperations;
};
@@ -44,7 +45,7 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
))}
<Button component={Link} to={operations.to} variant="subtle">
{t("ledger.open")}
{props.moreLabel ?? t("ledger.open")}
</Button>
</Stack>
);