refactor: enforce account page view model boundary
This commit is contained in:
@@ -13,6 +13,11 @@ import {
|
||||
AccountPageLoaded,
|
||||
AccountPageLoading,
|
||||
} from "./AccountPage";
|
||||
import type {
|
||||
AccountPageRecentEvents,
|
||||
AccountPageRecentOperations,
|
||||
} from "./AccountPage.vm";
|
||||
import { toAccountPagePositionSections } from "./AccountPage.vm";
|
||||
|
||||
vi.mock("@/features/auth", () => ({
|
||||
useAuth: () => ({ accessToken: "test-token" }),
|
||||
@@ -73,13 +78,39 @@ const portfolio: Portfolio = {
|
||||
cashBalances: [{ currency: "USD", value: "0.5" }],
|
||||
};
|
||||
|
||||
const emptyRecentOperations: AccountPageRecentOperations = {
|
||||
state: "empty",
|
||||
to: "/accounts/account-1/operations",
|
||||
};
|
||||
|
||||
const emptyRecentEvents: AccountPageRecentEvents = {
|
||||
state: "empty",
|
||||
to: "/accounts/account-1/events",
|
||||
};
|
||||
|
||||
function loadedProps(nextPortfolio: Portfolio = portfolio) {
|
||||
return {
|
||||
portfolio: nextPortfolio,
|
||||
positionSections: toAccountPagePositionSections(nextPortfolio),
|
||||
recentOperations: emptyRecentOperations,
|
||||
recentEvents: emptyRecentEvents,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyProps() {
|
||||
return {
|
||||
recentOperations: emptyRecentOperations,
|
||||
recentEvents: emptyRecentEvents,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AccountPage states", () => {
|
||||
afterEach(cleanup);
|
||||
it("renders loading and empty states", () => {
|
||||
renderPage(<AccountPageLoading />);
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
|
||||
renderPage(<AccountPageEmpty />);
|
||||
renderPage(<AccountPageEmpty {...emptyProps()} />);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Портфель пока пуст" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -104,7 +135,7 @@ describe("AccountPage states", () => {
|
||||
});
|
||||
|
||||
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
|
||||
renderPage(<AccountPageLoaded portfolio={portfolio} />);
|
||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Портфель" }),
|
||||
@@ -138,7 +169,7 @@ describe("AccountPage states", () => {
|
||||
it("renders stock cards with logo fallback and signed unlabeled return", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
portfolio={{
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [
|
||||
{
|
||||
@@ -164,7 +195,7 @@ describe("AccountPage states", () => {
|
||||
logoName: null,
|
||||
},
|
||||
],
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("А")).toBeInTheDocument();
|
||||
@@ -177,7 +208,7 @@ describe("AccountPage states", () => {
|
||||
it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
portfolio={{
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [
|
||||
{
|
||||
@@ -203,7 +234,7 @@ describe("AccountPage states", () => {
|
||||
logoName: null,
|
||||
},
|
||||
],
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute(
|
||||
@@ -222,10 +253,10 @@ describe("AccountPage states", () => {
|
||||
it("renders the bonds section as a single-column grid on any screen width", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
portfolio={{
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [portfolio.positions[1]],
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const singleColumnGrids = Array.from(document.querySelectorAll("style"))
|
||||
@@ -242,7 +273,7 @@ describe("AccountPage states", () => {
|
||||
it("keeps other-position cards compact with labeled return and no logo", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
portfolio={{
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [
|
||||
{
|
||||
@@ -257,7 +288,7 @@ describe("AccountPage states", () => {
|
||||
logoName: "fond.png",
|
||||
},
|
||||
],
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
|
||||
@@ -267,7 +298,7 @@ describe("AccountPage states", () => {
|
||||
it("uses the cached account title when available", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
portfolio={portfolio}
|
||||
{...loadedProps()}
|
||||
account={{
|
||||
id: "account-1",
|
||||
name: "Основной",
|
||||
@@ -281,47 +312,37 @@ describe("AccountPage states", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the portfolio visible when recent events fail", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||
),
|
||||
);
|
||||
it("keeps the portfolio visible when recent operations fail", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
accountId="account-1"
|
||||
accessToken="token"
|
||||
portfolio={portfolio}
|
||||
{...loadedProps()}
|
||||
recentOperations={{
|
||||
state: "error",
|
||||
to: "/accounts/account-1/operations",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("Не удалось загрузить последние события."),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Не удалось загрузить последние события."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the portfolio visible when upcoming events fail", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }),
|
||||
),
|
||||
);
|
||||
it("keeps the portfolio visible when upcoming events fail", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
accountId="account-1"
|
||||
accessToken="token"
|
||||
portfolio={portfolio}
|
||||
{...loadedProps()}
|
||||
recentEvents={{
|
||||
state: "error",
|
||||
to: "/accounts/account-1/events",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("Не удалось загрузить будущие события."),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Не удалось загрузить будущие события."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,22 +14,23 @@ export function AccountPage() {
|
||||
return (
|
||||
<AccountPageEmpty
|
||||
account={viewModel.account}
|
||||
accountId={viewModel.accountId}
|
||||
accessToken={viewModel.accessToken}
|
||||
refreshing={viewModel.refreshing}
|
||||
refreshError={viewModel.refreshError}
|
||||
retry={viewModel.retry}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
recentEvents={viewModel.recentEvents}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<AccountPageLoaded
|
||||
account={viewModel.account}
|
||||
accountId={viewModel.accountId}
|
||||
accessToken={viewModel.accessToken}
|
||||
portfolio={viewModel.portfolio}
|
||||
positionSections={viewModel.positionSections}
|
||||
refreshing={viewModel.refreshing}
|
||||
refreshError={viewModel.refreshError}
|
||||
retry={viewModel.retry}
|
||||
recentOperations={viewModel.recentOperations}
|
||||
recentEvents={viewModel.recentEvents}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,26 @@
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { useBrokerageOperations } from "@/entities/broker-operation";
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
import { accountTypeKey } from "@/entities/brokerage-account";
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import {
|
||||
formatMoney,
|
||||
formatShare,
|
||||
formatSnapshotTime,
|
||||
instrumentLogoUrl,
|
||||
} from "@/entities/portfolio";
|
||||
import { formatSnapshotTime } from "@/entities/portfolio";
|
||||
import { StateView } from "@/shared/ui";
|
||||
import classes from "./AccountPage.module.css";
|
||||
import type {
|
||||
AccountPagePositionSection,
|
||||
AccountPageRecentEvents,
|
||||
AccountPageRecentOperations,
|
||||
} from "./AccountPage.vm";
|
||||
import {
|
||||
AccountHeading,
|
||||
CashBalances,
|
||||
PortfolioRefreshState,
|
||||
PortfolioTotals,
|
||||
PositionSection,
|
||||
RecentEvents,
|
||||
RecentOperations,
|
||||
Section,
|
||||
} from "./components";
|
||||
|
||||
export function AccountPageLoading() {
|
||||
const { t } = useTranslation();
|
||||
@@ -41,6 +32,7 @@ export function AccountPageLoading() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountPageError({
|
||||
code,
|
||||
retry,
|
||||
@@ -93,20 +85,21 @@ export function AccountPageError({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountPageEmpty({
|
||||
account,
|
||||
accountId,
|
||||
accessToken = null,
|
||||
refreshing = false,
|
||||
refreshError = false,
|
||||
retry,
|
||||
recentOperations,
|
||||
recentEvents,
|
||||
}: {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
accessToken?: string | null;
|
||||
refreshing?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -121,43 +114,32 @@ export function AccountPageEmpty({
|
||||
title={t("portfolio.emptyTitle")}
|
||||
text={t("portfolio.emptyText")}
|
||||
/>
|
||||
<RecentOperations accountId={accountId} accessToken={accessToken} />
|
||||
<RecentEvents accountId={accountId} accessToken={accessToken} />
|
||||
<RecentOperations operations={recentOperations} />
|
||||
<RecentEvents events={recentEvents} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountPageLoaded({
|
||||
account,
|
||||
accountId,
|
||||
accessToken,
|
||||
portfolio,
|
||||
positionSections,
|
||||
refreshing = false,
|
||||
refreshError = false,
|
||||
retry,
|
||||
recentOperations,
|
||||
recentEvents,
|
||||
}: {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
accessToken?: string | null;
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
refreshing?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const stocks = portfolio.positions.filter((p) => p.type === "STOCK");
|
||||
const bonds = portfolio.positions.filter((p) => p.type === "BOND");
|
||||
const other = portfolio.positions.filter(
|
||||
(p) => p.type !== "STOCK" && p.type !== "BOND",
|
||||
);
|
||||
const denominators = new Map<string, number>();
|
||||
for (const position of portfolio.positions) {
|
||||
if (!position.currentValue) continue;
|
||||
const currency = position.currentValue.currency;
|
||||
denominators.set(
|
||||
currency,
|
||||
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack className={classes.page} gap="lg">
|
||||
<AccountHeading account={account} />
|
||||
@@ -174,336 +156,25 @@ export function AccountPageLoaded({
|
||||
date: formatSnapshotTime(portfolio.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
|
||||
{portfolio.totalsByCurrency.map((total) => (
|
||||
<Paper className={classes.total} key={total.currency} withBorder>
|
||||
<Text c="dimmed" size="xs">
|
||||
{total.currency}
|
||||
</Text>
|
||||
<Text fw={800} size="xl">
|
||||
{formatMoney(total)}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{stocks.length ? (
|
||||
<PortfolioTotals totals={portfolio.totalsByCurrency} />
|
||||
{positionSections.map((section) => (
|
||||
<PositionSection
|
||||
title={t("portfolio.stocks")}
|
||||
positions={stocks}
|
||||
denominators={denominators}
|
||||
wide
|
||||
key={section.kind}
|
||||
title={t(positionSectionTitleKey(section.kind))}
|
||||
section={section}
|
||||
/>
|
||||
) : null}
|
||||
{bonds.length ? (
|
||||
<PositionSection
|
||||
title={t("portfolio.bonds")}
|
||||
positions={bonds}
|
||||
denominators={denominators}
|
||||
wide
|
||||
/>
|
||||
) : null}
|
||||
{other.length ? (
|
||||
<PositionSection
|
||||
title={t("portfolio.otherPositions")}
|
||||
positions={other}
|
||||
denominators={denominators}
|
||||
/>
|
||||
) : null}
|
||||
))}
|
||||
<Section title={t("portfolio.cashBalances")}>
|
||||
{portfolio.cashBalances.map((balance) => (
|
||||
<Paper className={classes.cash} key={balance.currency} withBorder>
|
||||
<Text>{balance.currency}</Text>
|
||||
<Text fw={700}>{formatMoney(balance)}</Text>
|
||||
</Paper>
|
||||
))}
|
||||
<CashBalances balances={portfolio.cashBalances} />
|
||||
</Section>
|
||||
<RecentOperations
|
||||
accountId={accountId}
|
||||
accessToken={accessToken ?? null}
|
||||
/>
|
||||
<RecentEvents accountId={accountId} accessToken={accessToken ?? null} />
|
||||
<RecentOperations operations={recentOperations} />
|
||||
<RecentEvents events={recentEvents} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
function PortfolioRefreshState({
|
||||
refreshing,
|
||||
refreshError,
|
||||
retry,
|
||||
}: {
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!refreshing && !refreshError) return null;
|
||||
return (
|
||||
<>
|
||||
{refreshing ? (
|
||||
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
|
||||
<Loader size="sm" />
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("portfolio.refreshing")}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{refreshError ? (
|
||||
<Alert color="orange">
|
||||
<Stack gap="sm">
|
||||
<Text>{t("portfolio.refreshError")}</Text>
|
||||
<Button onClick={retry} variant="light">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
function RecentOperations({
|
||||
accountId,
|
||||
accessToken,
|
||||
}: {
|
||||
accountId: string | undefined;
|
||||
accessToken: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const operations = useBrokerageOperations(accountId, accessToken, {
|
||||
limit: 5,
|
||||
});
|
||||
return (
|
||||
<Section title={t("ledger.recentTitle")}>
|
||||
{operations.isLoading ? (
|
||||
<Text c="dimmed">{t("ledger.recentLoading")}</Text>
|
||||
) : null}
|
||||
{operations.isError ? (
|
||||
<Alert color="red">{t("ledger.recentError")}</Alert>
|
||||
) : null}
|
||||
{operations.data?.items.length === 0 ? (
|
||||
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
|
||||
) : null}
|
||||
{operations.data?.items.map((operation) => (
|
||||
<Paper className={classes.cash} key={operation.id} withBorder>
|
||||
<Text>{operation.description || operation.type}</Text>
|
||||
<Text fw={700}>
|
||||
{operation.payment
|
||||
? formatMoney(operation.payment)
|
||||
: t("ledger.noMoney")}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/accounts/${accountId}/operations`}
|
||||
variant="subtle"
|
||||
>
|
||||
{t("ledger.open")}
|
||||
</Button>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
function RecentEvents({
|
||||
accountId,
|
||||
accessToken,
|
||||
}: {
|
||||
accountId: string | undefined;
|
||||
accessToken: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const events = useBrokerageEvents(accountId, accessToken);
|
||||
return (
|
||||
<Section title={t("events.recentTitle")}>
|
||||
{events.isLoading ? (
|
||||
<Text c="dimmed">{t("events.recentLoading")}</Text>
|
||||
) : null}
|
||||
{events.isError ? (
|
||||
<Alert color="red">{t("events.recentError")}</Alert>
|
||||
) : null}
|
||||
{events.data?.events.length === 0 ? (
|
||||
<Text c="dimmed">{t("events.recentEmpty")}</Text>
|
||||
) : null}
|
||||
{events.data?.events.slice(0, 5).map((event) => (
|
||||
<RecentEvent
|
||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||
event={event}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/accounts/${accountId}/events`}
|
||||
variant="subtle"
|
||||
>
|
||||
{t("events.open")}
|
||||
</Button>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
function RecentEvent({ event }: { event: BrokerageEvent }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Paper className={classes.cash} withBorder>
|
||||
<Text>{`${t(`events.type.${event.type}`)} · ${event.displayName}`}</Text>
|
||||
<Text fw={700}>
|
||||
{event.amount && event.currency
|
||||
? formatMoney({ currency: event.currency, value: event.amount })
|
||||
: t("events.noAmount")}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
function PositionSection({
|
||||
title,
|
||||
positions,
|
||||
denominators,
|
||||
wide = false,
|
||||
}: {
|
||||
title: string;
|
||||
positions: Portfolio["positions"];
|
||||
denominators: Map<string, number>;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Section title={title}>
|
||||
<SimpleGrid cols={wide ? 1 : { base: 1, sm: 2, md: 3 }}>
|
||||
{positions.map((position) => (
|
||||
<PositionCard
|
||||
key={`${position.figi}-${position.displayName}-${position.quantity}`}
|
||||
position={position}
|
||||
denominators={denominators}
|
||||
wide={wide}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
function PositionCard({
|
||||
position,
|
||||
denominators,
|
||||
wide = false,
|
||||
}: {
|
||||
position: Portfolio["positions"][number];
|
||||
denominators: Map<string, number>;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const share = position.currentValue?.currency
|
||||
? formatShare(position.currentValue, {
|
||||
currency: position.currentValue.currency,
|
||||
value: String(denominators.get(position.currentValue.currency) ?? 0),
|
||||
})
|
||||
: null;
|
||||
const returnClass = position.absoluteReturn
|
||||
? Number(position.absoluteReturn.value) > 0
|
||||
? classes.positive
|
||||
: Number(position.absoluteReturn.value) < 0
|
||||
? classes.negative
|
||||
: undefined
|
||||
: undefined;
|
||||
const returnText = position.absoluteReturn
|
||||
? wide
|
||||
? `${Number(position.absoluteReturn.value) > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
|
||||
: t("portfolio.return", {
|
||||
value: formatMoney(position.absoluteReturn),
|
||||
})
|
||||
: null;
|
||||
const logoSrc = wide ? instrumentLogoUrl(position.logoName) : null;
|
||||
return (
|
||||
<Card className={classes.tile} withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
{wide ? (
|
||||
<PositionLogo
|
||||
key={logoSrc ?? "no-logo"}
|
||||
src={logoSrc}
|
||||
label={position.displayName}
|
||||
/>
|
||||
) : null}
|
||||
<Stack gap={2} miw={0}>
|
||||
<Text fw={700} lineClamp={1}>
|
||||
{position.displayName}
|
||||
</Text>
|
||||
{position.isin ? (
|
||||
<Text c="dimmed" size="xs" className={classes.mono}>
|
||||
{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 ? (
|
||||
<Text fw={800} size="lg">
|
||||
{formatMoney(position.currentValue)}
|
||||
</Text>
|
||||
) : null}
|
||||
{returnText ? (
|
||||
<Text size="sm" fw={600} className={returnClass}>
|
||||
{returnText}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
{position.averagePurchasePrice || share ? (
|
||||
<Group justify="space-between" mt="xs">
|
||||
{position.averagePurchasePrice ? (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.avgPrice", {
|
||||
value: formatMoney(position.averagePurchasePrice),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{share ? (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.share", { value: share })}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
function PositionLogo({ src, label }: { src: string | null; label: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (src && !failed)
|
||||
return (
|
||||
<Avatar
|
||||
src={src}
|
||||
alt={label}
|
||||
size={40}
|
||||
radius="md"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Avatar size={40} radius="md">
|
||||
{(label.trim()[0] ?? "?").toUpperCase()}
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
function AccountHeading({ account }: { account?: BrokerageAccount }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
|
||||
{account ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(accountTypeKey(account.type))}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Title order={2}>{title}</Title>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
|
||||
if (kind === "stocks") return "portfolio.stocks";
|
||||
if (kind === "bonds") return "portfolio.bonds";
|
||||
return "portfolio.otherPositions";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
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 { ReactNode } from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { server } from "@/test/server";
|
||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
||||
|
||||
vi.mock("@/features/auth", () => ({
|
||||
useAuth: () => ({ accessToken: "test-token" }),
|
||||
}));
|
||||
|
||||
const portfolio: Portfolio = {
|
||||
lastSuccessfulAt: "2026-08-13T10:00:00Z",
|
||||
isStale: true,
|
||||
totalsByCurrency: [{ currency: "RUB", value: "150" }],
|
||||
positions: [
|
||||
{
|
||||
figi: "FIGI-STOCK",
|
||||
displayName: "Акция",
|
||||
type: "STOCK",
|
||||
quantity: "2",
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
averagePurchasePrice: null,
|
||||
absoluteReturn: { currency: "RUB", value: "10" },
|
||||
isin: null,
|
||||
logoName: "akciya.png",
|
||||
},
|
||||
{
|
||||
figi: "FIGI-BOND",
|
||||
displayName: "Облигация",
|
||||
type: "BOND",
|
||||
quantity: "1",
|
||||
currentValue: { currency: "RUB", value: "50" },
|
||||
averagePurchasePrice: { currency: "RUB", value: "45" },
|
||||
absoluteReturn: { currency: "RUB", value: "-5" },
|
||||
isin: "RU000A0JX0J2",
|
||||
logoName: null,
|
||||
},
|
||||
{
|
||||
figi: "FIGI-FUND",
|
||||
displayName: "Фонд",
|
||||
type: "FUND",
|
||||
quantity: "3",
|
||||
currentValue: null,
|
||||
averagePurchasePrice: null,
|
||||
absoluteReturn: null,
|
||||
isin: null,
|
||||
logoName: "fond.png",
|
||||
},
|
||||
],
|
||||
cashBalances: [{ currency: "USD", value: "1" }],
|
||||
};
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/accounts/account-1"]}>
|
||||
<Routes>
|
||||
<Route path="/accounts/:accountId" element={children} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function mockLoadedAccountPage() {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(portfolio),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
items: [
|
||||
{
|
||||
id: "operation-1",
|
||||
occurredAt: "2026-08-13T10:00:00Z",
|
||||
type: "Buy",
|
||||
category: "trade",
|
||||
description: "Покупка акции",
|
||||
figi: "FIGI-STOCK",
|
||||
instrumentUid: null,
|
||||
ticker: "STK",
|
||||
instrumentType: "share",
|
||||
payment: { currency: "RUB", value: "-100" },
|
||||
quantity: "1",
|
||||
price: null,
|
||||
accruedInterest: null,
|
||||
commission: null,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
observedAt: "2026-08-13T10:00:00Z",
|
||||
}),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json({
|
||||
accountId: "account-1",
|
||||
calculatedAt: "2026-08-13T10:00:00Z",
|
||||
events: Array.from({ length: 6 }, (_, index) => ({
|
||||
type: "coupon",
|
||||
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
|
||||
dateKind: "event",
|
||||
figi: `FIGI-EVENT-${index}`,
|
||||
instrumentUid: null,
|
||||
ticker: `BND${index}`,
|
||||
displayName: `Купон ${index}`,
|
||||
currency: "RUB",
|
||||
amount: String(index + 1),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("useAccountPageViewModel", () => {
|
||||
it("prepares grouped position sections with derived display data", async () => {
|
||||
mockLoadedAccountPage();
|
||||
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.state).toBe("loaded"));
|
||||
const loaded = result.current as Extract<
|
||||
typeof result.current,
|
||||
{ state: "loaded" }
|
||||
>;
|
||||
expect(loaded.positionSections).toMatchObject([
|
||||
{
|
||||
kind: "stocks",
|
||||
wide: true,
|
||||
positions: [
|
||||
{
|
||||
displayName: "Акция",
|
||||
shareText: "66,7 %",
|
||||
returnText: "+10 ₽",
|
||||
returnTone: "positive",
|
||||
logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "bonds",
|
||||
wide: true,
|
||||
positions: [
|
||||
{
|
||||
displayName: "Облигация",
|
||||
shareText: "33,3 %",
|
||||
returnText: "-5 ₽",
|
||||
returnTone: "negative",
|
||||
logoSrc: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "other",
|
||||
wide: false,
|
||||
positions: [
|
||||
{
|
||||
displayName: "Фонд",
|
||||
shareText: null,
|
||||
returnText: null,
|
||||
returnTone: undefined,
|
||||
logoSrc: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("prepares recent operations and events state for presentation views", async () => {
|
||||
mockLoadedAccountPage();
|
||||
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(result.current as { recentEvents?: { state: string } }).recentEvents,
|
||||
)?.toMatchObject({ state: "loaded" }),
|
||||
);
|
||||
const loaded = result.current as Extract<
|
||||
typeof result.current,
|
||||
{ state: "loaded" }
|
||||
>;
|
||||
expect(loaded.recentOperations).toMatchObject({
|
||||
state: "loaded",
|
||||
to: "/accounts/account-1/operations",
|
||||
items: [{ id: "operation-1", description: "Покупка акции" }],
|
||||
});
|
||||
expect(loaded.recentEvents).toMatchObject({
|
||||
state: "loaded",
|
||||
to: "/accounts/account-1/events",
|
||||
items: [
|
||||
{ ticker: "BND0" },
|
||||
{ ticker: "BND1" },
|
||||
{ ticker: "BND2" },
|
||||
{ ticker: "BND3" },
|
||||
{ ticker: "BND4" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps recent errors isolated in the ViewModel presentation state", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json(portfolio),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||
),
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
state: "loaded",
|
||||
recentOperations: { state: "error" },
|
||||
recentEvents: { state: "error" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,102 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router";
|
||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||
import { useBrokerageOperations } from "@/entities/broker-operation";
|
||||
import {
|
||||
type BrokerageAccount,
|
||||
brokerageAccountsQueryKey,
|
||||
} from "@/entities/brokerage-account";
|
||||
import { portfolioErrorCode, usePortfolio } from "@/entities/portfolio";
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import {
|
||||
formatMoney,
|
||||
formatShare,
|
||||
instrumentLogoUrl,
|
||||
portfolioErrorCode,
|
||||
usePortfolio,
|
||||
} from "@/entities/portfolio";
|
||||
import { useAuth } from "@/features/auth";
|
||||
|
||||
export function useAccountPageViewModel() {
|
||||
type PositionSectionKind = "stocks" | "bonds" | "other";
|
||||
type AccountPageReturnTone = "positive" | "negative";
|
||||
|
||||
export type AccountPagePosition = Portfolio["positions"][number] & {
|
||||
shareText: string | null;
|
||||
returnText: string | null;
|
||||
returnTone?: AccountPageReturnTone;
|
||||
logoSrc: string | null;
|
||||
};
|
||||
|
||||
export type AccountPagePositionSection = {
|
||||
kind: PositionSectionKind;
|
||||
wide: boolean;
|
||||
positions: AccountPagePosition[];
|
||||
};
|
||||
|
||||
export type AccountPageRecentOperations =
|
||||
| {
|
||||
state: "loading" | "error" | "empty";
|
||||
to: string;
|
||||
}
|
||||
| {
|
||||
state: "loaded";
|
||||
to: string;
|
||||
items: BrokerageOperation[];
|
||||
};
|
||||
|
||||
export type AccountPageRecentEvents =
|
||||
| {
|
||||
state: "loading" | "error" | "empty";
|
||||
to: string;
|
||||
}
|
||||
| {
|
||||
state: "loaded";
|
||||
to: string;
|
||||
items: BrokerageEvent[];
|
||||
};
|
||||
|
||||
type AccountPageContent = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry: () => void;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
};
|
||||
|
||||
type AccountPageLoadedViewModel = AccountPageContent & {
|
||||
state: "loaded";
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
};
|
||||
|
||||
type AccountPageEmptyViewModel = AccountPageContent & {
|
||||
state: "empty";
|
||||
};
|
||||
|
||||
export type AccountPageViewModel =
|
||||
| { state: "loading" }
|
||||
| { state: "error"; code?: string; retry: () => void }
|
||||
| AccountPageEmptyViewModel
|
||||
| AccountPageLoadedViewModel;
|
||||
|
||||
export function useAccountPageViewModel(): AccountPageViewModel {
|
||||
const { accountId } = useParams();
|
||||
const { accessToken } = useAuth();
|
||||
const portfolio = usePortfolio(accountId, accessToken);
|
||||
const operations = useBrokerageOperations(accountId, accessToken, {
|
||||
limit: 5,
|
||||
});
|
||||
const events = useBrokerageEvents(accountId, accessToken);
|
||||
const account = useQueryClient()
|
||||
.getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey)
|
||||
?.find((item) => item.id === accountId);
|
||||
const recentOperations = toRecentOperations(accountId, operations);
|
||||
const recentEvents = toRecentEvents(accountId, events);
|
||||
if (!portfolio.data) {
|
||||
if (portfolio.isError)
|
||||
return {
|
||||
@@ -33,15 +116,110 @@ export function useAccountPageViewModel() {
|
||||
state: "empty" as const,
|
||||
account,
|
||||
accountId,
|
||||
accessToken,
|
||||
recentOperations,
|
||||
recentEvents,
|
||||
...refresh,
|
||||
};
|
||||
return {
|
||||
state: "loaded" as const,
|
||||
account,
|
||||
accountId,
|
||||
accessToken,
|
||||
portfolio: portfolio.data,
|
||||
positionSections: toAccountPagePositionSections(portfolio.data),
|
||||
recentOperations,
|
||||
recentEvents,
|
||||
...refresh,
|
||||
};
|
||||
}
|
||||
|
||||
function toRecentOperations(
|
||||
accountId: string | undefined,
|
||||
operations: ReturnType<typeof useBrokerageOperations>,
|
||||
): AccountPageRecentOperations {
|
||||
const to = `/accounts/${accountId}/operations`;
|
||||
if (operations.isLoading) return { state: "loading", to };
|
||||
if (operations.isError) return { state: "error", to };
|
||||
if (!operations.data?.items.length) return { state: "empty", to };
|
||||
return { state: "loaded", to, items: operations.data.items };
|
||||
}
|
||||
|
||||
function toRecentEvents(
|
||||
accountId: string | undefined,
|
||||
events: ReturnType<typeof useBrokerageEvents>,
|
||||
): AccountPageRecentEvents {
|
||||
const to = `/accounts/${accountId}/events`;
|
||||
if (events.isLoading) return { state: "loading", to };
|
||||
if (events.isError) return { state: "error", to };
|
||||
const items = events.data?.events.slice(0, 5) ?? [];
|
||||
if (!items.length) return { state: "empty", to };
|
||||
return { state: "loaded", to, items };
|
||||
}
|
||||
|
||||
export function toAccountPagePositionSections(
|
||||
portfolio: Portfolio,
|
||||
): AccountPagePositionSection[] {
|
||||
const denominators = new Map<string, number>();
|
||||
for (const position of portfolio.positions) {
|
||||
if (!position.currentValue) continue;
|
||||
const currency = position.currentValue.currency;
|
||||
denominators.set(
|
||||
currency,
|
||||
(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);
|
||||
return [stocks, bonds, other].filter(
|
||||
(section) => section.positions.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
||||
function toPosition(
|
||||
position: Portfolio["positions"][number],
|
||||
denominators: Map<string, number>,
|
||||
wide: boolean,
|
||||
): AccountPagePosition {
|
||||
const returnValue = position.absoluteReturn
|
||||
? Number(position.absoluteReturn.value)
|
||||
: null;
|
||||
const returnTone =
|
||||
returnValue === null || returnValue === 0
|
||||
? undefined
|
||||
: returnValue > 0
|
||||
? "positive"
|
||||
: "negative";
|
||||
return {
|
||||
...position,
|
||||
shareText: position.currentValue?.currency
|
||||
? formatShare(position.currentValue, {
|
||||
currency: position.currentValue.currency,
|
||||
value: String(denominators.get(position.currentValue.currency) ?? 0),
|
||||
})
|
||||
: null,
|
||||
returnText: position.absoluteReturn
|
||||
? `${wide && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
|
||||
: null,
|
||||
returnTone,
|
||||
logoSrc: wide ? instrumentLogoUrl(position.logoName) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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";
|
||||
|
||||
export function AccountHeading({ account }: { account?: BrokerageAccount }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
|
||||
{account ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(accountTypeKey(account.type))}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Paper, Text } from "@mantine/core";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { formatMoney } from "@/entities/portfolio";
|
||||
import classes from "../AccountPage.module.css";
|
||||
|
||||
export function CashBalances({
|
||||
balances,
|
||||
}: {
|
||||
balances: Portfolio["cashBalances"];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{balances.map((balance) => (
|
||||
<Paper className={classes.cash} key={balance.currency} withBorder>
|
||||
<Text>{balance.currency}</Text>
|
||||
<Text fw={700}>{formatMoney(balance)}</Text>
|
||||
</Paper>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function PortfolioRefreshState({
|
||||
refreshing,
|
||||
refreshError,
|
||||
retry,
|
||||
}: {
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!refreshing && !refreshError) return null;
|
||||
return (
|
||||
<>
|
||||
{refreshing ? (
|
||||
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
|
||||
<Loader size="sm" />
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("portfolio.refreshing")}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{refreshError ? (
|
||||
<Alert color="orange">
|
||||
<Stack gap="sm">
|
||||
<Text>{t("portfolio.refreshError")}</Text>
|
||||
<Button onClick={retry} variant="light">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Paper, SimpleGrid, Text } from "@mantine/core";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { formatMoney } from "@/entities/portfolio";
|
||||
import classes from "../AccountPage.module.css";
|
||||
|
||||
export function PortfolioTotals({
|
||||
totals,
|
||||
}: {
|
||||
totals: Portfolio["totalsByCurrency"];
|
||||
}) {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
|
||||
{totals.map((total) => (
|
||||
<Paper className={classes.total} key={total.currency} withBorder>
|
||||
<Text c="dimmed" size="xs">
|
||||
{total.currency}
|
||||
</Text>
|
||||
<Text fw={800} size="xl">
|
||||
{formatMoney(total)}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatMoney } from "@/entities/portfolio";
|
||||
import classes from "../AccountPage.module.css";
|
||||
import type { AccountPagePosition } from "../AccountPage.vm";
|
||||
import { PositionLogo } from "./PositionLogo.view";
|
||||
|
||||
export function PositionCard({
|
||||
position,
|
||||
wide = false,
|
||||
}: {
|
||||
position: AccountPagePosition;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const returnClass = position.returnTone
|
||||
? classes[position.returnTone]
|
||||
: undefined;
|
||||
const returnText =
|
||||
position.returnText && wide
|
||||
? position.returnText
|
||||
: position.returnText
|
||||
? t("portfolio.return", {
|
||||
value: position.returnText,
|
||||
})
|
||||
: null;
|
||||
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}
|
||||
</Text>
|
||||
{position.isin ? (
|
||||
<Text c="dimmed" size="xs" className={classes.mono}>
|
||||
{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 ? (
|
||||
<Text fw={800} size="lg">
|
||||
{formatMoney(position.currentValue)}
|
||||
</Text>
|
||||
) : null}
|
||||
{returnText ? (
|
||||
<Text size="sm" fw={600} className={returnClass}>
|
||||
{returnText}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
{position.averagePurchasePrice || position.shareText ? (
|
||||
<Group justify="space-between" mt="xs">
|
||||
{position.averagePurchasePrice ? (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.avgPrice", {
|
||||
value: formatMoney(position.averagePurchasePrice),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{position.shareText ? (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("portfolio.share", { value: position.shareText })}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Avatar } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
|
||||
export function PositionLogo({
|
||||
src,
|
||||
label,
|
||||
}: {
|
||||
src: string | null;
|
||||
label: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (src && !failed)
|
||||
return (
|
||||
<Avatar
|
||||
src={src}
|
||||
alt={label}
|
||||
size={40}
|
||||
radius="md"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Avatar size={40} radius="md">
|
||||
{(label.trim()[0] ?? "?").toUpperCase()}
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SimpleGrid } from "@mantine/core";
|
||||
import type { AccountPagePositionSection } from "../AccountPage.vm";
|
||||
import { PositionCard } from "./PositionCard.view";
|
||||
import { Section } from "./Section.view";
|
||||
|
||||
export function PositionSection({
|
||||
title,
|
||||
section,
|
||||
}: {
|
||||
title: string;
|
||||
section: AccountPagePositionSection;
|
||||
}) {
|
||||
return (
|
||||
<Section title={title}>
|
||||
<SimpleGrid cols={section.wide ? 1 : { base: 1, sm: 2, md: 3 }}>
|
||||
{section.positions.map((position) => (
|
||||
<PositionCard
|
||||
key={`${position.figi}-${position.displayName}-${position.quantity}`}
|
||||
position={position}
|
||||
wide={section.wide}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Alert, Button, Paper, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { formatMoney } from "@/entities/portfolio";
|
||||
import classes from "../AccountPage.module.css";
|
||||
import type { AccountPageRecentEvents } from "../AccountPage.vm";
|
||||
import { Section } from "./Section.view";
|
||||
|
||||
export function RecentEvents({ events }: { events: AccountPageRecentEvents }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Section title={t("events.recentTitle")}>
|
||||
{events.state === "loading" ? (
|
||||
<Text c="dimmed">{t("events.recentLoading")}</Text>
|
||||
) : null}
|
||||
{events.state === "error" ? (
|
||||
<Alert color="red">{t("events.recentError")}</Alert>
|
||||
) : null}
|
||||
{events.state === "empty" ? (
|
||||
<Text c="dimmed">{t("events.recentEmpty")}</Text>
|
||||
) : null}
|
||||
{events.state === "loaded"
|
||||
? events.items.map((event) => (
|
||||
<RecentEvent
|
||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||
event={event}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
<Button component={Link} to={events.to} variant="subtle">
|
||||
{t("events.open")}
|
||||
</Button>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentEvent({ event }: { event: BrokerageEvent }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Paper className={classes.cash} withBorder>
|
||||
<Text>{`${t(`events.type.${event.type}`)} · ${event.displayName}`}</Text>
|
||||
<Text fw={700}>
|
||||
{event.amount && event.currency
|
||||
? formatMoney({ currency: event.currency, value: event.amount })
|
||||
: t("events.noAmount")}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Alert, Button, Paper, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { formatMoney } from "@/entities/portfolio";
|
||||
import classes from "../AccountPage.module.css";
|
||||
import type { AccountPageRecentOperations } from "../AccountPage.vm";
|
||||
import { Section } from "./Section.view";
|
||||
|
||||
export function RecentOperations({
|
||||
operations,
|
||||
}: {
|
||||
operations: AccountPageRecentOperations;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Section title={t("ledger.recentTitle")}>
|
||||
{operations.state === "loading" ? (
|
||||
<Text c="dimmed">{t("ledger.recentLoading")}</Text>
|
||||
) : null}
|
||||
{operations.state === "error" ? (
|
||||
<Alert color="red">{t("ledger.recentError")}</Alert>
|
||||
) : null}
|
||||
{operations.state === "empty" ? (
|
||||
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
|
||||
) : null}
|
||||
{operations.state === "loaded"
|
||||
? operations.items.map((operation) => (
|
||||
<Paper className={classes.cash} key={operation.id} withBorder>
|
||||
<Text>{operation.description || operation.type}</Text>
|
||||
<Text fw={700}>
|
||||
{operation.payment
|
||||
? formatMoney(operation.payment)
|
||||
: t("ledger.noMoney")}
|
||||
</Text>
|
||||
</Paper>
|
||||
))
|
||||
: null}
|
||||
<Button component={Link} to={operations.to} variant="subtle">
|
||||
{t("ledger.open")}
|
||||
</Button>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Title order={2}>{title}</Title>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { AccountHeading } from "./AccountHeading.view";
|
||||
export { CashBalances } from "./CashBalances.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";
|
||||
export { Section } from "./Section.view";
|
||||
Reference in New Issue
Block a user