refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -27,7 +27,12 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => {
|
||||
? t("accounts.all")
|
||||
: (account?.name ?? t("portfolio.title"))}
|
||||
</Title>
|
||||
{description && <Text c="dimmed" size="sm">{description}</Text>}
|
||||
|
||||
{description && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,29 +34,35 @@ type AccountPageEmptyProps = {
|
||||
|
||||
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const text = t(
|
||||
props.accountId === "all"
|
||||
? "portfolio.allEmptyText"
|
||||
: "portfolio.emptyText",
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
|
||||
<PortfolioRefreshState
|
||||
refreshError={props.refreshError ?? false}
|
||||
refreshing={props.refreshing ?? false}
|
||||
retry={props.retry}
|
||||
sourceError={props.sourceError}
|
||||
/>
|
||||
<StateView
|
||||
text={t(
|
||||
props.accountId === "all"
|
||||
? "portfolio.allEmptyText"
|
||||
: "portfolio.emptyText",
|
||||
)}
|
||||
title={t("portfolio.emptyTitle")}
|
||||
/>
|
||||
|
||||
<StateView text={text} title={t("portfolio.emptyTitle")} />
|
||||
|
||||
<PortfolioStructure
|
||||
structure={{ currencies: [], unvaluedCategories: [] }}
|
||||
/>
|
||||
|
||||
<RecentOperations operations={props.recentOperations} />
|
||||
|
||||
<BrokerageEventsPreview events={props.recentEvents} />
|
||||
{!!(props.incomeSummary) && (<IncomeSummary {...props.incomeSummary} />)}
|
||||
|
||||
{!!props.incomeSummary && <IncomeSummary {...props.incomeSummary} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,7 +25,10 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
||||
title={t("portfolio.accountUnavailableTitle")}
|
||||
/>
|
||||
);
|
||||
if (isApiErrorCode(code, ApiErrorCode.TbankNotConnected) || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken))
|
||||
if (
|
||||
isApiErrorCode(code, ApiErrorCode.TbankNotConnected)
|
||||
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|
||||
)
|
||||
return (
|
||||
<StateView
|
||||
action={(
|
||||
@@ -47,6 +50,7 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
||||
title={t("accounts.unavailableTitle")}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<StateView
|
||||
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
|
||||
|
||||
@@ -42,18 +42,30 @@ type AccountPageLoadedProps = {
|
||||
|
||||
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const defaultValue = [
|
||||
...props.positionSections.map(section => section.kind),
|
||||
"cash",
|
||||
"recentOperations",
|
||||
"recentEvents",
|
||||
"incomeSummary",
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||
|
||||
<PortfolioRefreshState
|
||||
refreshError={props.refreshError ?? false}
|
||||
refreshing={props.refreshing ?? false}
|
||||
retry={props.retry}
|
||||
sourceError={props.sourceError}
|
||||
/>
|
||||
{!!(props.portfolio.isStale) && ((
|
||||
|
||||
{!!props.portfolio.isStale && (
|
||||
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
|
||||
))}
|
||||
)}
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(
|
||||
props.accountId === "all"
|
||||
@@ -64,6 +76,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{props.partial
|
||||
? (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
@@ -71,12 +84,14 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
: (
|
||||
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
||||
)}
|
||||
{!(props.partial) && ((
|
||||
|
||||
{!props.partial && (
|
||||
<PortfolioStructure
|
||||
key={props.accountId}
|
||||
structure={props.portfolio.structure}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
|
||||
<Accordion
|
||||
classNames={{
|
||||
chevron: classes.accordionChevron,
|
||||
@@ -84,13 +99,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
label: classes.accordionLabel,
|
||||
panel: classes.accordionPanel,
|
||||
}}
|
||||
defaultValue={[
|
||||
...props.positionSections.map(section => section.kind),
|
||||
"cash",
|
||||
"recentOperations",
|
||||
"recentEvents",
|
||||
"incomeSummary",
|
||||
]}
|
||||
defaultValue={defaultValue}
|
||||
multiple
|
||||
>
|
||||
{props.positionSections.map(section => (
|
||||
@@ -98,35 +107,44 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
<Accordion.Control>
|
||||
{t(positionSectionTitleKey(section.kind))}
|
||||
</Accordion.Control>
|
||||
|
||||
<Accordion.Panel>
|
||||
<PositionSection section={section} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
|
||||
<Accordion.Item value="cash">
|
||||
<Accordion.Control>{t("portfolio.cashBalances")}</Accordion.Control>
|
||||
|
||||
<Accordion.Panel>
|
||||
<CurrencyCards amounts={props.portfolio.cashBalances} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="recentOperations">
|
||||
<Accordion.Control>{t("ledger.recentTitle")}</Accordion.Control>
|
||||
|
||||
<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) && ((
|
||||
{!!props.incomeSummary && (
|
||||
<IncomeSummary {...props.incomeSummary} />
|
||||
))}
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
@@ -136,6 +154,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
|
||||
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
|
||||
if (kind === "stocks") return "portfolio.stocks";
|
||||
|
||||
return kind === "bonds" ? "portfolio.bonds" : "portfolio.otherPositions";
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -19,4 +19,5 @@ const meta = {
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const InitialLoading: Story = { name: "Начальная загрузка счёта" };
|
||||
|
||||
+5
@@ -8,9 +8,11 @@ import classes from "../../AccountPage.module.css";
|
||||
|
||||
const AccountPageLoading: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="35%" />
|
||||
|
||||
<SimpleGrid
|
||||
aria-hidden="true"
|
||||
cols={{ base: 1, md: 3, sm: 2 }}
|
||||
@@ -19,6 +21,7 @@ const AccountPageLoading: FC = () => {
|
||||
{["first", "second", "third"].map(placeholder => (
|
||||
<Paper className={classes.total} key={placeholder} withBorder>
|
||||
<Skeleton height={24} radius={4} width={40} />
|
||||
|
||||
<Skeleton
|
||||
className={classes.totalAmountSkeleton}
|
||||
height={21}
|
||||
@@ -28,7 +31,9 @@ const AccountPageLoading: FC = () => {
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Skeleton height={260} radius="sm" />
|
||||
|
||||
{["first", "second", "third"].map(placeholder => (
|
||||
<Skeleton height={52} key={placeholder} radius="sm" />
|
||||
))}
|
||||
|
||||
@@ -15,11 +15,13 @@ describe("CurrencyCards currency presentation", () => {
|
||||
{ currency: "CAD", value: "4.00" },
|
||||
{ currency: "XYZ", value: "5.00" },
|
||||
].slice(0, count);
|
||||
|
||||
const { container } = render(
|
||||
<MantineProvider>
|
||||
<CurrencyCards amounts={totals} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryAllByRole("definition").map(node => node.textContent),
|
||||
).toEqual(
|
||||
|
||||
@@ -16,8 +16,9 @@ type CurrencyCardsProps = {
|
||||
};
|
||||
|
||||
const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
|
||||
const { amounts } = props;
|
||||
const { t } = useTranslation();
|
||||
const { amounts } = props;
|
||||
|
||||
const currencies: Record<string, { name: string; symbol: string }> = {
|
||||
EUR: { name: t("portfolio.currencies.EUR"), symbol: "€" },
|
||||
RUB: { name: t("portfolio.currencies.RUB"), symbol: "₽" },
|
||||
@@ -28,6 +29,7 @@ const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
|
||||
<SimpleGrid cols={{ base: 1, md: 3, sm: 2 }} spacing={10}>
|
||||
{amounts.map((money) => {
|
||||
const amount = formatDecimal(money.value);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className={classes.total}
|
||||
@@ -40,10 +42,12 @@ const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
|
||||
<span aria-hidden="true">
|
||||
{currencies[money.currency]?.symbol ?? money.currency}
|
||||
</span>
|
||||
|
||||
<VisuallyHidden>
|
||||
{currencies[money.currency]?.name ?? money.currency}
|
||||
</VisuallyHidden>
|
||||
</Text>
|
||||
|
||||
<Text className={classes.totalAmount} component="dd" m={0}>
|
||||
{amount.startsWith("-")
|
||||
? (
|
||||
@@ -51,13 +55,13 @@ const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
|
||||
<span className={classes.totalLeadingDigits}>
|
||||
{amount.slice(0, SIGN_PREFIX_LENGTH)}
|
||||
</span>
|
||||
|
||||
{amount.slice(SIGN_PREFIX_LENGTH)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
amount
|
||||
)}
|
||||
{" "}
|
||||
)}{" "}
|
||||
{currencies[money.currency]?.symbol ?? money.currency}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
@@ -50,42 +50,40 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text c="dimmed">{t("income.summaryEmpty")}</Text>
|
||||
|
||||
<Button component={Link} to={props.to} variant="subtle">
|
||||
{t("income.open")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{!!(props.lastSuccessfulAt) && ((
|
||||
{!!props.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
))}
|
||||
{!!(props.stale) && (<Text size="xs">{t("investment.stale")}</Text>)}
|
||||
{!!(props.refreshError) && ((
|
||||
)}
|
||||
|
||||
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
||||
|
||||
{!!props.refreshError && (
|
||||
<Text role="status">{t("income.refreshError")}</Text>
|
||||
))}
|
||||
)}
|
||||
|
||||
{props.summary.totalsByCurrency.map(total => (
|
||||
<Paper key={total.currency} p="sm" withBorder>
|
||||
<Text fw={700}>{formatMoney(total.total)}</Text>
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("income.coupon")}
|
||||
:
|
||||
{" "}
|
||||
{formatMoney(total.coupon)}
|
||||
{" "}
|
||||
·
|
||||
{" "}
|
||||
{t("income.dividend")}
|
||||
:
|
||||
{" "}
|
||||
{formatMoney(total.dividend)}
|
||||
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
|
||||
{t("income.dividend")}: {formatMoney(total.dividend)}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
<Button component={Link} to={props.to} variant="subtle">
|
||||
{t("income.open")}
|
||||
</Button>
|
||||
|
||||
+2
@@ -30,6 +30,7 @@ const meta = {
|
||||
{ currency: "USD", value: "320" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PortfolioRefreshState {...arguments_} />
|
||||
</Stack>
|
||||
),
|
||||
@@ -40,6 +41,7 @@ const meta = {
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Idle: Story = { name: "Сохранённые данные" };
|
||||
export const Refreshing: Story = {
|
||||
args: { refreshing: true },
|
||||
|
||||
+7
-4
@@ -24,15 +24,18 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
|
||||
>
|
||||
{t(refreshError ? "accounts.retry" : "investment.refresh")}
|
||||
</Button>
|
||||
{!!(refreshing) && ((
|
||||
|
||||
{!!refreshing && (
|
||||
<Group aria-label={t("portfolio.refreshing")} gap="xs" role="status">
|
||||
<Loader size="sm" />
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("portfolio.refreshing")}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{!!(refreshError) && ((
|
||||
)}
|
||||
|
||||
{!!refreshError && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
<Stack gap="sm">
|
||||
<Text>
|
||||
@@ -44,7 +47,7 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
))}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+22
-13
@@ -15,6 +15,7 @@ const rub: Portfolio["structure"]["currencies"][number] = {
|
||||
hasNegativeValues: false,
|
||||
totalValue: "1250000",
|
||||
};
|
||||
|
||||
const meta = {
|
||||
args: { structure: { currencies: [rub], unvaluedCategories: [] } },
|
||||
component: PortfolioStructure,
|
||||
@@ -31,7 +32,9 @@ const meta = {
|
||||
} satisfies Meta<typeof PortfolioStructure>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Regular: Story = {};
|
||||
export const MultipleCurrencies: Story = {
|
||||
args: {
|
||||
@@ -77,12 +80,15 @@ export const Incomplete: Story = {
|
||||
currencies: [
|
||||
{
|
||||
...rub,
|
||||
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...row,
|
||||
sharePercent: null,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */))),
|
||||
categories: rub.categories.map(
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
row =>
|
||||
({
|
||||
...row,
|
||||
sharePercent: null,
|
||||
}),
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
},
|
||||
],
|
||||
unvaluedCategories: [{ count: 2, type: "STOCK" }],
|
||||
@@ -103,13 +109,16 @@ export const Negative: Story = {
|
||||
currencies: [
|
||||
{
|
||||
...rub,
|
||||
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...row,
|
||||
sharePercent: null,
|
||||
value: row.type === "OTHER" ? "25000" : row.value,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */))),
|
||||
categories: rub.categories.map(
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
row =>
|
||||
({
|
||||
...row,
|
||||
sharePercent: null,
|
||||
value: row.type === "OTHER" ? "25000" : row.value,
|
||||
}),
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
hasNegativeValues: true,
|
||||
totalValue: "1150000",
|
||||
},
|
||||
|
||||
+26
-14
@@ -17,6 +17,7 @@ const rub: Portfolio["structure"]["currencies"][number] = {
|
||||
hasNegativeValues: false,
|
||||
totalValue: "100",
|
||||
};
|
||||
|
||||
const ui = (structure: null | Portfolio["structure"] | undefined) => (
|
||||
<MantineProvider>
|
||||
<PortfolioStructure structure={structure} />
|
||||
@@ -24,7 +25,11 @@ const ui = (structure: null | Portfolio["structure"] | undefined) => (
|
||||
);
|
||||
|
||||
describe("portfolio structure", () => {
|
||||
it.each([undefined, /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null])(
|
||||
it.each([
|
||||
undefined,
|
||||
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
|
||||
null,
|
||||
])(
|
||||
"keeps an older snapshot without structure available (%s) and recovers on refresh",
|
||||
(structure) => {
|
||||
const view = render(ui(structure));
|
||||
@@ -68,12 +73,15 @@ describe("portfolio structure", () => {
|
||||
currencies: [
|
||||
{
|
||||
...rub,
|
||||
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...row,
|
||||
sharePercent: null,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */))),
|
||||
categories: rub.categories.map(
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
row =>
|
||||
({
|
||||
...row,
|
||||
sharePercent: null,
|
||||
}),
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
},
|
||||
],
|
||||
unvaluedCategories: [
|
||||
@@ -97,13 +105,16 @@ describe("portfolio structure", () => {
|
||||
currencies: [
|
||||
{
|
||||
...rub,
|
||||
categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...row,
|
||||
sharePercent: null,
|
||||
value: row.type === "STOCK" ? "-60" : row.value,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */))),
|
||||
categories: rub.categories.map(
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
row =>
|
||||
({
|
||||
...row,
|
||||
sharePercent: null,
|
||||
value: row.type === "STOCK" ? "-60" : row.value,
|
||||
}),
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
hasNegativeValues: true,
|
||||
},
|
||||
],
|
||||
@@ -123,6 +134,7 @@ describe("portfolio structure", () => {
|
||||
const view = render(
|
||||
ui({ currencies: [], unvaluedCategories: [{ count: 1, type: "BOND" }] }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Облигации: без оценки — 1")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
||||
view.rerender(ui({ currencies: [], unvaluedCategories: [] }));
|
||||
|
||||
+8
-73
@@ -1,80 +1,15 @@
|
||||
import { type FC, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import type { FC } from "react";
|
||||
|
||||
import { PortfolioStructureView } from "./PortfolioStructure.view";
|
||||
|
||||
const MIN_VISIBLE_SHARE_PERCENT = 0.01;
|
||||
|
||||
// A cached snapshot or an older running server can predate this response field.
|
||||
type PortfolioStructureProps = { structure?: null | Portfolio["structure"] };
|
||||
|
||||
const categoryPresentation = {
|
||||
BOND: { color: "grape.6", label: "portfolio.bonds" },
|
||||
CASH: { color: "green.6", label: "portfolio.structure.cash" },
|
||||
OTHER: { color: "orange.6", label: "portfolio.structure.other" },
|
||||
STOCK: { color: "blue.6", label: "portfolio.stocks" },
|
||||
} as const;
|
||||
import {
|
||||
type PortfolioStructureProps,
|
||||
usePortfolioStructureVm,
|
||||
} from "./PortfolioStructure.vm";
|
||||
|
||||
const PortfolioStructure: FC<PortfolioStructureProps> = (props) => {
|
||||
const { structure } = props;
|
||||
const { t } = useTranslation();
|
||||
const [currency, setCurrency] = useState<null | string>(null);
|
||||
const selected
|
||||
= structure?.currencies.find(item => item.currency === currency)
|
||||
?? structure?.currencies[0];
|
||||
const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
|
||||
const rows = selected
|
||||
? selected.categories.map((category) => {
|
||||
const presentation = categoryPresentation[category.type];
|
||||
const percentage
|
||||
= category.sharePercent === null ? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null : Number(category.sharePercent);
|
||||
const unknown = structure?.unvaluedCategories.some(
|
||||
item => item.type === category.type,
|
||||
);
|
||||
const amount = formatMoney({
|
||||
currency: selected.currency,
|
||||
value: category.value,
|
||||
});
|
||||
let displayedAmount = amount;
|
||||
if (unknown) displayedAmount = category.value === "0" ? t("portfolio.structure.unvalued") : t("portfolio.structure.knownAmount", { amount });
|
||||
let share = t("portfolio.structure.shareUnavailable");
|
||||
if (percentage !== null) share = percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT
|
||||
? t("portfolio.structure.smallShare")
|
||||
: `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`;
|
||||
return {
|
||||
amount: displayedAmount,
|
||||
color: presentation.color,
|
||||
name: t(presentation.label),
|
||||
share,
|
||||
value: percentage ?? 0,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
const chartData = rows.filter(row => row.value > 0);
|
||||
return (
|
||||
<PortfolioStructureView
|
||||
chartData={chartData}
|
||||
currencies={structure?.currencies.map(item => item.currency) ?? []}
|
||||
currency={selected?.currency ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null}
|
||||
incomplete={isIncomplete}
|
||||
negative={selected?.hasNegativeValues ?? false}
|
||||
onCurrencyChange={setCurrency}
|
||||
rows={rows}
|
||||
unavailable={!structure}
|
||||
unvalued={
|
||||
structure?.unvaluedCategories.map(category =>
|
||||
t("portfolio.structure.unvaluedCategory", {
|
||||
category: t(categoryPresentation[category.type].label),
|
||||
count: category.count,
|
||||
}),
|
||||
) ?? []
|
||||
}
|
||||
/>
|
||||
);
|
||||
const vm = usePortfolioStructureVm(props);
|
||||
|
||||
return <PortfolioStructureView {...vm} />;
|
||||
};
|
||||
|
||||
export { PortfolioStructure };
|
||||
|
||||
+54
-40
@@ -16,6 +16,13 @@ type PortfolioStructureViewProps = {
|
||||
unavailable: boolean;
|
||||
unvalued: string[];
|
||||
};
|
||||
|
||||
type PortfolioTooltipProps = {
|
||||
active?: boolean;
|
||||
payload?: { name?: number | string }[];
|
||||
rows: StructureRow[];
|
||||
};
|
||||
|
||||
type StructureRow = {
|
||||
amount: string;
|
||||
color: string;
|
||||
@@ -24,9 +31,28 @@ type StructureRow = {
|
||||
value: number;
|
||||
};
|
||||
|
||||
const PortfolioTooltip: FC<PortfolioTooltipProps> = (props) => {
|
||||
const row = props.rows.find(item => item.name === props.payload?.[0]?.name);
|
||||
if (!row || !props.active) return;
|
||||
|
||||
return (
|
||||
<Paper p="sm" radius="md" shadow="sm">
|
||||
<Text fw={600} size="sm">{row.name}</Text>
|
||||
|
||||
<Text size="sm">{row.amount} · {row.share}</Text>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const titleId = useId();
|
||||
const appearanceClass = `${classes.content} ${props.chartData.length > 0 ? classes.layout : ""}`;
|
||||
|
||||
const tooltipProps = {
|
||||
content: <PortfolioTooltip rows={props.chartData} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
aria-labelledby={titleId}
|
||||
@@ -38,7 +64,8 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
|
||||
<Title id={titleId} order={2} size="h4">
|
||||
{t("portfolio.structure.title")}
|
||||
</Title>
|
||||
{(props.currencies.length > 1) && ((
|
||||
|
||||
{props.currencies.length > 1 && (
|
||||
<Select
|
||||
allowDeselect={false}
|
||||
aria-label={t("portfolio.structure.currency")}
|
||||
@@ -47,8 +74,9 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
|
||||
value={props.currency}
|
||||
w={112}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(
|
||||
props.unavailable
|
||||
@@ -56,32 +84,36 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
|
||||
: "portfolio.structure.basis",
|
||||
)}
|
||||
</Text>
|
||||
{!!(props.incomplete) && ((
|
||||
|
||||
{!!props.incomplete && (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">{t("portfolio.structure.incomplete")}</Text>
|
||||
|
||||
{props.unvalued.map(message => (
|
||||
<Text c="dimmed" key={message} size="sm">
|
||||
{message}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
{!!(props.negative) && ((
|
||||
)}
|
||||
|
||||
{!!props.negative && (
|
||||
<Text size="sm">{t("portfolio.structure.negative")}</Text>
|
||||
))}
|
||||
{!!(!props.unavailable
|
||||
)}
|
||||
|
||||
{!!(
|
||||
!props.unavailable
|
||||
&& props.chartData.length === 0
|
||||
&& !props.incomplete
|
||||
&& !props.negative) && ((
|
||||
&& !props.negative
|
||||
) && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("portfolio.structure.empty")}
|
||||
</Text>
|
||||
))}
|
||||
<div
|
||||
className={`${classes.content} ${props.chartData.length > 0 ? classes.layout : ""}`}
|
||||
key={props.currency}
|
||||
>
|
||||
{props.chartData.length > 0 && ((
|
||||
)}
|
||||
|
||||
<div className={appearanceClass} key={props.currency}>
|
||||
{props.chartData.length > 0 && (
|
||||
<DonutChart
|
||||
aria-label={t("portfolio.structure.chartLabel")}
|
||||
chartLabel={props.currency ?? undefined}
|
||||
@@ -94,32 +126,11 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
|
||||
strokeWidth={2}
|
||||
thickness={32}
|
||||
tooltipDataSource="segment"
|
||||
tooltipProps={{
|
||||
content: ({ active, payload }) => {
|
||||
const row = props.chartData.find(
|
||||
item => item.name === payload?.[0]?.name,
|
||||
);
|
||||
return active && row
|
||||
? (
|
||||
<Paper p="sm" radius="md" shadow="sm">
|
||||
<Text fw={600} size="sm">
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{row.amount}
|
||||
{" "}
|
||||
·
|
||||
{" "}
|
||||
{row.share}
|
||||
</Text>
|
||||
</Paper>
|
||||
)
|
||||
: undefined;
|
||||
},
|
||||
}}
|
||||
tooltipProps={tooltipProps}
|
||||
/>
|
||||
))}
|
||||
{props.rows.length > 0 && ((
|
||||
)}
|
||||
|
||||
{props.rows.length > 0 && (
|
||||
<ul
|
||||
aria-label={t("portfolio.structure.categories")}
|
||||
className={classes.list}
|
||||
@@ -132,12 +143,15 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
|
||||
bg={row.color}
|
||||
className={classes.marker}
|
||||
/>
|
||||
|
||||
<Text size="sm">{row.name}</Text>
|
||||
</Group>
|
||||
|
||||
<div className={classes.values}>
|
||||
<Text fw={600} size="sm">
|
||||
{row.amount}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{row.share}
|
||||
</Text>
|
||||
@@ -145,7 +159,7 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
const MIN_VISIBLE_SHARE_PERCENT = 0.01;
|
||||
|
||||
// A cached snapshot or an older running server can predate this response field.
|
||||
type PortfolioStructureProps = { structure?: null | Portfolio["structure"] };
|
||||
|
||||
const categoryPresentation = {
|
||||
BOND: { color: "grape.6", label: "portfolio.bonds" },
|
||||
CASH: { color: "green.6", label: "portfolio.structure.cash" },
|
||||
OTHER: { color: "orange.6", label: "portfolio.structure.other" },
|
||||
STOCK: { color: "blue.6", label: "portfolio.stocks" },
|
||||
} as const;
|
||||
|
||||
const usePortfolioStructureVm = (props: PortfolioStructureProps) => {
|
||||
const { structure } = props;
|
||||
const { t } = useTranslation();
|
||||
const [currency, setCurrency] = useState<null | string>(null);
|
||||
|
||||
const selected
|
||||
= structure?.currencies.find(item => item.currency === currency)
|
||||
?? structure?.currencies[0];
|
||||
|
||||
const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
|
||||
const rows = portfolioRows(selected, structure, t);
|
||||
const chartData = rows.filter(row => row.value > 0);
|
||||
|
||||
return {
|
||||
chartData,
|
||||
currencies: structure?.currencies.map(item => item.currency) ?? [],
|
||||
// eslint-disable-next-line unicorn/no-null -- Mantine clears the controlled currency with null.
|
||||
currency: selected?.currency ?? null,
|
||||
incomplete: isIncomplete,
|
||||
negative: selected?.hasNegativeValues ?? false,
|
||||
onCurrencyChange: setCurrency,
|
||||
rows,
|
||||
unavailable: !structure,
|
||||
unvalued:
|
||||
structure?.unvaluedCategories.map(category =>
|
||||
t("portfolio.structure.unvaluedCategory", {
|
||||
category: t(categoryPresentation[category.type].label),
|
||||
count: category.count,
|
||||
}),
|
||||
) ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
function portfolioRows(
|
||||
selected:
|
||||
NonNullable<Portfolio["structure"]>["currencies"][number] | undefined,
|
||||
structure: PortfolioStructureProps["structure"],
|
||||
t: TFunction,
|
||||
) {
|
||||
if (!selected) return [];
|
||||
|
||||
return selected.categories.map((category) => {
|
||||
const presentation = categoryPresentation[category.type];
|
||||
|
||||
const percentage
|
||||
= category.sharePercent === null
|
||||
? undefined
|
||||
: Number(category.sharePercent);
|
||||
|
||||
const unknown = structure?.unvaluedCategories.some(
|
||||
item => item.type === category.type,
|
||||
);
|
||||
|
||||
let amount = formatMoney({
|
||||
currency: selected.currency,
|
||||
value: category.value,
|
||||
});
|
||||
|
||||
if (unknown) {
|
||||
amount
|
||||
= category.value === "0"
|
||||
? t("portfolio.structure.unvalued")
|
||||
: t("portfolio.structure.knownAmount", { amount });
|
||||
}
|
||||
|
||||
let share = t("portfolio.structure.shareUnavailable");
|
||||
|
||||
if (percentage !== undefined) {
|
||||
if (percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT)
|
||||
share = t("portfolio.structure.smallShare");
|
||||
else
|
||||
share = `${new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
}).format(percentage)} %`;
|
||||
}
|
||||
|
||||
return {
|
||||
amount,
|
||||
color: presentation.color,
|
||||
name: t(presentation.label),
|
||||
share,
|
||||
value: percentage ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export { type PortfolioStructureProps, usePortfolioStructureVm };
|
||||
@@ -4,24 +4,25 @@ import type { AccountPagePosition } from "../../AccountPage.vm";
|
||||
|
||||
import { PositionCard } from "./PositionCard.view";
|
||||
|
||||
const syntheticPosition = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
absoluteReturn: { currency: "RUB", value: "5000" },
|
||||
averagePurchasePrice: { currency: "RUB", value: "200" },
|
||||
currentValue: { currency: "RUB", value: "25000" },
|
||||
displayName: "Учебные технологии",
|
||||
figi: null,
|
||||
isin: null,
|
||||
logoName: null,
|
||||
logoSrc: null,
|
||||
quantity: "100",
|
||||
returnText: "+5 000 ₽",
|
||||
returnTone: "positive",
|
||||
shareText: "20%",
|
||||
ticker: "DEMO",
|
||||
type: "STOCK",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */) satisfies AccountPagePosition;
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
const syntheticPosition
|
||||
= {
|
||||
absoluteReturn: { currency: "RUB", value: "5000" },
|
||||
averagePurchasePrice: { currency: "RUB", value: "200" },
|
||||
currentValue: { currency: "RUB", value: "25000" },
|
||||
displayName: "Учебные технологии",
|
||||
figi: null,
|
||||
isin: null,
|
||||
logoName: null,
|
||||
logoSrc: null,
|
||||
quantity: "100",
|
||||
returnText: "+5 000 ₽",
|
||||
returnTone: "positive",
|
||||
shareText: "20%",
|
||||
ticker: "DEMO",
|
||||
type: "STOCK",
|
||||
} satisfies /* eslint-enable unicorn/no-null */ AccountPagePosition;
|
||||
|
||||
const meta = {
|
||||
args: { kind: "stocks", position: syntheticPosition },
|
||||
@@ -61,8 +62,10 @@ export const StockGain: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
export const BondLoss: Story
|
||||
= {
|
||||
args: {
|
||||
kind: "bonds",
|
||||
position: {
|
||||
@@ -70,7 +73,8 @@ export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture pre
|
||||
absoluteReturn: { currency: "RUB", value: "-500" },
|
||||
averagePurchasePrice: { currency: "RUB", value: "1000" },
|
||||
currentValue: { currency: "RUB", value: "9500" },
|
||||
displayName: "Учебные облигации для долгосрочных накоплений, выпуск 2030",
|
||||
displayName:
|
||||
"Учебные облигации для долгосрочных накоплений, выпуск 2030",
|
||||
isin: "DEMO-BOND-2030",
|
||||
quantity: "10",
|
||||
returnText: "−500 ₽",
|
||||
@@ -85,15 +89,17 @@ export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture pre
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Для облигации используется идентификатор выпуска. Длинное имя и отрицательная сумма позволяют проверить компактное представление на узком экране.",
|
||||
"Для облигации используется идентификатор выпуска. Длинное имя и отрицательная сумма позволяют проверить компактное представление на узком экране.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
};
|
||||
/* eslint-enable unicorn/no-null */
|
||||
|
||||
export const Other: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
export const Other: Story
|
||||
= {
|
||||
args: {
|
||||
kind: "other",
|
||||
position: {
|
||||
@@ -109,15 +115,17 @@ export const Other: Story = (/* eslint-disable unicorn/no-null -- Fixture preser
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Компактный вариант для остальных инструментов сохраняет количество, стоимость и пояснение результата без оформления акции или облигации.",
|
||||
"Компактный вариант для остальных инструментов сохраняет количество, стоимость и пояснение результата без оформления акции или облигации.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
};
|
||||
/* eslint-enable unicorn/no-null */
|
||||
|
||||
export const MissingValuation: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
export const MissingValuation: Story
|
||||
= {
|
||||
args: {
|
||||
position: {
|
||||
...syntheticPosition,
|
||||
@@ -136,9 +144,9 @@ export const MissingValuation: Story = (/* eslint-disable unicorn/no-null -- Fix
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.",
|
||||
"При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
};
|
||||
/* eslint-enable unicorn/no-null */
|
||||
|
||||
@@ -23,20 +23,26 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
const { kind, position } = props;
|
||||
const { t } = useTranslation();
|
||||
const isExpanded = kind !== "other";
|
||||
|
||||
const returnClass = position.returnTone
|
||||
? classes[position.returnTone]
|
||||
: undefined;
|
||||
|
||||
let returnText = position.returnText;
|
||||
if (returnText && !isExpanded) returnText = t("portfolio.return", { value: returnText });
|
||||
if (returnText && !isExpanded)
|
||||
returnText = t("portfolio.return", { value: returnText });
|
||||
const instrumentTo = position.instrumentTo;
|
||||
|
||||
const identifier
|
||||
= kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
|
||||
|
||||
const identifierText = [
|
||||
t("portfolio.quantity", { value: position.quantity }),
|
||||
identifier,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
const accountLink = position.account
|
||||
? (
|
||||
<Anchor
|
||||
@@ -48,15 +54,16 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
</Anchor>
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const secondaryText = [
|
||||
position.averagePurchasePrice
|
||||
? t("portfolio.avgPrice", {
|
||||
value: formatMoney(position.averagePurchasePrice),
|
||||
})
|
||||
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null,
|
||||
: null, // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null.
|
||||
position.shareText
|
||||
? t("portfolio.share", { value: position.shareText })
|
||||
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null,
|
||||
: null, // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null.
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
@@ -71,6 +78,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
src={position.logoSrc}
|
||||
to={instrumentTo}
|
||||
/>
|
||||
|
||||
<Stack className={classes.expandedIdentity} gap={2} miw={0}>
|
||||
{instrumentTo
|
||||
? (
|
||||
@@ -83,31 +91,37 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
{position.displayName}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{accountLink}
|
||||
|
||||
<Text c="dimmed" className={classes.expandedIdentifiers} size="xs">
|
||||
{identifierText}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack align="flex-end" className={classes.expandedValue} gap={2}>
|
||||
{!!(position.currentValue) && ((
|
||||
{!!position.currentValue && (
|
||||
<Text fw={700} size="lg">
|
||||
{formatMoney(position.currentValue)}
|
||||
</Text>
|
||||
))}
|
||||
{!!(returnText) && ((
|
||||
)}
|
||||
|
||||
{!!returnText && (
|
||||
<Text className={returnClass} fw={600} size="sm">
|
||||
{returnText}
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
{!!(secondaryText) && ((
|
||||
|
||||
{!!secondaryText && (
|
||||
<Text c="dimmed" className={classes.expandedMeta} size="xs">
|
||||
{secondaryText}
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={classes.tile} withBorder>
|
||||
<Group align="flex-start" justify="space-between" wrap="nowrap">
|
||||
@@ -115,45 +129,52 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
<Text fw={700} lineClamp={1}>
|
||||
{position.displayName}
|
||||
</Text>
|
||||
|
||||
{accountLink}
|
||||
{!!(position.isin) && ((
|
||||
|
||||
{!!position.isin && (
|
||||
<Text c="dimmed" className={classes.mono} size="xs">
|
||||
{position.isin}
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.quantity", { value: position.quantity })}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack align="flex-end" gap={2}>
|
||||
{!!(position.currentValue) && ((
|
||||
{!!position.currentValue && (
|
||||
<Text fw={800} size="lg">
|
||||
{formatMoney(position.currentValue)}
|
||||
</Text>
|
||||
))}
|
||||
{!!(returnText) && ((
|
||||
)}
|
||||
|
||||
{!!returnText && (
|
||||
<Text className={returnClass} fw={600} size="sm">
|
||||
{returnText}
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
{!!(position.averagePurchasePrice || position.shareText) && ((
|
||||
|
||||
{!!(position.averagePurchasePrice || position.shareText) && (
|
||||
<Group justify="space-between" mt="xs">
|
||||
{!!(position.averagePurchasePrice) && ((
|
||||
{!!position.averagePurchasePrice && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.avgPrice", {
|
||||
value: formatMoney(position.averagePurchasePrice),
|
||||
})}
|
||||
</Text>
|
||||
))}
|
||||
{!!(position.shareText) && ((
|
||||
)}
|
||||
|
||||
{!!position.shareText && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.share", { value: position.shareText })}
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,17 +24,19 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const identity = group.positions[0];
|
||||
const instrumentTo = identity.instrumentTo;
|
||||
|
||||
return (
|
||||
<Card className={classes.tile} withBorder>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{kind !== "other" && ((
|
||||
{kind !== "other" && (
|
||||
<PositionLogo
|
||||
key={identity.logoSrc ?? "no-logo"}
|
||||
label={identity.displayName}
|
||||
src={identity.logoSrc}
|
||||
to={instrumentTo}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
|
||||
<Stack gap={2} miw={0}>
|
||||
{instrumentTo
|
||||
? (
|
||||
@@ -45,72 +47,80 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
|
||||
: (
|
||||
<Text fw={600}>{identity.displayName}</Text>
|
||||
)}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{identity.ticker ?? identity.isin ?? identity.figi}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Stack
|
||||
aria-label={t("portfolio.byAccount")}
|
||||
className={classes.accountPositions}
|
||||
component="ul"
|
||||
gap={0}
|
||||
>
|
||||
{group.positions.map(position => (
|
||||
<Stack
|
||||
className={classes.accountPosition}
|
||||
component="li"
|
||||
gap={4}
|
||||
key={position.account?.id}
|
||||
>
|
||||
<Group align="flex-start" gap="xs" justify="space-between">
|
||||
{!!(position.account) && ((
|
||||
<Anchor
|
||||
className={classes.accountPositionName}
|
||||
component={Link}
|
||||
size="sm"
|
||||
to={`/accounts/${encodeURIComponent(position.account.id)}`}
|
||||
>
|
||||
{position.account.name}
|
||||
</Anchor>
|
||||
))}
|
||||
<Text fw={600} size="sm">
|
||||
{t("portfolio.quantity", { value: position.quantity })}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs" justify="space-between">
|
||||
{!!(position.currentValue) && ((
|
||||
<Text size="sm">{formatMoney(position.currentValue)}</Text>
|
||||
))}
|
||||
{!!(position.returnText) && ((
|
||||
<Text
|
||||
className={
|
||||
(position.returnTone) ? (classes[position.returnTone]) : undefined
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{kind === "other"
|
||||
? t("portfolio.return", { value: position.returnText })
|
||||
: position.returnText}
|
||||
{group.positions.map((position) => {
|
||||
const appearanceClass = position.returnTone
|
||||
? classes[position.returnTone]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className={classes.accountPosition}
|
||||
component="li"
|
||||
gap={4}
|
||||
key={position.account?.id}
|
||||
>
|
||||
<Group align="flex-start" gap="xs" justify="space-between">
|
||||
{!!position.account && (
|
||||
<Anchor
|
||||
className={classes.accountPositionName}
|
||||
component={Link}
|
||||
size="sm"
|
||||
to={`/accounts/${encodeURIComponent(position.account.id)}`}
|
||||
>
|
||||
{position.account.name}
|
||||
</Anchor>
|
||||
)}
|
||||
|
||||
<Text fw={600} size="sm">
|
||||
{t("portfolio.quantity", { value: position.quantity })}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{!!(position.averagePurchasePrice) && ((
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.avgPrice", {
|
||||
value: formatMoney(position.averagePurchasePrice),
|
||||
})}
|
||||
</Text>
|
||||
))}
|
||||
{!!(position.shareText) && ((
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.share", { value: position.shareText })}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" justify="space-between">
|
||||
{!!position.currentValue && (
|
||||
<Text size="sm">{formatMoney(position.currentValue)}</Text>
|
||||
)}
|
||||
|
||||
{!!position.returnText && (
|
||||
<Text className={appearanceClass} size="sm">
|
||||
{kind === "other"
|
||||
? t("portfolio.return", { value: position.returnText })
|
||||
: position.returnText}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
{!!position.averagePurchasePrice && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.avgPrice", {
|
||||
value: formatMoney(position.averagePurchasePrice),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!position.shareText && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.share", { value: position.shareText })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Anchor, Avatar } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { useImageFallback } from "@/shared/lib";
|
||||
|
||||
type PositionLogoProps = {
|
||||
label: string;
|
||||
src: null | string;
|
||||
@@ -12,41 +13,36 @@ type PositionLogoProps = {
|
||||
|
||||
const PositionLogo: FC<PositionLogoProps> = (props) => {
|
||||
const { label, src } = props;
|
||||
const [failed, setFailed] = useState(false);
|
||||
const { failed, onError } = useImageFallback();
|
||||
|
||||
const logo
|
||||
= src && !failed
|
||||
? (
|
||||
<Avatar
|
||||
alt={label}
|
||||
onError={() => setFailed(true)}
|
||||
radius="md"
|
||||
size={40}
|
||||
src={src}
|
||||
/>
|
||||
<Avatar alt={label} onError={onError} radius="md" size={40} src={src} />
|
||||
)
|
||||
: (
|
||||
<Avatar radius="md" size={40}>
|
||||
{(label.trim()[0] ?? "?").toUpperCase()}
|
||||
</Avatar>
|
||||
);
|
||||
return props.to
|
||||
? (
|
||||
<Anchor
|
||||
aria-label={label}
|
||||
c="inherit"
|
||||
component={Link}
|
||||
display="block"
|
||||
style={{ borderRadius: "var(--mantine-radius-md)" }}
|
||||
to={props.to}
|
||||
underline="never"
|
||||
>
|
||||
{logo}
|
||||
</Anchor>
|
||||
)
|
||||
: (
|
||||
logo
|
||||
);
|
||||
|
||||
if (props.to) return (
|
||||
<Anchor
|
||||
aria-label={label}
|
||||
c="inherit"
|
||||
component={Link}
|
||||
display="block"
|
||||
style={{ borderRadius: "var(--mantine-radius-md)" }}
|
||||
to={props.to}
|
||||
underline="never"
|
||||
>
|
||||
{logo}
|
||||
</Anchor>
|
||||
);
|
||||
|
||||
return (
|
||||
logo
|
||||
);
|
||||
};
|
||||
|
||||
export { PositionLogo };
|
||||
|
||||
@@ -15,24 +15,27 @@ const PositionSection: FC<PositionSectionProps> = (props) => {
|
||||
const { section } = props;
|
||||
const isExpanded = section.kind !== "other";
|
||||
|
||||
const cols = isExpanded ? 1 : { base: 1, md: 3, sm: 2 };
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={isExpanded ? 1 : { base: 1, md: 3, sm: 2 }}>
|
||||
{section.groups.map(group =>
|
||||
group.positions.length > 1
|
||||
? (
|
||||
<PositionGroupCard
|
||||
group={group}
|
||||
key={group.key}
|
||||
kind={section.kind}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<PositionCard
|
||||
key={group.key}
|
||||
kind={section.kind}
|
||||
position={group.positions[0]}
|
||||
/>
|
||||
),
|
||||
<SimpleGrid cols={cols}>
|
||||
{section.groups.map((group) => {
|
||||
if (group.positions.length > 1) return (
|
||||
<PositionGroupCard
|
||||
group={group}
|
||||
key={group.key}
|
||||
kind={section.kind}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<PositionCard
|
||||
key={group.key}
|
||||
kind={section.kind}
|
||||
position={group.positions[0]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
@@ -15,54 +15,61 @@ type RecentOperationsProps = {
|
||||
};
|
||||
|
||||
const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
||||
const { operations } = props;
|
||||
const { t } = useTranslation();
|
||||
const { operations } = props;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{(operations.state === "loaded") && ((
|
||||
{operations.state === "loaded" && (
|
||||
<>
|
||||
{!!(operations.lastSuccessfulAt) && ((
|
||||
{!!operations.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(operations.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
))}
|
||||
{!!(operations.stale) && ((
|
||||
<Text size="xs">{t("investment.stale")}</Text>
|
||||
))}
|
||||
{!!(operations.refreshError) && ((
|
||||
)}
|
||||
|
||||
{!!operations.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
||||
|
||||
{!!operations.refreshError && (
|
||||
<Text role="status">{t("accounts.refreshError")}</Text>
|
||||
))}
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
{(operations.state === "loading") && ((
|
||||
)}
|
||||
|
||||
{operations.state === "loading" && (
|
||||
<LoadingSkeleton label={t("ledger.recentLoading")}>
|
||||
{["first", "second", "third"].map(placeholder => (
|
||||
<Skeleton height={48} key={placeholder} radius="sm" />
|
||||
))}
|
||||
</LoadingSkeleton>
|
||||
))}
|
||||
{(operations.state === "error") && ((
|
||||
)}
|
||||
|
||||
{operations.state === "error" && (
|
||||
<Alert color="red">{t("ledger.recentError")}</Alert>
|
||||
))}
|
||||
{(operations.state === "empty") && ((
|
||||
)}
|
||||
|
||||
{operations.state === "empty" && (
|
||||
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
|
||||
))}
|
||||
{(operations.state === "partial") && ((
|
||||
)}
|
||||
|
||||
{operations.state === "partial" && (
|
||||
<Text role="status">{t("investment.partial")}</Text>
|
||||
))}
|
||||
{(operations.state === "loaded" || operations.state === "partial") && (operations.items.map(operation => (
|
||||
<LedgerEntryRow
|
||||
key={JSON.stringify([
|
||||
operation.account?.id,
|
||||
operation.id,
|
||||
operation.logoName,
|
||||
])}
|
||||
operation={operation}
|
||||
/>
|
||||
)))}
|
||||
)}
|
||||
|
||||
{(operations.state === "loaded" || operations.state === "partial")
|
||||
&& operations.items.map(operation => (
|
||||
<LedgerEntryRow
|
||||
key={JSON.stringify([
|
||||
operation.account?.id,
|
||||
operation.id,
|
||||
operation.logoName,
|
||||
])}
|
||||
operation={operation}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button component={Link} to={operations.to} variant="subtle">
|
||||
{t("ledger.open")}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user