feat: unify upcoming event cards across pages

This commit is contained in:
2026-09-09 17:58:02 +03:00
parent 3017fc810e
commit 451400b63b
20 changed files with 313 additions and 140 deletions
@@ -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" },
}, },
]); ]);
+5
View File
@@ -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 };
@@ -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,
},
],
},
},
};
@@ -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>
); );
}; };
+105
View File
@@ -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
+2 -11
View File
@@ -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">
+2
View File
@@ -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: {
+10
View File
@@ -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"
] ]
}, },
+1
View File
@@ -144,6 +144,7 @@ const ru = {
errorText: "Попробуйте запросить прогноз еще раз.", errorText: "Попробуйте запросить прогноз еще раз.",
retry: "Повторить", retry: "Повторить",
noAmount: "Сумма неизвестна", noAmount: "Сумма неизвестна",
projectedAmount: "Ожидаемая сумма",
dateKind: { event: "Дата события", registry: "Дата реестра" }, dateKind: { event: "Дата события", registry: "Дата реестра" },
type: { type: {
coupon: "Купон", coupon: "Купон",
@@ -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;
} }
+49
View File
@@ -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
View File
@@ -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";
+1 -1
View File
@@ -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());