feat: replace account page accordion with tabs and show open position result (#112)
This commit was merged in pull request #113.
This commit is contained in:
@@ -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 };
|
||||
|
||||
+3
-1
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user