chore: finish frontend ESLint migration #18

Enable every plugin recommended preset and replace Biome with ESLint formatting.

Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
2026-09-28 16:36:40 +03:00
committed by ksv741
parent 57630102e4
commit 72d7835227
253 changed files with 10059 additions and 8864 deletions
@@ -1,7 +1,10 @@
import { Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { accountTypeKey } from "@/entities/brokerage-account";
type AccountHeadingProps = {
@@ -13,6 +16,10 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => {
const { account } = props;
const { t } = useTranslation();
let description: string | undefined;
if (props.accountId === "all") description = t("accounts.allDescription");
else if (account) description = t(accountTypeKey(account.type));
return (
<Stack gap={2}>
<Title order={1}>
@@ -20,15 +27,7 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => {
? 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>
) : null}
{description && <Text c="dimmed" size="sm">{description}</Text>}
</Stack>
);
};
@@ -1,14 +1,19 @@
import type { FC } from "react";
import { Stack } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import type {
AccountPageIncomeSummary,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../AccountPage.vm";
import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState/PortfolioRefreshState.view";
@@ -18,13 +23,13 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = {
account?: BrokerageAccount;
accountId?: string;
refreshing?: boolean;
sourceError?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
refreshError?: boolean;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
};
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
@@ -33,25 +38,25 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<Stack>
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
sourceError={props.sourceError}
refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false}
retry={props.retry}
sourceError={props.sourceError}
/>
<StateView
title={t("portfolio.emptyTitle")}
text={t(
props.accountId === "all"
? "portfolio.allEmptyText"
: "portfolio.emptyText",
)}
title={t("portfolio.emptyTitle")}
/>
<PortfolioStructure
structure={{ currencies: [], unvaluedCategories: [] }}
/>
<RecentOperations operations={props.recentOperations} />
<BrokerageEventsPreview events={props.recentEvents} />
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
{!!(props.incomeSummary) && (<IncomeSummary {...props.incomeSummary} />)}
</Stack>
);
};
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -12,44 +13,44 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
if (code === "NOT_FOUND")
return (
<StateView
error
title={t("portfolio.accountUnavailableTitle")}
text={t("portfolio.accountUnavailableText")}
action={
action={(
<Button component={Link} to="/accounts">
{t("portfolio.back")}
</Button>
}
)}
error
text={t("portfolio.accountUnavailableText")}
title={t("portfolio.accountUnavailableTitle")}
/>
);
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("portfolio.connectionErrorTitle")}
text={t("portfolio.connectionErrorText")}
action={
action={(
<Button component={Link} to="/settings">
{t("accounts.openSettings")}
</Button>
}
)}
error
text={t("portfolio.connectionErrorText")}
title={t("portfolio.connectionErrorTitle")}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
error
text={t("accounts.unavailableText")}
title={t("accounts.unavailableTitle")}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
error
text={t("states.errorText")}
title={t("states.errorTitle")}
/>
);
};
@@ -1,17 +1,22 @@
import { Accordion, Alert, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { Accordion, Alert, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import type { Portfolio } from "@/entities/portfolio";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type {
AccountPageIncomeSummary,
AccountPagePositionSection,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../AccountPage.vm";
import classes from "../AccountPage.module.css";
import { AccountHeading } from "./AccountHeading.view";
import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
import { IncomeSummary } from "./IncomeSummary.view";
@@ -23,16 +28,16 @@ import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = {
account?: BrokerageAccount;
accountId?: string;
incomeSummary?: AccountPageIncomeSummary;
partial?: boolean;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
sourceError?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
recentOperations: AccountPageRecentOperations;
refreshError?: boolean;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
@@ -41,14 +46,14 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Stack gap="lg">
<AccountHeading account={props.account} accountId={props.accountId} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
sourceError={props.sourceError}
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>
) : null}
))}
<Text c="dimmed" size="sm">
{t(
props.accountId === "all"
@@ -59,34 +64,36 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
},
)}
</Text>
{props.partial ? (
<Alert role="status">{t("investment.partial")}</Alert>
) : (
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
)}
{!props.partial ? (
{props.partial
? (
<Alert role="status">{t("investment.partial")}</Alert>
)
: (
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
)}
{!(props.partial) && ((
<PortfolioStructure
key={props.accountId}
structure={props.portfolio.structure}
/>
) : null}
))}
<Accordion
multiple
classNames={{
chevron: classes.accordionChevron,
control: classes.accordionControl,
label: classes.accordionLabel,
panel: classes.accordionPanel,
}}
defaultValue={[
...props.positionSections.map((section) => section.kind),
...props.positionSections.map(section => section.kind),
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
]}
classNames={{
control: classes.accordionControl,
label: classes.accordionLabel,
chevron: classes.accordionChevron,
panel: classes.accordionPanel,
}}
multiple
>
{props.positionSections.map((section) => (
{props.positionSections.map(section => (
<Accordion.Item key={section.kind} value={section.kind}>
<Accordion.Control>
{t(positionSectionTitleKey(section.kind))}
@@ -117,9 +124,9 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Accordion.Item value="incomeSummary">
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
<Accordion.Panel>
{props.incomeSummary ? (
{!!(props.incomeSummary) && ((
<IncomeSummary {...props.incomeSummary} />
) : null}
))}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
@@ -129,8 +136,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks";
if (kind === "bonds") return "portfolio.bonds";
return "portfolio.otherPositions";
return kind === "bonds" ? "portfolio.bonds" : "portfolio.otherPositions";
}
export { AccountPageLoaded };
@@ -1,10 +1,9 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AccountPageLoading } from "./AccountPageLoading.view";
const meta = {
title: "Product/AccountPageLoading",
component: AccountPageLoading,
tags: ["autodocs"],
parameters: {
docs: {
description: {
@@ -13,6 +12,8 @@ const meta = {
},
},
},
tags: ["autodocs"],
title: "Product/AccountPageLoading",
} satisfies Meta<typeof AccountPageLoading>;
export default meta;
@@ -1,7 +1,9 @@
import type { FC } from "react";
import { Paper, SimpleGrid, Skeleton } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import classes from "../../AccountPage.module.css";
const AccountPageLoading: FC = () => {
@@ -10,25 +12,25 @@ const AccountPageLoading: FC = () => {
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="35%" />
<SimpleGrid
cols={{ base: 1, sm: 2, md: 3 }}
spacing={10}
aria-hidden="true"
cols={{ base: 1, md: 3, sm: 2 }}
spacing={10}
>
{[0, 1, 2].map((index) => (
<Paper key={index} className={classes.total} withBorder>
<Skeleton height={24} width={40} radius={4} />
{["first", "second", "third"].map(placeholder => (
<Paper className={classes.total} key={placeholder} withBorder>
<Skeleton height={24} radius={4} width={40} />
<Skeleton
height={21}
width={180}
radius={4}
className={classes.totalAmountSkeleton}
height={21}
radius={4}
width={180}
/>
</Paper>
))}
</SimpleGrid>
<Skeleton height={260} radius="sm" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={52} radius="sm" />
{["first", "second", "third"].map(placeholder => (
<Skeleton height={52} key={placeholder} radius="sm" />
))}
</LoadingSkeleton>
);
@@ -1,6 +1,7 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CurrencyCards } from "./CurrencyCards.view";
describe("CurrencyCards currency presentation", () => {
@@ -20,12 +21,14 @@ describe("CurrencyCards currency presentation", () => {
</MantineProvider>,
);
expect(
screen.queryAllByRole("definition").map((node) => node.textContent),
screen.queryAllByRole("definition").map(node => node.textContent),
).toEqual(
["3,00 €", "2,00 $", "1,00 ₽", "4,00 CAD", "5,00 XYZ"].slice(0, count),
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(container.querySelectorAll("dl")).toHaveLength(count);
expect(
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
container.querySelector("[tabindex], button, a, input"),
).toBeNull();
},
@@ -57,11 +60,14 @@ describe("CurrencyCards currency presentation", () => {
);
const term = screen.getByRole("term");
expect(term).toHaveTextContent(name);
expect(term.querySelector('[aria-hidden="true"]')).toHaveTextContent(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(term.querySelector("[aria-hidden=\"true\"]")).toHaveTextContent(
symbol,
);
expect(screen.getByRole("definition")).toHaveTextContent(expected);
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(term.parentElement).toBe(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
screen.getByRole("definition").parentElement,
);
},
@@ -79,7 +85,7 @@ describe("CurrencyCards currency presentation", () => {
</MantineProvider>,
);
expect(
screen.getAllByRole("definition").map((node) => node.textContent),
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1,00 $", "2,00 CAD"]);
});
});
@@ -1,10 +1,16 @@
import { Paper, SimpleGrid, Text, VisuallyHidden } from "@mantine/core";
import type { FC } from "react";
import { Paper, SimpleGrid, Text, VisuallyHidden } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
import { formatDecimal } from "@/shared/lib";
import classes from "../../AccountPage.module.css";
const SIGN_PREFIX_LENGTH = 2;
type CurrencyCardsProps = {
amounts: Portfolio["totalsByCurrency"];
};
@@ -12,25 +18,25 @@ type CurrencyCardsProps = {
const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
const { amounts } = props;
const { t } = useTranslation();
const currencies: Record<string, { symbol: string; name: string }> = {
RUB: { symbol: "₽", name: t("portfolio.currencies.RUB") },
USD: { symbol: "$", name: t("portfolio.currencies.USD") },
EUR: { symbol: "€", name: t("portfolio.currencies.EUR") },
const currencies: Record<string, { name: string; symbol: string }> = {
EUR: { name: t("portfolio.currencies.EUR"), symbol: "€" },
RUB: { name: t("portfolio.currencies.RUB"), symbol: "₽" },
USD: { name: t("portfolio.currencies.USD"), symbol: "$" },
};
return (
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing={10}>
<SimpleGrid cols={{ base: 1, md: 3, sm: 2 }} spacing={10}>
{amounts.map((money) => {
const amount = formatDecimal(money.value);
return (
<Paper
component="dl"
m={0}
className={classes.total}
component="dl"
key={money.currency}
m={0}
withBorder
>
<Text component="dt" className={classes.totalBadge}>
<Text className={classes.totalBadge} component="dt">
<span aria-hidden="true">
{currencies[money.currency]?.symbol ?? money.currency}
</span>
@@ -38,17 +44,20 @@ const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
{currencies[money.currency]?.name ?? money.currency}
</VisuallyHidden>
</Text>
<Text component="dd" m={0} className={classes.totalAmount}>
{amount.startsWith("-") ? (
<>
<span className={classes.totalLeadingDigits}>
{amount.slice(0, 2)}
</span>
{amount.slice(2)}
</>
) : (
amount
)}{" "}
<Text className={classes.totalAmount} component="dd" m={0}>
{amount.startsWith("-")
? (
<>
<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>
@@ -1,30 +1,33 @@
import type { FC } from "react";
import { Button, Paper, Stack, Text } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
type IncomeSummaryProps =
| { state: "loading"; to: string }
| { state: "partial"; to: string }
| { state: "error"; to: string; retry: () => void }
type IncomeSummaryProps
= | {
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "loaded";
summary: IncomeSummaryData;
to: string;
}
| { retry: () => void; state: "error"; to: string }
| { state: "empty"; to: string }
| {
state: "loaded";
to: string;
summary: IncomeSummaryData;
lastSuccessfulAt?: string | null;
stale?: boolean;
refreshError?: boolean;
};
| { state: "loading"; to: string }
| { state: "partial"; to: string };
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation();
if (props.state === "loading" || props.state === "partial")
return (
<Text role="status" c="dimmed">
<Text c="dimmed" role="status">
{t(
props.state === "partial"
? "investment.partial"
@@ -35,12 +38,12 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
if (props.state === "error")
return (
<StateView
error
title={t("income.summaryError")}
text={t("income.errorText")}
action={
<Button onClick={props.retry}>{t("income.summaryRetry")}</Button>
}
error
text={t("income.errorText")}
title={t("income.summaryError")}
/>
);
if (props.state === "empty")
@@ -54,23 +57,32 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
);
return (
<Stack gap="xs">
{props.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{!!(props.lastSuccessfulAt) && ((
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
) : null}
{props.stale ? <Text size="xs">{t("investment.stale")}</Text> : null}
{props.refreshError ? (
))}
{!!(props.stale) && (<Text size="xs">{t("investment.stale")}</Text>)}
{!!(props.refreshError) && ((
<Text role="status">{t("income.refreshError")}</Text>
) : null}
{props.summary.totalsByCurrency.map((total) => (
))}
{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>
))}
@@ -1,19 +1,19 @@
import { Stack } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Stack } from "@mantine/core";
import { fn } from "storybook/test";
import { CurrencyCards } from "../CurrencyCards/CurrencyCards.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
const meta = {
title: "Product/PortfolioRefreshState",
component: PortfolioRefreshState,
tags: ["autodocs"],
args: { refreshing: false, refreshError: false, retry: fn() },
args: { refreshError: false, refreshing: false, retry: fn() },
argTypes: {
refreshing: { control: "boolean" },
refreshError: { control: "boolean" },
refreshing: { control: "boolean" },
retry: { control: false },
},
component: PortfolioRefreshState,
parameters: {
docs: {
description: {
@@ -22,7 +22,7 @@ const meta = {
},
},
},
render: (args) => (
render: arguments_ => (
<Stack>
<CurrencyCards
amounts={[
@@ -30,9 +30,11 @@ const meta = {
{ currency: "USD", value: "320" },
]}
/>
<PortfolioRefreshState {...args} />
<PortfolioRefreshState {...arguments_} />
</Stack>
),
tags: ["autodocs"],
title: "Product/PortfolioRefreshState",
} satisfies Meta<typeof PortfolioRefreshState>;
export default meta;
@@ -40,15 +42,15 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Idle: Story = { name: "Сохранённые данные" };
export const Refreshing: Story = {
name: "Фоновое обновление",
args: { refreshing: true },
name: "Фоновое обновление",
};
export const RefreshError: Story = {
name: "Ошибка обновления с retry",
args: { refreshError: true },
name: "Ошибка обновления с retry",
};
export const SourceFailure: Story = {
name: "Ошибка синхронизации сохраняет суммы",
args: { refreshError: true, sourceError: true },
name: "Ошибка синхронизации сохраняет суммы",
};
@@ -1,38 +1,39 @@
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
type PortfolioRefreshStateProps = {
sourceError?: boolean;
refreshing: boolean;
refreshError: boolean;
refreshing: boolean;
retry?: () => void;
sourceError?: boolean;
};
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
const { refreshing, refreshError, retry } = props;
const { refreshError, refreshing, retry } = props;
const { t } = useTranslation();
return (
<>
<Button
onClick={retry}
disabled={refreshing}
onClick={retry}
variant="light"
w="fit-content"
>
{t(refreshError ? "accounts.retry" : "investment.refresh")}
</Button>
{refreshing ? (
<Group gap="xs" role="status" aria-label={t("portfolio.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>
) : null}
{refreshError ? (
<Alert color="orange" role="status" aria-live="polite">
))}
{!!(refreshError) && ((
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="sm">
<Text>
{t(
@@ -43,7 +44,7 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
</Text>
</Stack>
</Alert>
) : null}
))}
</>
);
};
@@ -1,23 +1,23 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Portfolio } from "@/entities/portfolio";
import { PortfolioStructure } from "./PortfolioStructure";
const rub: Portfolio["structure"]["currencies"][number] = {
currency: "RUB",
totalValue: "1250000",
hasNegativeValues: false,
categories: [
{ type: "STOCK", value: "650000", sharePercent: "52" },
{ type: "BOND", value: "375000", sharePercent: "30" },
{ type: "OTHER", value: "125000", sharePercent: "10" },
{ type: "CASH", value: "100000", sharePercent: "8" },
{ sharePercent: "52", type: "STOCK", value: "650000" },
{ sharePercent: "30", type: "BOND", value: "375000" },
{ sharePercent: "10", type: "OTHER", value: "125000" },
{ sharePercent: "8", type: "CASH", value: "100000" },
],
currency: "RUB",
hasNegativeValues: false,
totalValue: "1250000",
};
const meta = {
title: "Product/PortfolioStructure",
component: PortfolioStructure,
tags: ["autodocs"],
args: { structure: { currencies: [rub], unvaluedCategories: [] } },
component: PortfolioStructure,
parameters: {
docs: {
description: {
@@ -26,6 +26,8 @@ const meta = {
},
},
},
tags: ["autodocs"],
title: "Product/PortfolioStructure",
} satisfies Meta<typeof PortfolioStructure>;
export default meta;
@@ -37,10 +39,10 @@ export const MultipleCurrencies: Story = {
currencies: [
rub,
{
categories: [{ sharePercent: "100", type: "CASH", value: "500" }],
currency: "USD",
totalValue: "500",
hasNegativeValues: false,
categories: [{ type: "CASH", value: "500", sharePercent: "100" }],
totalValue: "500",
},
],
unvaluedCategories: [],
@@ -52,14 +54,14 @@ export const CashOnly: Story = {
structure: {
currencies: [
{
currency: "RUB",
totalValue: "100000",
hasNegativeValues: false,
categories: rub.categories.map((row) => ({
categories: rub.categories.map(row => ({
...row,
value: row.type === "CASH" ? "100000" : "0",
sharePercent: row.type === "CASH" ? "100" : "0",
value: row.type === "CASH" ? "100000" : "0",
})),
currency: "RUB",
hasNegativeValues: false,
totalValue: "100000",
},
],
unvaluedCategories: [],
@@ -75,13 +77,15 @@ export const Incomplete: Story = {
currencies: [
{
...rub,
categories: rub.categories.map((row) => ({
...row,
sharePercent: null,
})),
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 */))),
},
],
unvaluedCategories: [{ type: "STOCK", count: 2 }],
unvaluedCategories: [{ count: 2, type: "STOCK" }],
},
},
};
@@ -89,7 +93,7 @@ export const UnknownOnly: Story = {
args: {
structure: {
currencies: [],
unvaluedCategories: [{ type: "BOND", count: 1 }],
unvaluedCategories: [{ count: 1, type: "BOND" }],
},
},
};
@@ -99,13 +103,15 @@ 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 */))),
hasNegativeValues: true,
totalValue: "1150000",
categories: rub.categories.map((row) => ({
...row,
value: row.type === "OTHER" ? "25000" : row.value,
sharePercent: null,
})),
},
],
unvaluedCategories: [],
@@ -1,28 +1,30 @@
import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { PortfolioStructure } from "./PortfolioStructure";
const rub: Portfolio["structure"]["currencies"][number] = {
currency: "RUB",
totalValue: "100",
hasNegativeValues: false,
categories: [
{ type: "STOCK", value: "60", sharePercent: "60" },
{ type: "BOND", value: "20", sharePercent: "20" },
{ type: "OTHER", value: "0", sharePercent: "0" },
{ type: "CASH", value: "20", sharePercent: "20" },
{ sharePercent: "60", type: "STOCK", value: "60" },
{ sharePercent: "20", type: "BOND", value: "20" },
{ sharePercent: "0", type: "OTHER", value: "0" },
{ sharePercent: "20", type: "CASH", value: "20" },
],
currency: "RUB",
hasNegativeValues: false,
totalValue: "100",
};
const ui = (structure: Portfolio["structure"] | null | undefined) => (
const ui = (structure: null | Portfolio["structure"] | undefined) => (
<MantineProvider>
<PortfolioStructure structure={structure} />
</MantineProvider>
);
describe("portfolio structure", () => {
it.each([undefined, null])(
it.each([undefined, /* eslint-disable-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));
@@ -66,15 +68,17 @@ describe("portfolio structure", () => {
currencies: [
{
...rub,
categories: rub.categories.map((row) => ({
...row,
sharePercent: null,
})),
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 */))),
},
],
unvaluedCategories: [
{ type: "OTHER", count: 2 },
{ type: "STOCK", count: 1 },
{ count: 2, type: "OTHER" },
{ count: 1, type: "STOCK" },
],
}),
);
@@ -93,12 +97,14 @@ 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 */))),
hasNegativeValues: true,
categories: rub.categories.map((row) => ({
...row,
value: row.type === "STOCK" ? "-60" : row.value,
sharePercent: null,
})),
},
],
unvaluedCategories: [],
@@ -115,7 +121,7 @@ describe("portfolio structure", () => {
});
it("handles unknown-only and empty compositions", () => {
const view = render(
ui({ currencies: [], unvaluedCategories: [{ type: "BOND", count: 1 }] }),
ui({ currencies: [], unvaluedCategories: [{ count: 1, type: "BOND" }] }),
);
expect(screen.getByText("Облигации: без оценки — 1")).toBeInTheDocument();
expect(screen.queryByRole("list")).not.toBeInTheDocument();
@@ -130,9 +136,9 @@ describe("portfolio structure", () => {
...rub,
categories: [
{
sharePercent: "0.000001",
type: "CASH",
value: "9007199254740993.000000001",
sharePercent: "0.000001",
},
],
},
@@ -1,72 +1,72 @@
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
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?: Portfolio["structure"] | null };
type PortfolioStructureProps = { structure?: null | Portfolio["structure"] };
const categoryPresentation = {
STOCK: { label: "portfolio.stocks", color: "blue.6" },
BOND: { label: "portfolio.bonds", color: "grape.6" },
OTHER: { label: "portfolio.structure.other", color: "orange.6" },
CASH: { label: "portfolio.structure.cash", color: "green.6" },
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 PortfolioStructure: FC<PortfolioStructureProps> = (props) => {
const { structure } = props;
const { t } = useTranslation();
const [currency, setCurrency] = useState<string | null>(null);
const selected =
structure?.currencies.find((item) => item.currency === currency) ??
structure?.currencies[0];
const incomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
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 ? null : Number(category.sharePercent);
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,
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 {
name: t(presentation.label),
amount: displayedAmount,
color: presentation.color,
amount: unknown
? category.value === "0"
? t("portfolio.structure.unvalued")
: t("portfolio.structure.knownAmount", { amount })
: amount,
share:
percentage === null
? t("portfolio.structure.shareUnavailable")
: percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT
? t("portfolio.structure.smallShare")
: `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`,
name: t(presentation.label),
share,
value: percentage ?? 0,
};
})
: [];
const chartData = rows.filter((row) => row.value > 0);
const chartData = rows.filter(row => row.value > 0);
return (
<PortfolioStructureView
unavailable={!structure}
currencies={structure?.currencies.map((item) => item.currency) ?? []}
currency={selected?.currency ?? null}
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}
chartData={chartData}
incomplete={incomplete}
negative={selected?.hasNegativeValues ?? false}
unavailable={!structure}
unvalued={
structure?.unvaluedCategories.map((category) =>
structure?.unvaluedCategories.map(category =>
t("portfolio.structure.unvaluedCategory", {
category: t(categoryPresentation[category.type].label),
count: category.count,
@@ -2,123 +2,130 @@ import { DonutChart } from "@mantine/charts";
import { Box, Group, Paper, Select, Stack, Text, Title } from "@mantine/core";
import { type FC, useId } from "react";
import { useTranslation } from "react-i18next";
import classes from "./PortfolioStructure.module.css";
type StructureRow = {
name: string;
color: string;
amount: string;
share: string;
value: number;
};
type PortfolioStructureViewProps = {
unavailable: boolean;
currencies: string[];
currency: string | null;
onCurrencyChange: (currency: string | null) => void;
rows: StructureRow[];
chartData: StructureRow[];
currencies: string[];
currency: null | string;
incomplete: boolean;
negative: boolean;
onCurrencyChange: (currency: null | string) => void;
rows: StructureRow[];
unavailable: boolean;
unvalued: string[];
};
type StructureRow = {
amount: string;
color: string;
name: string;
share: string;
value: number;
};
const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
const { t } = useTranslation();
const titleId = useId();
return (
<Stack
component="section"
aria-labelledby={titleId}
gap="md"
className={classes.section}
component="section"
gap="md"
>
<Group justify="space-between" align="center">
<Title order={2} size="h4" id={titleId}>
<Group align="center" justify="space-between">
<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")}
data={props.currencies}
value={props.currency}
onChange={props.onCurrencyChange}
allowDeselect={false}
value={props.currency}
w={112}
/>
) : null}
))}
</Group>
<Text size="sm" c="dimmed">
<Text c="dimmed" size="sm">
{t(
props.unavailable
? "portfolio.structure.unavailable"
: "portfolio.structure.basis",
)}
</Text>
{props.incomplete ? (
{!!(props.incomplete) && ((
<Stack gap={4}>
<Text size="sm">{t("portfolio.structure.incomplete")}</Text>
{props.unvalued.map((message) => (
<Text size="sm" c="dimmed" key={message}>
{props.unvalued.map(message => (
<Text c="dimmed" key={message} size="sm">
{message}
</Text>
))}
</Stack>
) : null}
{props.negative ? (
))}
{!!(props.negative) && ((
<Text size="sm">{t("portfolio.structure.negative")}</Text>
) : null}
{!props.unavailable &&
!props.chartData.length &&
!props.incomplete &&
!props.negative ? (
<Text size="sm" c="dimmed">
))}
{!!(!props.unavailable
&& props.chartData.length === 0
&& !props.incomplete
&& !props.negative) && ((
<Text c="dimmed" size="sm">
{t("portfolio.structure.empty")}
</Text>
) : null}
))}
<div
className={`${classes.content} ${props.chartData.length > 0 ? classes.layout : ""}`}
key={props.currency}
className={`${classes.content} ${props.chartData.length ? classes.layout : ""}`}
>
{props.chartData.length ? (
{props.chartData.length > 0 && ((
<DonutChart
data={props.chartData}
size={220}
thickness={32}
strokeWidth={2}
startAngle={90}
endAngle={-270}
chartLabel={props.currency ?? undefined}
tooltipDataSource="segment"
pieProps={{ isAnimationActive: false }}
pieChartProps={{ title: t("portfolio.structure.chartLabel") }}
aria-label={t("portfolio.structure.chartLabel")}
chartLabel={props.currency ?? undefined}
data={props.chartData}
endAngle={-270}
pieChartProps={{ title: t("portfolio.structure.chartLabel") }}
pieProps={{ isAnimationActive: false }}
size={220}
startAngle={90}
strokeWidth={2}
thickness={32}
tooltipDataSource="segment"
tooltipProps={{
content: ({ active, payload }) => {
const row = props.chartData.find(
(item) => item.name === payload?.[0]?.name,
item => item.name === payload?.[0]?.name,
);
return active && row ? (
<Paper shadow="sm" p="sm" radius="md">
<Text size="sm" fw={600}>
{row.name}
</Text>
<Text size="sm">
{row.amount} · {row.share}
</Text>
</Paper>
) : null;
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;
},
}}
/>
) : null}
{props.rows.length ? (
))}
{props.rows.length > 0 && ((
<ul
className={classes.list}
aria-label={t("portfolio.structure.categories")}
className={classes.list}
>
{props.rows.map((row) => (
<li key={row.name} className={classes.row}>
{props.rows.map(row => (
<li className={classes.row} key={row.name}>
<Group gap="xs" wrap="nowrap">
<Box
aria-hidden="true"
@@ -128,17 +135,17 @@ const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
<Text size="sm">{row.name}</Text>
</Group>
<div className={classes.values}>
<Text size="sm" fw={600}>
<Text fw={600} size="sm">
{row.amount}
</Text>
<Text size="sm" c="dimmed">
<Text c="dimmed" size="sm">
{row.share}
</Text>
</div>
</li>
))}
</ul>
) : null}
))}
</div>
</Stack>
);
@@ -1,28 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { AccountPagePosition } from "../../AccountPage.vm";
import { PositionCard } from "./PositionCard.view";
const syntheticPosition = {
figi: null,
displayName: "Учебные технологии",
type: "STOCK",
quantity: "100",
currentValue: { currency: "RUB", value: "25000" },
averagePurchasePrice: { currency: "RUB", value: "200" },
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,
ticker: "DEMO",
logoName: null,
shareText: "20%",
logoSrc: null,
quantity: "100",
returnText: "+5 000 ₽",
returnTone: "positive",
logoSrc: null,
} satisfies AccountPagePosition;
shareText: "20%",
ticker: "DEMO",
type: "STOCK",
}
/* eslint-enable unicorn/no-null */) satisfies AccountPagePosition;
const meta = {
title: "Product/PositionCard",
component: PositionCard,
tags: ["autodocs"],
args: { kind: "stocks", position: syntheticPosition },
argTypes: {
kind: {
control: "inline-radio",
@@ -30,6 +32,7 @@ const meta = {
},
position: { control: false },
},
component: PositionCard,
parameters: {
docs: {
description: {
@@ -38,7 +41,8 @@ const meta = {
},
},
},
args: { kind: "stocks", position: syntheticPosition },
tags: ["autodocs"],
title: "Product/PositionCard",
} satisfies Meta<typeof PositionCard>;
export default meta;
@@ -57,78 +61,84 @@ export const StockGain: Story = {
},
};
export const BondLoss: Story = {
name: "Облигация с отрицательным результатом",
args: {
kind: "bonds",
position: {
...syntheticPosition,
displayName: "Учебные облигации для долгосрочных накоплений, выпуск 2030",
type: "BOND",
quantity: "10",
currentValue: { currency: "RUB", value: "9500" },
averagePurchasePrice: { currency: "RUB", value: "1000" },
absoluteReturn: { currency: "RUB", value: "-500" },
isin: "DEMO-BOND-2030",
ticker: null,
shareText: "7,6%",
returnText: "−500 ₽",
returnTone: "negative",
export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
kind: "bonds",
position: {
...syntheticPosition,
absoluteReturn: { currency: "RUB", value: "-500" },
averagePurchasePrice: { currency: "RUB", value: "1000" },
currentValue: { currency: "RUB", value: "9500" },
displayName: "Учебные облигации для долгосрочных накоплений, выпуск 2030",
isin: "DEMO-BOND-2030",
quantity: "10",
returnText: "−500 ₽",
returnTone: "negative",
shareText: "7,6%",
ticker: null,
type: "BOND",
},
},
},
parameters: {
docs: {
description: {
story:
name: "Облигация с отрицательным результатом",
parameters: {
docs: {
description: {
story:
"Для облигации используется идентификатор выпуска. Длинное имя и отрицательная сумма позволяют проверить компактное представление на узком экране.",
},
},
},
},
};
}
/* eslint-enable unicorn/no-null */);
export const Other: Story = {
name: "Прочий инструмент",
args: {
kind: "other",
position: {
...syntheticPosition,
displayName: "Учебный фонд",
type: "FUND",
isin: "DEMO-FUND",
ticker: null,
export const Other: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
kind: "other",
position: {
...syntheticPosition,
displayName: "Учебный фонд",
isin: "DEMO-FUND",
ticker: null,
type: "FUND",
},
},
},
parameters: {
docs: {
description: {
story:
name: "Прочий инструмент",
parameters: {
docs: {
description: {
story:
"Компактный вариант для остальных инструментов сохраняет количество, стоимость и пояснение результата без оформления акции или облигации.",
},
},
},
},
};
}
/* eslint-enable unicorn/no-null */);
export const MissingValuation: Story = {
name: "Оценка и идентификаторы отсутствуют",
args: {
position: {
...syntheticPosition,
displayName: "Учебная позиция без текущей оценки",
currentValue: null,
averagePurchasePrice: null,
absoluteReturn: null,
ticker: null,
shareText: null,
returnText: null,
returnTone: undefined,
},
},
parameters: {
docs: {
description: {
story:
"При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.",
export const MissingValuation: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
position: {
...syntheticPosition,
absoluteReturn: null,
averagePurchasePrice: null,
currentValue: null,
displayName: "Учебная позиция без текущей оценки",
returnText: null,
returnTone: undefined,
shareText: null,
ticker: null,
},
},
},
};
name: "Оценка и идентификаторы отсутствуют",
parameters: {
docs: {
description: {
story:
"При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.",
},
},
},
}
/* eslint-enable unicorn/no-null */);
@@ -1,13 +1,17 @@
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import classes from "../../AccountPage.module.css";
import type {
AccountPagePosition,
AccountPagePositionSection,
} from "../../AccountPage.vm";
import classes from "../../AccountPage.module.css";
import { PositionLogo } from "../PositionLogo.view";
type PositionCardProps = {
@@ -22,41 +26,37 @@ const PositionCard: FC<PositionCardProps> = (props) => {
const returnClass = position.returnTone
? classes[position.returnTone]
: undefined;
const returnText =
position.returnText && isExpanded
? position.returnText
: position.returnText
? t("portfolio.return", {
value: position.returnText,
})
: null;
let returnText = position.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 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
component={Link}
to={`/accounts/${encodeURIComponent(position.account.id)}`}
size="xs"
>
{position.account.name}
</Anchor>
) : null;
const accountLink = position.account
? (
<Anchor
component={Link}
size="xs"
to={`/accounts/${encodeURIComponent(position.account.id)}`}
>
{position.account.name}
</Anchor>
)
: undefined;
const secondaryText = [
position.averagePurchasePrice
? t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})
: null,
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null,
position.shareText
? t("portfolio.share", { value: position.shareText })
: null,
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null,
]
.filter(Boolean)
.join(" · ");
@@ -67,91 +67,93 @@ const PositionCard: FC<PositionCardProps> = (props) => {
<div className={classes.expandedMain}>
<PositionLogo
key={position.logoSrc ?? "no-logo"}
src={position.logoSrc}
label={position.displayName}
src={position.logoSrc}
to={instrumentTo}
/>
<Stack gap={2} miw={0} className={classes.expandedIdentity}>
{instrumentTo ? (
<Anchor component={Link} to={instrumentTo} c="inherit" fw={600}>
{position.displayName}
</Anchor>
) : (
<Text fw={600} lineClamp={1}>
{position.displayName}
</Text>
)}
<Stack className={classes.expandedIdentity} gap={2} miw={0}>
{instrumentTo
? (
<Anchor c="inherit" component={Link} fw={600} to={instrumentTo}>
{position.displayName}
</Anchor>
)
: (
<Text fw={600} lineClamp={1}>
{position.displayName}
</Text>
)}
{accountLink}
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
<Text c="dimmed" className={classes.expandedIdentifiers} size="xs">
{identifierText}
</Text>
</Stack>
<Stack gap={2} align="flex-end" className={classes.expandedValue}>
{position.currentValue ? (
<Stack align="flex-end" className={classes.expandedValue} gap={2}>
{!!(position.currentValue) && ((
<Text fw={700} size="lg">
{formatMoney(position.currentValue)}
</Text>
) : null}
{returnText ? (
<Text size="sm" fw={600} className={returnClass}>
))}
{!!(returnText) && ((
<Text className={returnClass} fw={600} size="sm">
{returnText}
</Text>
) : null}
))}
</Stack>
</div>
{secondaryText ? (
<Text c="dimmed" size="xs" className={classes.expandedMeta}>
{!!(secondaryText) && ((
<Text c="dimmed" className={classes.expandedMeta} size="xs">
{secondaryText}
</Text>
) : null}
))}
</Card>
);
return (
<Card className={classes.tile} withBorder>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group align="flex-start" justify="space-between" wrap="nowrap">
<Stack gap={2} miw={0}>
<Text fw={700} lineClamp={1}>
{position.displayName}
</Text>
{accountLink}
{position.isin ? (
<Text c="dimmed" size="xs" className={classes.mono}>
{!!(position.isin) && ((
<Text c="dimmed" className={classes.mono} size="xs">
{position.isin}
</Text>
) : null}
))}
<Text c="dimmed" size="xs">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
{position.currentValue ? (
<Stack align="flex-end" gap={2}>
{!!(position.currentValue) && ((
<Text fw={800} size="lg">
{formatMoney(position.currentValue)}
</Text>
) : null}
{returnText ? (
<Text size="sm" fw={600} className={returnClass}>
))}
{!!(returnText) && ((
<Text className={returnClass} fw={600} size="sm">
{returnText}
</Text>
) : null}
))}
</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>
) : null}
{position.shareText ? (
))}
{!!(position.shareText) && ((
<Text c="dimmed" size="xs">
{t("portfolio.share", { value: position.shareText })}
</Text>
) : null}
))}
</Group>
) : null}
))}
</Card>
);
};
@@ -1,13 +1,17 @@
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { Anchor, Card, Group, Stack, Text } from "@mantine/core";
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 classes from "../AccountPage.module.css";
import { PositionLogo } from "./PositionLogo.view";
type PositionGroupCardProps = {
@@ -22,88 +26,88 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
const instrumentTo = identity.instrumentTo;
return (
<Card className={classes.tile} withBorder>
<Group wrap="nowrap" gap="sm">
{kind !== "other" ? (
<Group gap="sm" wrap="nowrap">
{kind !== "other" && ((
<PositionLogo
key={identity.logoSrc ?? "no-logo"}
src={identity.logoSrc}
label={identity.displayName}
src={identity.logoSrc}
to={instrumentTo}
/>
) : null}
))}
<Stack gap={2} miw={0}>
{instrumentTo ? (
<Anchor component={Link} to={instrumentTo} c="inherit" fw={600}>
{identity.displayName}
</Anchor>
) : (
<Text fw={600}>{identity.displayName}</Text>
)}
{instrumentTo
? (
<Anchor c="inherit" component={Link} fw={600} to={instrumentTo}>
{identity.displayName}
</Anchor>
)
: (
<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}
className={classes.accountPositions}
aria-label={t("portfolio.byAccount")}
>
{group.positions.map((position) => (
{group.positions.map(position => (
<Stack
component="li"
key={position.account?.id}
gap={4}
className={classes.accountPosition}
component="li"
gap={4}
key={position.account?.id}
>
<Group justify="space-between" align="flex-start" gap="xs">
{position.account ? (
<Group align="flex-start" gap="xs" justify="space-between">
{!!(position.account) && ((
<Anchor
component={Link}
to={`/accounts/${encodeURIComponent(position.account.id)}`}
size="sm"
className={classes.accountPositionName}
component={Link}
size="sm"
to={`/accounts/${encodeURIComponent(position.account.id)}`}
>
{position.account.name}
</Anchor>
) : null}
<Text size="sm" fw={600}>
))}
<Text fw={600} size="sm">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Group>
<Group justify="space-between" gap="xs">
{position.currentValue ? (
<Group gap="xs" justify="space-between">
{!!(position.currentValue) && ((
<Text size="sm">{formatMoney(position.currentValue)}</Text>
) : null}
{position.returnText ? (
))}
{!!(position.returnText) && ((
<Text
size="sm"
className={
position.returnTone
? classes[position.returnTone]
: undefined
(position.returnTone) ? (classes[position.returnTone]) : undefined
}
size="sm"
>
{kind === "other"
? t("portfolio.return", { value: position.returnText })
: position.returnText}
</Text>
) : null}
))}
</Group>
<Group gap="xs">
{position.averagePurchasePrice ? (
{!!(position.averagePurchasePrice) && ((
<Text c="dimmed" size="xs">
{t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})}
</Text>
) : null}
{position.shareText ? (
))}
{!!(position.shareText) && ((
<Text c="dimmed" size="xs">
{t("portfolio.share", { value: position.shareText })}
</Text>
) : null}
))}
</Group>
</Stack>
))}
@@ -1,47 +1,52 @@
import { Anchor, Avatar } from "@mantine/core";
import type { FC } from "react";
import { Anchor, Avatar } from "@mantine/core";
import { useState } from "react";
import { Link } from "react-router";
type PositionLogoProps = {
src: string | null;
label: string;
src: null | string;
to?: string;
};
const PositionLogo: FC<PositionLogoProps> = (props) => {
const { src, label } = props;
const { label, src } = props;
const [failed, setFailed] = useState(false);
const logo =
src && !failed ? (
<Avatar
src={src}
alt={label}
size={40}
radius="md"
onError={() => setFailed(true)}
/>
) : (
<Avatar size={40} radius="md">
{(label.trim()[0] ?? "?").toUpperCase()}
</Avatar>
);
return props.to ? (
<Anchor
component={Link}
to={props.to}
aria-label={label}
c="inherit"
underline="never"
display="block"
style={{ borderRadius: "var(--mantine-radius-md)" }}
>
{logo}
</Anchor>
) : (
logo
);
const logo
= src && !failed
? (
<Avatar
alt={label}
onError={() => setFailed(true)}
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
);
};
export { PositionLogo };
@@ -1,6 +1,9 @@
import { SimpleGrid } from "@mantine/core";
import type { FC } from "react";
import { SimpleGrid } from "@mantine/core";
import type { AccountPagePositionSection } from "../AccountPage.vm";
import { PositionCard } from "./PositionCard/PositionCard.view";
import { PositionGroupCard } from "./PositionGroupCard.view";
@@ -10,24 +13,26 @@ type PositionSectionProps = {
const PositionSection: FC<PositionSectionProps> = (props) => {
const { section } = props;
const expanded = section.kind !== "other";
const isExpanded = section.kind !== "other";
return (
<SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}>
{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 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>
);
@@ -1,10 +1,13 @@
import type { FC } from "react";
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountPageRecentOperations } from "../AccountPage.vm";
type RecentOperationsProps = {
@@ -17,51 +20,49 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
return (
<Stack gap="sm">
{operations.state === "loaded" ? (
{(operations.state === "loaded") && ((
<>
{operations.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{!!(operations.lastSuccessfulAt) && ((
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(operations.lastSuccessfulAt),
})}
</Text>
) : null}
{operations.stale ? (
))}
{!!(operations.stale) && ((
<Text size="xs">{t("investment.stale")}</Text>
) : null}
{operations.refreshError ? (
))}
{!!(operations.refreshError) && ((
<Text role="status">{t("accounts.refreshError")}</Text>
) : null}
))}
</>
) : null}
{operations.state === "loading" ? (
))}
{(operations.state === "loading") && ((
<LoadingSkeleton label={t("ledger.recentLoading")}>
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={48} radius="sm" />
{["first", "second", "third"].map(placeholder => (
<Skeleton height={48} key={placeholder} radius="sm" />
))}
</LoadingSkeleton>
) : null}
{operations.state === "error" ? (
))}
{(operations.state === "error") && ((
<Alert color="red">{t("ledger.recentError")}</Alert>
) : null}
{operations.state === "empty" ? (
))}
{(operations.state === "empty") && ((
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
) : null}
{operations.state === "partial" ? (
))}
{(operations.state === "partial") && ((
<Text role="status">{t("investment.partial")}</Text>
) : null}
{operations.state === "loaded" || operations.state === "partial"
? operations.items.map((operation) => (
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
))
: null}
))}
{(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>