feat: unify upcoming event cards across pages
This commit is contained in:
@@ -1,11 +1,9 @@
|
|||||||
import { Avatar, Paper, Stack, Text } from "@mantine/core";
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
|
import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
|
||||||
|
import { EntryCard } from "@/shared/ui";
|
||||||
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
||||||
import { paymentPresentation } from "../model/display";
|
import { paymentPresentation } from "../model/display";
|
||||||
import classes from "./LedgerEntryRow.module.css";
|
|
||||||
|
|
||||||
type LedgerEntryRowProps = {
|
type LedgerEntryRowProps = {
|
||||||
operation: BrokerageOperation;
|
operation: BrokerageOperation;
|
||||||
@@ -14,44 +12,23 @@ type LedgerEntryRowProps = {
|
|||||||
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
||||||
const { operation } = props;
|
const { operation } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [imageFailed, setImageFailed] = useState(false);
|
|
||||||
const instrument =
|
const instrument =
|
||||||
operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument");
|
operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument");
|
||||||
const logoSrc = imageFailed ? null : instrumentLogoUrl(operation.logoName);
|
|
||||||
const payment = paymentPresentation(operation);
|
const payment = paymentPresentation(operation);
|
||||||
const metadata = [formatSnapshotTime(operation.occurredAt), instrument].join(
|
const metadata = [formatSnapshotTime(operation.occurredAt), instrument].join(
|
||||||
" · ",
|
" · ",
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className={classes.row} withBorder>
|
<EntryCard
|
||||||
<Avatar
|
title={operation.description || operation.type}
|
||||||
alt=""
|
metadata={metadata}
|
||||||
aria-hidden="true"
|
initial={(instrument.trim()[0] ?? "?").toUpperCase()}
|
||||||
radius="md"
|
imageSrc={instrumentLogoUrl(operation.logoName)}
|
||||||
size={40}
|
value={payment.amount}
|
||||||
src={logoSrc}
|
valueLabel={t(payment.label)}
|
||||||
onError={() => setImageFailed(true)}
|
tone={payment.tone}
|
||||||
>
|
/>
|
||||||
{(instrument.trim()[0] ?? "?").toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
<Stack className={classes.identity} gap={2}>
|
|
||||||
<Text fw={700} lineClamp={1}>
|
|
||||||
{operation.description || operation.type}
|
|
||||||
</Text>
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{metadata}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack className={classes.amount} gap={2}>
|
|
||||||
<Text className={classes[payment.tone]} fw={700}>
|
|
||||||
{payment.amount}
|
|
||||||
</Text>
|
|
||||||
<Text className={classes[payment.tone]} size="sm">
|
|
||||||
{t(payment.label)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ describe("useBrokerageEvents", () => {
|
|||||||
instrumentUid: null,
|
instrumentUid: null,
|
||||||
ticker: "SBER",
|
ticker: "SBER",
|
||||||
displayName: "Сбер",
|
displayName: "Сбер",
|
||||||
|
logoName: "sber3.png",
|
||||||
projectedAmount: { currency: "RUB", value: "12.34" },
|
projectedAmount: { currency: "RUB", value: "12.34" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -64,6 +65,7 @@ describe("useBrokerageEvents", () => {
|
|||||||
instrumentUid: null,
|
instrumentUid: null,
|
||||||
ticker: "SBER",
|
ticker: "SBER",
|
||||||
displayName: "Сбер",
|
displayName: "Сбер",
|
||||||
|
logoName: "sber3.png",
|
||||||
projectedAmount: { currency: "RUB", value: "12.34" },
|
projectedAmount: { currency: "RUB", value: "12.34" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -2,3 +2,8 @@ export {
|
|||||||
type BrokerageEvent,
|
type BrokerageEvent,
|
||||||
useBrokerageEvents,
|
useBrokerageEvents,
|
||||||
} from "./api/useBrokerageEvents";
|
} from "./api/useBrokerageEvents";
|
||||||
|
export { BrokerageEventCard } from "./ui/BrokerageEventCard";
|
||||||
|
export {
|
||||||
|
BrokerageEventsPreview,
|
||||||
|
type BrokerageEventsPreviewState,
|
||||||
|
} from "./ui/BrokerageEventsPreview";
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
||||||
|
import { EntryCard } from "@/shared/ui";
|
||||||
|
import type { BrokerageEvent } from "../api/useBrokerageEvents";
|
||||||
|
|
||||||
|
type BrokerageEventCardProps = { event: BrokerageEvent };
|
||||||
|
|
||||||
|
const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
|
||||||
|
const { event } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const date = new Intl.DateTimeFormat("ru-RU", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeZone: "UTC",
|
||||||
|
}).format(new Date(`${event.date}T00:00:00Z`));
|
||||||
|
|
||||||
|
const metadata = [
|
||||||
|
date,
|
||||||
|
event.dateKind === "registry" ? t("events.dateKind.registry") : null,
|
||||||
|
event.isin,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntryCard
|
||||||
|
title={`${t(`events.type.${event.type}`)} · ${event.displayName}`}
|
||||||
|
metadata={metadata}
|
||||||
|
initial={(
|
||||||
|
(event.ticker ?? event.displayName).trim()[0] ?? "?"
|
||||||
|
).toUpperCase()}
|
||||||
|
imageSrc={instrumentLogoUrl(event.logoName)}
|
||||||
|
value={
|
||||||
|
event.projectedAmount
|
||||||
|
? formatMoney(event.projectedAmount)
|
||||||
|
: t("events.noAmount")
|
||||||
|
}
|
||||||
|
valueLabel={t("events.projectedAmount")}
|
||||||
|
tone="neutral"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { BrokerageEventCard };
|
||||||
+25
-4
@@ -1,7 +1,7 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
import type { BrokerageEvent } from "../api/useBrokerageEvents";
|
||||||
import { RecentEvents } from "./RecentEvents.view";
|
import { BrokerageEventsPreview } from "./BrokerageEventsPreview";
|
||||||
|
|
||||||
const syntheticEvent = {
|
const syntheticEvent = {
|
||||||
type: "coupon",
|
type: "coupon",
|
||||||
@@ -11,6 +11,8 @@ const syntheticEvent = {
|
|||||||
instrumentUid: null,
|
instrumentUid: null,
|
||||||
ticker: "DEMO",
|
ticker: "DEMO",
|
||||||
displayName: "Учебные облигации",
|
displayName: "Учебные облигации",
|
||||||
|
logoName: null,
|
||||||
|
isin: "RU000A0JX0J2",
|
||||||
projectedAmount: { currency: "RUB", value: "850.50" },
|
projectedAmount: { currency: "RUB", value: "850.50" },
|
||||||
} satisfies BrokerageEvent;
|
} satisfies BrokerageEvent;
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ const eventsPath = "/accounts/storybook-account/events";
|
|||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: "Product/RecentEvents",
|
title: "Product/RecentEvents",
|
||||||
component: RecentEvents,
|
component: BrokerageEventsPreview,
|
||||||
tags: ["autodocs"],
|
tags: ["autodocs"],
|
||||||
argTypes: {
|
argTypes: {
|
||||||
events: { control: false },
|
events: { control: false },
|
||||||
@@ -56,7 +58,7 @@ const meta = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies Meta<typeof RecentEvents>;
|
} satisfies Meta<typeof BrokerageEventsPreview>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
@@ -131,3 +133,22 @@ export const MissingAmount: Story = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const LongEvent: Story = {
|
||||||
|
name: "Длинное название на узком экране",
|
||||||
|
args: {
|
||||||
|
events: {
|
||||||
|
state: "loaded",
|
||||||
|
to: eventsPath,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
...syntheticEvent,
|
||||||
|
type: "offer",
|
||||||
|
displayName:
|
||||||
|
"Учебные облигации компании с очень длинным названием инструмента",
|
||||||
|
projectedAmount: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
+11
-29
@@ -1,22 +1,20 @@
|
|||||||
import { Alert, Button, Paper, Skeleton, Stack, Text } from "@mantine/core";
|
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
|
||||||
import { formatMoney } from "@/shared/lib";
|
|
||||||
import { LoadingSkeleton } from "@/shared/ui";
|
import { LoadingSkeleton } from "@/shared/ui";
|
||||||
import classes from "../AccountPage.module.css";
|
import type { BrokerageEvent } from "../api/useBrokerageEvents";
|
||||||
import type { AccountPageRecentEvents } from "../AccountPage.vm";
|
import { BrokerageEventCard } from "./BrokerageEventCard";
|
||||||
|
|
||||||
type RecentEventsProps = {
|
type BrokerageEventsPreviewProps = {
|
||||||
events: AccountPageRecentEvents;
|
events: BrokerageEventsPreviewState;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RecentEventProps = {
|
type BrokerageEventsPreviewState =
|
||||||
event: BrokerageEvent;
|
| { state: "loading" | "error" | "empty"; to: string }
|
||||||
};
|
| { state: "loaded"; to: string; items: BrokerageEvent[] };
|
||||||
|
|
||||||
const RecentEvents: FC<RecentEventsProps> = (props) => {
|
const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
||||||
const { events } = props;
|
const { events } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -37,7 +35,7 @@ const RecentEvents: FC<RecentEventsProps> = (props) => {
|
|||||||
) : null}
|
) : null}
|
||||||
{events.state === "loaded"
|
{events.state === "loaded"
|
||||||
? events.items.map((event) => (
|
? events.items.map((event) => (
|
||||||
<RecentEvent
|
<BrokerageEventCard
|
||||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||||
event={event}
|
event={event}
|
||||||
/>
|
/>
|
||||||
@@ -50,20 +48,4 @@ const RecentEvents: FC<RecentEventsProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RecentEvent: FC<RecentEventProps> = (props) => {
|
export { BrokerageEventsPreview, type BrokerageEventsPreviewState };
|
||||||
const { event } = props;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper className={classes.cash} withBorder>
|
|
||||||
<Text>{`${t(`events.type.${event.type}`)} · ${event.displayName}`}</Text>
|
|
||||||
<Text fw={700}>
|
|
||||||
{event.projectedAmount
|
|
||||||
? formatMoney(event.projectedAmount)
|
|
||||||
: t("events.noAmount")}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { RecentEvents };
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
import { MemoryRouter, Route, Routes } from "react-router";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
||||||
import { AccountEventsPage } from "./AccountEventsPage";
|
import { AccountEventsPage } from "./AccountEventsPage";
|
||||||
|
|
||||||
vi.mock("@/entities/brokerage-event", () => ({
|
vi.mock("@/entities/brokerage-event", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/entities/brokerage-event")>()),
|
||||||
useBrokerageEvents: vi.fn(),
|
useBrokerageEvents: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -81,6 +82,8 @@ describe("AccountEventsPage", () => {
|
|||||||
projectedAmount: { currency: "RUB", value: "1250" },
|
projectedAmount: { currency: "RUB", value: "1250" },
|
||||||
ticker: "OFZ",
|
ticker: "OFZ",
|
||||||
type: "coupon",
|
type: "coupon",
|
||||||
|
logoName: "ofz.png",
|
||||||
|
isin: "RU000A0JX0J2",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: "2026-08-25",
|
date: "2026-08-25",
|
||||||
@@ -91,6 +94,8 @@ describe("AccountEventsPage", () => {
|
|||||||
projectedAmount: null,
|
projectedAmount: null,
|
||||||
ticker: "OFZ",
|
ticker: "OFZ",
|
||||||
type: "offer",
|
type: "offer",
|
||||||
|
logoName: null,
|
||||||
|
isin: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -98,9 +103,26 @@ describe("AccountEventsPage", () => {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
} as never);
|
} as never);
|
||||||
renderPage();
|
const { container } = renderPage();
|
||||||
expect(screen.getByText("1 250 ₽")).toBeInTheDocument();
|
expect(screen.getByText("1 250 ₽")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
|
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Купон · ОФЗ 26238")).toBeVisible();
|
||||||
|
expect(screen.getByText("Оферта · ОФЗ 26238")).toBeVisible();
|
||||||
|
expect(screen.getByText("20 авг. 2026 г. · RU000A0JX0J2")).toBeVisible();
|
||||||
|
expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1);
|
||||||
|
expect(screen.getByText("25 авг. 2026 г.")).toBeVisible();
|
||||||
|
expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2);
|
||||||
|
const logo = container.querySelector("img");
|
||||||
|
expect(logo).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"https://invest-brands.cdn-tinkoff.ru/ofzx160.png",
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText("O")).toHaveLength(1);
|
||||||
|
if (!logo) throw new Error("Expected the event logo");
|
||||||
|
fireEvent.error(logo);
|
||||||
|
expect(container.querySelector("img")).toBeNull();
|
||||||
|
expect(screen.getAllByText("O")).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders empty and retryable error states", () => {
|
it("renders empty and retryable error states", () => {
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Card, Group, Stack, Text } from "@mantine/core";
|
|
||||||
import type { FC } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
|
||||||
import { formatMoney } from "@/shared/lib";
|
|
||||||
|
|
||||||
type AccountEventRowProps = { event: BrokerageEvent };
|
|
||||||
|
|
||||||
const AccountEventRow: FC<AccountEventRowProps> = (props) => {
|
|
||||||
const { event } = props;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<Card withBorder>
|
|
||||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Text fw={700}>{event.displayName}</Text>
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{formatCalendarDate(event.date)} ·{" "}
|
|
||||||
{t(`events.dateKind.${event.dateKind}`)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack gap={2} align="flex-end">
|
|
||||||
<Text fw={700}>
|
|
||||||
{event.projectedAmount
|
|
||||||
? formatMoney(event.projectedAmount)
|
|
||||||
: t("events.noAmount")}
|
|
||||||
</Text>
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{t(`events.type.${event.type}`)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatCalendarDate(date: string) {
|
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
|
||||||
dateStyle: "medium",
|
|
||||||
timeZone: "UTC",
|
|
||||||
}).format(new Date(`${date}T00:00:00Z`));
|
|
||||||
}
|
|
||||||
|
|
||||||
export { AccountEventRow };
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Stack, Text, Title } from "@mantine/core";
|
import { Stack, Text, Title } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
import {
|
||||||
import { AccountEventRow } from "./AccountEventRow.view";
|
type BrokerageEvent,
|
||||||
|
BrokerageEventCard,
|
||||||
|
} from "@/entities/brokerage-event";
|
||||||
|
|
||||||
type AccountEventsPageLoadedProps = { events: BrokerageEvent[] };
|
type AccountEventsPageLoadedProps = { events: BrokerageEvent[] };
|
||||||
|
|
||||||
@@ -12,12 +14,14 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
|||||||
<Stack maw={960} gap="lg">
|
<Stack maw={960} gap="lg">
|
||||||
<Title order={1}>{t("events.title")}</Title>
|
<Title order={1}>{t("events.title")}</Title>
|
||||||
<Text c="dimmed">{t("events.hint")}</Text>
|
<Text c="dimmed">{t("events.hint")}</Text>
|
||||||
{props.events.map((event) => (
|
<Stack gap="sm">
|
||||||
<AccountEventRow
|
{props.events.map((event) => (
|
||||||
event={event}
|
<BrokerageEventCard
|
||||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
event={event}
|
||||||
/>
|
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
import { MemoryRouter, Route, Routes } from "react-router";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||||
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
@@ -637,6 +638,110 @@ describe("AccountPage states", () => {
|
|||||||
expect(screen.getByText("−12 ₽")).toBeInTheDocument();
|
expect(screen.getByText("−12 ₽")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps future events ordered with calendar dates and projected rather than posted amounts", () => {
|
||||||
|
const event: BrokerageEvent = {
|
||||||
|
type: "coupon",
|
||||||
|
date: "2026-09-10",
|
||||||
|
dateKind: "event",
|
||||||
|
figi: null,
|
||||||
|
instrumentUid: null,
|
||||||
|
ticker: "DEMO",
|
||||||
|
displayName: "Облигация",
|
||||||
|
logoName: null,
|
||||||
|
isin: "RU000A0JX0J2",
|
||||||
|
projectedAmount: { currency: "RUB", value: "850.50" },
|
||||||
|
};
|
||||||
|
renderPage(
|
||||||
|
<AccountPageLoaded
|
||||||
|
{...loadedProps()}
|
||||||
|
recentEvents={{
|
||||||
|
state: "loaded",
|
||||||
|
to: "/accounts/account-1/events",
|
||||||
|
items: [
|
||||||
|
event,
|
||||||
|
{
|
||||||
|
...event,
|
||||||
|
type: "dividend",
|
||||||
|
date: "2026-09-11",
|
||||||
|
dateKind: "registry",
|
||||||
|
displayName: "Акция",
|
||||||
|
projectedAmount: { currency: "USD", value: "0" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...event,
|
||||||
|
type: "maturity",
|
||||||
|
date: "2026-09-12",
|
||||||
|
projectedAmount: { currency: "RUB", value: "1000" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...event,
|
||||||
|
type: "offer",
|
||||||
|
date: "2026-09-13",
|
||||||
|
projectedAmount: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const section = screen.getByRole("region", { name: "Будущие события" });
|
||||||
|
const content = within(section);
|
||||||
|
const text = section.textContent ?? "";
|
||||||
|
const titles = [
|
||||||
|
"Купон · Облигация",
|
||||||
|
"Дивиденд · Акция",
|
||||||
|
"Погашение · Облигация",
|
||||||
|
"Оферта · Облигация",
|
||||||
|
];
|
||||||
|
for (const title of titles) expect(content.getByText(title)).toBeVisible();
|
||||||
|
expect(titles.map((title) => text.indexOf(title))).toEqual(
|
||||||
|
titles.map((title) => text.indexOf(title)).sort((a, b) => a - b),
|
||||||
|
);
|
||||||
|
expect(content.getByText("10 сент. 2026 г. · RU000A0JX0J2")).toBeVisible();
|
||||||
|
expect(content.getAllByText(/RU000A0JX0J2$/)).toHaveLength(4);
|
||||||
|
expect(content.queryByText(/Дата события/)).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
content.getByText("11 сент. 2026 г. · Дата реестра · RU000A0JX0J2"),
|
||||||
|
).toBeVisible();
|
||||||
|
for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"])
|
||||||
|
expect(content.getByText(amount)).toBeVisible();
|
||||||
|
expect(content.getAllByText("Ожидаемая сумма")).toHaveLength(4);
|
||||||
|
expect(content.queryByText("Без движения денег")).not.toBeInTheDocument();
|
||||||
|
expect(content.queryByText(/^\+/)).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
content.getByRole("link", { name: "Открыть будущие события" }),
|
||||||
|
).toHaveAttribute("href", "/accounts/account-1/events");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["loading", "empty"] as const)(
|
||||||
|
"keeps the upcoming %s state and calendar action",
|
||||||
|
(state) => {
|
||||||
|
renderPage(
|
||||||
|
<AccountPageLoaded
|
||||||
|
{...loadedProps()}
|
||||||
|
recentEvents={{ state, to: "/accounts/account-1/events" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const content = within(
|
||||||
|
screen.getByRole("region", { name: "Будущие события" }),
|
||||||
|
);
|
||||||
|
if (state === "loading") {
|
||||||
|
const skeleton = content.getByRole("status", {
|
||||||
|
name: "Загрузка будущих событий",
|
||||||
|
});
|
||||||
|
expect(skeleton).toHaveAttribute("aria-busy", "true");
|
||||||
|
expect(within(skeleton).queryByRole("link")).not.toBeInTheDocument();
|
||||||
|
} else {
|
||||||
|
expect(
|
||||||
|
content.getByText("На ближайшие 30 дней событий не найдено."),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
content.getByRole("link", { name: "Открыть будущие события" }),
|
||||||
|
).toHaveAttribute("href", "/accounts/account-1/events");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("keeps the portfolio visible when upcoming events fail", () => {
|
it("keeps the portfolio visible when upcoming events fail", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
brokerageAccountsQueryKey,
|
brokerageAccountsQueryKey,
|
||||||
} from "@/entities/brokerage-account";
|
} from "@/entities/brokerage-account";
|
||||||
import {
|
import {
|
||||||
type BrokerageEvent,
|
type BrokerageEventsPreviewState,
|
||||||
useBrokerageEvents,
|
useBrokerageEvents,
|
||||||
} from "@/entities/brokerage-event";
|
} from "@/entities/brokerage-event";
|
||||||
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
||||||
@@ -42,16 +42,7 @@ type AccountPageRecentOperations =
|
|||||||
items: BrokerageOperation[];
|
items: BrokerageOperation[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type AccountPageRecentEvents =
|
type AccountPageRecentEvents = BrokerageEventsPreviewState;
|
||||||
| {
|
|
||||||
state: "loading" | "error" | "empty";
|
|
||||||
to: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
state: "loaded";
|
|
||||||
to: string;
|
|
||||||
items: BrokerageEvent[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type AccountPageIncomeSummary =
|
type AccountPageIncomeSummary =
|
||||||
| { state: "loading" | "empty"; to: string }
|
| { state: "loading" | "empty"; to: string }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Stack } from "@mantine/core";
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||||
|
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||||
import { StateView } from "@/shared/ui";
|
import { StateView } from "@/shared/ui";
|
||||||
import type {
|
import type {
|
||||||
AccountPageIncomeSummary,
|
AccountPageIncomeSummary,
|
||||||
@@ -11,7 +12,6 @@ import type {
|
|||||||
import { AccountHeading } from "./AccountHeading.view";
|
import { AccountHeading } from "./AccountHeading.view";
|
||||||
import { IncomeSummary } from "./IncomeSummary.view";
|
import { IncomeSummary } from "./IncomeSummary.view";
|
||||||
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
||||||
import { RecentEvents } from "./RecentEvents.view";
|
|
||||||
import { RecentOperations } from "./RecentOperations.view";
|
import { RecentOperations } from "./RecentOperations.view";
|
||||||
|
|
||||||
type AccountPageEmptyProps = {
|
type AccountPageEmptyProps = {
|
||||||
@@ -39,7 +39,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
|||||||
text={t("portfolio.emptyText")}
|
text={t("portfolio.emptyText")}
|
||||||
/>
|
/>
|
||||||
<RecentOperations operations={props.recentOperations} />
|
<RecentOperations operations={props.recentOperations} />
|
||||||
<RecentEvents events={props.recentEvents} />
|
<BrokerageEventsPreview events={props.recentEvents} />
|
||||||
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
|
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Accordion, Alert, Stack, Text } from "@mantine/core";
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||||
|
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
import classes from "../AccountPage.module.css";
|
import classes from "../AccountPage.module.css";
|
||||||
@@ -16,7 +17,6 @@ import { CurrencyCards } from "./CurrencyCards.view";
|
|||||||
import { IncomeSummary } from "./IncomeSummary.view";
|
import { IncomeSummary } from "./IncomeSummary.view";
|
||||||
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
||||||
import { PositionSection } from "./PositionSection.view";
|
import { PositionSection } from "./PositionSection.view";
|
||||||
import { RecentEvents } from "./RecentEvents.view";
|
|
||||||
import { RecentOperations } from "./RecentOperations.view";
|
import { RecentOperations } from "./RecentOperations.view";
|
||||||
|
|
||||||
type AccountPageLoadedProps = {
|
type AccountPageLoadedProps = {
|
||||||
@@ -91,7 +91,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
<Accordion.Item value="recentEvents">
|
<Accordion.Item value="recentEvents">
|
||||||
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
|
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<RecentEvents events={props.recentEvents} />
|
<BrokerageEventsPreview events={props.recentEvents} />
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
<Accordion.Item value="incomeSummary">
|
<Accordion.Item value="incomeSummary">
|
||||||
|
|||||||
Vendored
+2
@@ -312,6 +312,8 @@ export interface components {
|
|||||||
instrumentUid: string | null;
|
instrumentUid: string | null;
|
||||||
ticker: string;
|
ticker: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
isin: string | null;
|
||||||
|
logoName: string | null;
|
||||||
projectedAmount: components["schemas"]["MoneyDto"] | null;
|
projectedAmount: components["schemas"]["MoneyDto"] | null;
|
||||||
};
|
};
|
||||||
UpcomingEventsDto: {
|
UpcomingEventsDto: {
|
||||||
|
|||||||
@@ -1659,6 +1659,14 @@
|
|||||||
"displayName": {
|
"displayName": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"isin": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"logoName": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"projectedAmount": {
|
"projectedAmount": {
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1677,6 +1685,8 @@
|
|||||||
"instrumentUid",
|
"instrumentUid",
|
||||||
"ticker",
|
"ticker",
|
||||||
"displayName",
|
"displayName",
|
||||||
|
"isin",
|
||||||
|
"logoName",
|
||||||
"projectedAmount"
|
"projectedAmount"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ const ru = {
|
|||||||
errorText: "Попробуйте запросить прогноз еще раз.",
|
errorText: "Попробуйте запросить прогноз еще раз.",
|
||||||
retry: "Повторить",
|
retry: "Повторить",
|
||||||
noAmount: "Сумма неизвестна",
|
noAmount: "Сумма неизвестна",
|
||||||
|
projectedAmount: "Ожидаемая сумма",
|
||||||
dateKind: { event: "Дата события", registry: "Дата реестра" },
|
dateKind: { event: "Дата события", registry: "Дата реестра" },
|
||||||
type: {
|
type: {
|
||||||
coupon: "Купон",
|
coupon: "Купон",
|
||||||
|
|||||||
+3
-3
@@ -9,11 +9,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.identity,
|
.identity,
|
||||||
.amount {
|
.value {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount {
|
.value {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount {
|
.value {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Avatar, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
import { type FC, useState } from "react";
|
||||||
|
import classes from "./EntryCard.module.css";
|
||||||
|
|
||||||
|
type EntryCardProps = {
|
||||||
|
title: string;
|
||||||
|
metadata: string;
|
||||||
|
initial: string;
|
||||||
|
imageSrc?: string | null;
|
||||||
|
value: string;
|
||||||
|
valueLabel: string;
|
||||||
|
tone: "positive" | "negative" | "neutral";
|
||||||
|
};
|
||||||
|
|
||||||
|
const EntryCard: FC<EntryCardProps> = (props) => {
|
||||||
|
const [imageFailed, setImageFailed] = useState(false);
|
||||||
|
return (
|
||||||
|
<Paper className={classes.row} withBorder>
|
||||||
|
<Avatar
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
radius="md"
|
||||||
|
size={40}
|
||||||
|
src={imageFailed ? null : props.imageSrc}
|
||||||
|
onError={() => setImageFailed(true)}
|
||||||
|
>
|
||||||
|
{props.initial}
|
||||||
|
</Avatar>
|
||||||
|
<Stack className={classes.identity} gap={2}>
|
||||||
|
<Text fw={700} lineClamp={1}>
|
||||||
|
{props.title}
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{props.metadata}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack className={classes.value} gap={2}>
|
||||||
|
<Text className={classes[props.tone]} fw={700}>
|
||||||
|
{props.value}
|
||||||
|
</Text>
|
||||||
|
<Text className={classes[props.tone]} size="sm">
|
||||||
|
{props.valueLabel}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { EntryCard };
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export { AppShell } from "./AppShell";
|
export { AppShell } from "./AppShell";
|
||||||
|
export { EntryCard } from "./EntryCard";
|
||||||
export { LoadingSkeleton } from "./LoadingSkeleton";
|
export { LoadingSkeleton } from "./LoadingSkeleton";
|
||||||
export { StateView } from "./StateView";
|
export { StateView } from "./StateView";
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { fn } from "storybook/test";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import preview from "../../.storybook/preview";
|
import preview from "../../.storybook/preview";
|
||||||
import * as AccountStories from "../entities/brokerage-account/ui/BrokerageAccountList.stories";
|
import * as AccountStories from "../entities/brokerage-account/ui/BrokerageAccountList.stories";
|
||||||
|
import * as EventStories from "../entities/brokerage-event/ui/BrokerageEventsPreview.stories";
|
||||||
import * as LoadingStories from "../pages/account/ui/components/AccountPageLoading.stories";
|
import * as LoadingStories from "../pages/account/ui/components/AccountPageLoading.stories";
|
||||||
import * as RefreshStories from "../pages/account/ui/components/PortfolioRefreshState.stories";
|
import * as RefreshStories from "../pages/account/ui/components/PortfolioRefreshState.stories";
|
||||||
import * as PositionStories from "../pages/account/ui/components/PositionCard.stories";
|
import * as PositionStories from "../pages/account/ui/components/PositionCard.stories";
|
||||||
import * as EventStories from "../pages/account/ui/components/RecentEvents.stories";
|
|
||||||
import * as StateStories from "../shared/ui/StateView.stories";
|
import * as StateStories from "../shared/ui/StateView.stories";
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|||||||
Reference in New Issue
Block a user