refactor: align frontend ownership boundaries

Close audit gaps across API drift, query policy, UI, and tests.
This commit is contained in:
2026-09-06 18:20:05 +03:00
parent 175727bc93
commit c97f62bd98
118 changed files with 2190 additions and 2254 deletions
+7 -32
View File
@@ -32,61 +32,36 @@
color: var(--mantine-color-red-6);
}
.stockCard {
.expandedCard {
padding: var(--mantine-spacing-md);
}
.stockMain {
.expandedMain {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--mantine-spacing-sm);
align-items: center;
}
.stockIdentity,
.stockValue {
.expandedIdentity,
.expandedValue {
min-width: 0;
}
.stockIdentifiers {
.expandedIdentifiers {
overflow-wrap: anywhere;
}
.stockValue {
.expandedValue {
white-space: nowrap;
}
.stockMeta,
.bondMeta {
.expandedMeta {
padding-top: var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-sm);
border-top: 1px solid var(--mantine-color-gray-2);
}
.bondCard {
padding: var(--mantine-spacing-md);
}
.bondMain {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--mantine-spacing-sm);
align-items: center;
}
.bondIdentity,
.bondValue {
min-width: 0;
}
.bondIdentifiers {
overflow-wrap: anywhere;
}
.bondValue {
white-space: nowrap;
}
.mono {
font-family: var(--mantine-font-family-monospace);
}
+16 -20
View File
@@ -1,28 +1,28 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { describe, expect, it, vi } from "vitest";
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import {
AccountPage,
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
} from "./AccountPage";
import { AccountPage } from "./AccountPage";
import type {
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "./AccountPage.vm";
import { toAccountPagePositionSections } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading.view";
function renderPage(ui: React.ReactNode) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter>{ui}</MemoryRouter>
</MantineProvider>
@@ -113,10 +113,6 @@ function emptyProps() {
}
describe("AccountPage states", () => {
afterEach(() => {
configureAuthClient(null);
cleanup();
});
it("renders loading and empty states", () => {
renderPage(<AccountPageLoading />);
expect(screen.getByRole("status")).toBeInTheDocument();
@@ -528,12 +524,12 @@ describe("AccountPage states", () => {
);
}),
);
const queryClient = new QueryClient();
const queryClient = createTestQueryClient();
renderAccountRoute(queryClient);
await screen.findByText("Вторая");
void queryClient.invalidateQueries({
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"],
queryKey: portfolioQueryOptions("account-1").queryKey,
});
expect(
@@ -563,12 +559,12 @@ describe("AccountPage states", () => {
});
}),
);
const queryClient = new QueryClient();
const queryClient = createTestQueryClient();
renderAccountRoute(queryClient);
await screen.findByText("Вторая");
void queryClient.invalidateQueries({
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"],
queryKey: portfolioQueryOptions("account-1").queryKey,
});
await waitFor(() => expect(requestCount).toBe(2));
+5 -13
View File
@@ -1,11 +1,9 @@
import type { FC } from "react";
import {
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
} from "./AccountPage.view";
import { useAccountPageViewModel } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading.view";
const AccountPage: FC = () => {
const viewModel = useAccountPageViewModel();
@@ -38,10 +36,4 @@ const AccountPage: FC = () => {
);
};
export {
AccountPage,
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
};
export { AccountPage };
-244
View File
@@ -1,244 +0,0 @@
import { Accordion, Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import { formatSnapshotTime } from "@/entities/portfolio";
import { LoadingSkeleton, StateView } from "@/shared/ui";
import classes from "./AccountPage.module.css";
import type {
AccountPageIncomeSummary,
AccountPagePositionSection,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "./AccountPage.vm";
import {
AccountHeading,
CashBalances,
IncomeSummary,
PortfolioRefreshState,
PortfolioTotals,
PositionSection,
RecentEvents,
RecentOperations,
} from "./components";
type AccountPageErrorProps = {
code?: string;
retry: () => void;
};
type AccountPageEmptyProps = {
account?: BrokerageAccount;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
};
type AccountPageLoadedProps = {
account?: BrokerageAccount;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
};
const AccountPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="35%" />
<Skeleton height={120} radius="md" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={52} radius="sm" />
))}
</LoadingSkeleton>
);
};
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code, retry } = props;
const { t } = useTranslation();
if (code === "NOT_FOUND")
return (
<StateView
error
title={t("portfolio.accountUnavailableTitle")}
text={t("portfolio.accountUnavailableText")}
action={
<Button component={Link} to="/accounts">
{t("portfolio.back")}
</Button>
}
/>
);
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("portfolio.connectionErrorTitle")}
text={t("portfolio.connectionErrorText")}
action={
<Button component={Link} to="/settings">
{t("accounts.openSettings")}
</Button>
}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
};
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
const {
account,
refreshing = false,
refreshError = false,
retry,
recentOperations,
recentEvents,
incomeSummary,
} = props;
const { t } = useTranslation();
return (
<Stack>
<AccountHeading account={account} />
<PortfolioRefreshState
refreshing={refreshing}
refreshError={refreshError}
retry={retry}
/>
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
<RecentOperations operations={recentOperations} />
<RecentEvents events={recentEvents} />
{incomeSummary ? <IncomeSummary {...incomeSummary} /> : null}
</Stack>
);
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const {
account,
portfolio,
positionSections,
refreshing = false,
refreshError = false,
retry,
recentOperations,
recentEvents,
incomeSummary,
} = props;
const { t } = useTranslation();
return (
<Stack className={classes.page} gap="lg">
<AccountHeading account={account} />
<PortfolioRefreshState
refreshing={refreshing}
refreshError={refreshError}
retry={retry}
/>
{portfolio.isStale ? (
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null}
<Text c="dimmed" size="sm">
{t("portfolio.updatedAt", {
date: formatSnapshotTime(portfolio.lastSuccessfulAt),
})}
</Text>
<PortfolioTotals totals={portfolio.totalsByCurrency} />
<Accordion
multiple
defaultValue={[
...positionSections.map((s) => s.kind),
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
]}
classNames={{
control: classes.accordionControl,
label: classes.accordionLabel,
chevron: classes.accordionChevron,
panel: classes.accordionPanel,
}}
>
{positionSections.map((section) => (
<Accordion.Item key={section.kind} value={section.kind}>
<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>
<CashBalances balances={portfolio.cashBalances} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentOperations">
<Accordion.Control>{t("ledger.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentOperations operations={recentOperations} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentEvents">
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentEvents events={recentEvents} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="incomeSummary">
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
<Accordion.Panel>
{incomeSummary ? <IncomeSummary {...incomeSummary} /> : null}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
};
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks";
if (kind === "bonds") return "portfolio.bonds";
return "portfolio.otherPositions";
}
export {
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
};
+19 -18
View File
@@ -1,12 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { useAccountPageViewModel } from "./AccountPage.vm";
@@ -63,21 +63,21 @@ function configureTestAuth() {
});
}
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<QueryClientProvider client={new QueryClient()}>
function createWrapper() {
const { wrapper: QueryWrapper } = createQueryWrapper();
const Wrapper: FC<PropsWithChildren> = (props) => (
<QueryWrapper>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1"]}>
<Routes>
<Route path="/accounts/:accountId" element={children} />
<Route path="/accounts/:accountId" element={props.children} />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>
</QueryWrapper>
);
};
return Wrapper;
}
function mockLoadedAccountPage() {
server.use(
@@ -130,13 +130,13 @@ function mockLoadedAccountPage() {
}
describe("useAccountPageViewModel", () => {
afterEach(() => configureAuthClient(null));
it("prepares grouped position sections with derived display data", async () => {
configureTestAuth();
mockLoadedAccountPage();
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.state).toBe("loaded"));
const loaded = result.current as Extract<
@@ -146,7 +146,6 @@ describe("useAccountPageViewModel", () => {
expect(loaded.positionSections).toMatchObject([
{
kind: "stocks",
wide: true,
positions: [
{
displayName: "Акция",
@@ -159,7 +158,6 @@ describe("useAccountPageViewModel", () => {
},
{
kind: "bonds",
wide: true,
positions: [
{
displayName: "Облигация",
@@ -172,7 +170,6 @@ describe("useAccountPageViewModel", () => {
},
{
kind: "other",
wide: false,
positions: [
{
displayName: "Фонд",
@@ -190,7 +187,9 @@ describe("useAccountPageViewModel", () => {
configureTestAuth();
mockLoadedAccountPage();
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() =>
expect(
@@ -233,7 +232,9 @@ describe("useAccountPageViewModel", () => {
),
);
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() =>
expect(result.current).toMatchObject({
+12 -18
View File
@@ -12,13 +12,9 @@ import {
} from "@/entities/brokerage-event";
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio";
import {
formatMoney,
formatShare,
instrumentLogoUrl,
portfolioErrorCode,
usePortfolio,
} from "@/entities/portfolio";
import { formatShare, usePortfolio } from "@/entities/portfolio";
import { apiErrorCode } from "@/shared/api";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
type PositionSectionKind = "stocks" | "bonds" | "other";
type AccountPageReturnTone = "positive" | "negative";
@@ -32,7 +28,6 @@ type AccountPagePosition = Portfolio["positions"][number] & {
type AccountPagePositionSection = {
kind: PositionSectionKind;
wide: boolean;
positions: AccountPagePosition[];
};
@@ -108,7 +103,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
if (portfolio.isError)
return {
state: "error" as const,
code: portfolioErrorCode(portfolio.error),
code: apiErrorCode(portfolio.error),
retry: () => portfolio.refetch(),
};
return { state: "loading" as const };
@@ -188,9 +183,9 @@ function toAccountPagePositionSections(
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
);
}
const stocks = toSection("stocks", true, portfolio, denominators);
const bonds = toSection("bonds", true, portfolio, denominators);
const other = toSection("other", false, portfolio, denominators);
const stocks = toSection("stocks", portfolio, denominators);
const bonds = toSection("bonds", portfolio, denominators);
const other = toSection("other", portfolio, denominators);
return [stocks, bonds, other].filter(
(section) => section.positions.length > 0,
);
@@ -198,28 +193,27 @@ function toAccountPagePositionSections(
function toSection(
kind: PositionSectionKind,
wide: boolean,
portfolio: Portfolio,
denominators: Map<string, number>,
): AccountPagePositionSection {
return {
kind,
wide,
positions: portfolio.positions
.filter((position) => {
if (kind === "stocks") return position.type === "STOCK";
if (kind === "bonds") return position.type === "BOND";
return position.type !== "STOCK" && position.type !== "BOND";
})
.map((position) => toPosition(position, denominators, wide)),
.map((position) => toPosition(position, denominators, kind)),
};
}
function toPosition(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
wide: boolean,
kind: PositionSectionKind,
): AccountPagePosition {
const expanded = kind !== "other";
const returnValue = position.absoluteReturn
? Number(position.absoluteReturn.value)
: null;
@@ -238,10 +232,10 @@ function toPosition(
})
: null,
returnText: position.absoluteReturn
? `${wide && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
? `${expanded && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
: null,
returnTone,
logoSrc: wide ? instrumentLogoUrl(position.logoName) : null,
logoSrc: expanded ? instrumentLogoUrl(position.logoName) : null,
};
}
@@ -0,0 +1,48 @@
import { Stack } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { StateView } from "@/shared/ui";
import type {
AccountPageIncomeSummary,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../AccountPage.vm";
import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
import { RecentEvents } from "./RecentEvents.view";
import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = {
account?: BrokerageAccount;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
};
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<AccountHeading account={props.account} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
refreshError={props.refreshError ?? false}
retry={props.retry}
/>
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
<RecentOperations operations={props.recentOperations} />
<RecentEvents events={props.recentEvents} />
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
</Stack>
);
};
export { AccountPageEmpty };
@@ -0,0 +1,57 @@
import { Button } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { StateView } from "@/shared/ui";
type AccountPageErrorProps = { code?: string; retry: () => void };
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code, retry } = props;
const { t } = useTranslation();
if (code === "NOT_FOUND")
return (
<StateView
error
title={t("portfolio.accountUnavailableTitle")}
text={t("portfolio.accountUnavailableText")}
action={
<Button component={Link} to="/accounts">
{t("portfolio.back")}
</Button>
}
/>
);
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("portfolio.connectionErrorTitle")}
text={t("portfolio.connectionErrorText")}
action={
<Button component={Link} to="/settings">
{t("accounts.openSettings")}
</Button>
}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
};
export { AccountPageError };
@@ -0,0 +1,117 @@
import { Accordion, Alert, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import { formatSnapshotTime } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type {
AccountPageIncomeSummary,
AccountPagePositionSection,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../AccountPage.vm";
import { AccountHeading } from "./AccountHeading.view";
import { CashBalances } from "./CashBalances.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
import { PortfolioTotals } from "./PortfolioTotals.view";
import { PositionSection } from "./PositionSection.view";
import { RecentEvents } from "./RecentEvents.view";
import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = {
account?: BrokerageAccount;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation();
return (
<Stack className={classes.page} gap="lg">
<AccountHeading account={props.account} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
refreshError={props.refreshError ?? false}
retry={props.retry}
/>
{props.portfolio.isStale ? (
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null}
<Text c="dimmed" size="sm">
{t("portfolio.updatedAt", {
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
})}
</Text>
<PortfolioTotals totals={props.portfolio.totalsByCurrency} />
<Accordion
multiple
defaultValue={[
...props.positionSections.map((section) => section.kind),
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
]}
classNames={{
control: classes.accordionControl,
label: classes.accordionLabel,
chevron: classes.accordionChevron,
panel: classes.accordionPanel,
}}
>
{props.positionSections.map((section) => (
<Accordion.Item key={section.kind} value={section.kind}>
<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>
<CashBalances balances={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>
<RecentEvents events={props.recentEvents} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="incomeSummary">
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
<Accordion.Panel>
{props.incomeSummary ? (
<IncomeSummary {...props.incomeSummary} />
) : null}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
};
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks";
if (kind === "bonds") return "portfolio.bonds";
return "portfolio.otherPositions";
}
export { AccountPageLoaded };
@@ -0,0 +1,19 @@
import { Skeleton } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
const AccountPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="35%" />
<Skeleton height={120} radius="md" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={52} radius="sm" />
))}
</LoadingSkeleton>
);
};
export { AccountPageLoading };
@@ -1,7 +1,7 @@
import { Paper, Text } from "@mantine/core";
import type { FC } from "react";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
type CashBalancesProps = {
@@ -3,7 +3,7 @@ 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 } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import { StateView } from "@/shared/ui";
type IncomeSummaryProps =
@@ -1,7 +1,7 @@
import { Paper, SimpleGrid, Text } from "@mantine/core";
import type { FC } from "react";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
type PortfolioTotalsProps = {
@@ -1,44 +1,28 @@
import { Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type { AccountPagePosition } from "../AccountPage.vm";
import type {
AccountPagePosition,
AccountPagePositionSection,
} from "../AccountPage.vm";
import { PositionLogo } from "./PositionLogo.view";
type PositionCardProps = {
kind: AccountPagePositionSection["kind"];
position: AccountPagePosition;
wide?: boolean;
variant?: "stock" | "bond";
};
const PositionCard: FC<PositionCardProps> = (props) => {
const { position, wide = false, variant } = props;
const { kind, position } = props;
const { t } = useTranslation();
const isExpanded = variant === "stock" || variant === "bond";
const ec =
variant === "bond"
? {
card: classes.bondCard,
main: classes.bondMain,
identity: classes.bondIdentity,
identifiers: classes.bondIdentifiers,
value: classes.bondValue,
meta: classes.bondMeta,
}
: {
card: classes.stockCard,
main: classes.stockMain,
identity: classes.stockIdentity,
identifiers: classes.stockIdentifiers,
value: classes.stockValue,
meta: classes.stockMeta,
};
const isExpanded = kind !== "other";
const returnClass = position.returnTone
? classes[position.returnTone]
: undefined;
const returnText =
position.returnText && wide
position.returnText && isExpanded
? position.returnText
: position.returnText
? t("portfolio.return", {
@@ -46,7 +30,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
})
: null;
const identifier =
variant === "stock" ? (position.ticker ?? position.isin) : position.isin;
kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
const identifierText = [
t("portfolio.quantity", { value: position.quantity }),
identifier,
@@ -68,22 +52,22 @@ const PositionCard: FC<PositionCardProps> = (props) => {
if (isExpanded)
return (
<Card className={`${classes.tile} ${ec.card}`} withBorder>
<div className={ec.main}>
<Card className={`${classes.tile} ${classes.expandedCard}`} withBorder>
<div className={classes.expandedMain}>
<PositionLogo
key={position.logoSrc ?? "no-logo"}
src={position.logoSrc}
label={position.displayName}
/>
<Stack gap={2} miw={0} className={ec.identity}>
<Stack gap={2} miw={0} className={classes.expandedIdentity}>
<Text fw={600} lineClamp={1}>
{position.displayName}
</Text>
<Text c="dimmed" size="xs" className={ec.identifiers}>
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
{identifierText}
</Text>
</Stack>
<Stack gap={2} align="flex-end" className={ec.value}>
<Stack gap={2} align="flex-end" className={classes.expandedValue}>
{position.currentValue ? (
<Text fw={700} size="lg">
{formatMoney(position.currentValue)}
@@ -97,7 +81,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
</Stack>
</div>
{secondaryText ? (
<Text c="dimmed" size="xs" className={ec.meta}>
<Text c="dimmed" size="xs" className={classes.expandedMeta}>
{secondaryText}
</Text>
) : null}
@@ -106,13 +90,6 @@ const PositionCard: FC<PositionCardProps> = (props) => {
return (
<Card className={classes.tile} withBorder>
<Group justify="space-between" align="flex-start" wrap="nowrap">
{wide ? (
<PositionLogo
key={position.logoSrc ?? "no-logo"}
src={position.logoSrc}
label={position.displayName}
/>
) : null}
<Stack gap={2} miw={0}>
<Text fw={700} lineClamp={1}>
{position.displayName}
@@ -9,21 +9,15 @@ type PositionSectionProps = {
const PositionSection: FC<PositionSectionProps> = (props) => {
const { section } = props;
const expanded = section.kind !== "other";
return (
<SimpleGrid cols={section.wide ? 1 : { base: 1, sm: 2, md: 3 }}>
<SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}>
{section.positions.map((position) => (
<PositionCard
kind={section.kind}
key={`${position.figi}-${position.displayName}-${position.quantity}`}
position={position}
wide={section.wide}
variant={
section.kind === "stocks"
? "stock"
: section.kind === "bonds"
? "bond"
: undefined
}
/>
))}
</SimpleGrid>
@@ -3,7 +3,7 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "../AccountPage.module.css";
import type { AccountPageRecentEvents } from "../AccountPage.vm";
-8
View File
@@ -1,8 +0,0 @@
export { AccountHeading } from "./AccountHeading.view";
export { CashBalances } from "./CashBalances.view";
export { IncomeSummary } from "./IncomeSummary.view";
export { PortfolioRefreshState } from "./PortfolioRefreshState.view";
export { PortfolioTotals } from "./PortfolioTotals.view";
export { PositionSection } from "./PositionSection.view";
export { RecentEvents } from "./RecentEvents.view";
export { RecentOperations } from "./RecentOperations.view";