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 { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
|
||||
import { EntryCard } from "@/shared/ui";
|
||||
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
||||
import { paymentPresentation } from "../model/display";
|
||||
import classes from "./LedgerEntryRow.module.css";
|
||||
|
||||
type LedgerEntryRowProps = {
|
||||
operation: BrokerageOperation;
|
||||
@@ -14,44 +12,23 @@ type LedgerEntryRowProps = {
|
||||
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
||||
const { operation } = props;
|
||||
const { t } = useTranslation();
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
const instrument =
|
||||
operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument");
|
||||
const logoSrc = imageFailed ? null : instrumentLogoUrl(operation.logoName);
|
||||
const payment = paymentPresentation(operation);
|
||||
const metadata = [formatSnapshotTime(operation.occurredAt), instrument].join(
|
||||
" · ",
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper className={classes.row} withBorder>
|
||||
<Avatar
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
radius="md"
|
||||
size={40}
|
||||
src={logoSrc}
|
||||
onError={() => setImageFailed(true)}
|
||||
>
|
||||
{(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>
|
||||
<EntryCard
|
||||
title={operation.description || operation.type}
|
||||
metadata={metadata}
|
||||
initial={(instrument.trim()[0] ?? "?").toUpperCase()}
|
||||
imageSrc={instrumentLogoUrl(operation.logoName)}
|
||||
value={payment.amount}
|
||||
valueLabel={t(payment.label)}
|
||||
tone={payment.tone}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ describe("useBrokerageEvents", () => {
|
||||
instrumentUid: null,
|
||||
ticker: "SBER",
|
||||
displayName: "Сбер",
|
||||
logoName: "sber3.png",
|
||||
projectedAmount: { currency: "RUB", value: "12.34" },
|
||||
},
|
||||
],
|
||||
@@ -64,6 +65,7 @@ describe("useBrokerageEvents", () => {
|
||||
instrumentUid: null,
|
||||
ticker: "SBER",
|
||||
displayName: "Сбер",
|
||||
logoName: "sber3.png",
|
||||
projectedAmount: { currency: "RUB", value: "12.34" },
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -2,3 +2,8 @@ export {
|
||||
type BrokerageEvent,
|
||||
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 { MemoryRouter } from "react-router";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { RecentEvents } from "./RecentEvents.view";
|
||||
import type { BrokerageEvent } from "../api/useBrokerageEvents";
|
||||
import { BrokerageEventsPreview } from "./BrokerageEventsPreview";
|
||||
|
||||
const syntheticEvent = {
|
||||
type: "coupon",
|
||||
@@ -11,6 +11,8 @@ const syntheticEvent = {
|
||||
instrumentUid: null,
|
||||
ticker: "DEMO",
|
||||
displayName: "Учебные облигации",
|
||||
logoName: null,
|
||||
isin: "RU000A0JX0J2",
|
||||
projectedAmount: { currency: "RUB", value: "850.50" },
|
||||
} satisfies BrokerageEvent;
|
||||
|
||||
@@ -18,7 +20,7 @@ const eventsPath = "/accounts/storybook-account/events";
|
||||
|
||||
const meta = {
|
||||
title: "Product/RecentEvents",
|
||||
component: RecentEvents,
|
||||
component: BrokerageEventsPreview,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
events: { control: false },
|
||||
@@ -56,7 +58,7 @@ const meta = {
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof RecentEvents>;
|
||||
} satisfies Meta<typeof BrokerageEventsPreview>;
|
||||
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import { LoadingSkeleton } from "@/shared/ui";
|
||||
import classes from "../AccountPage.module.css";
|
||||
import type { AccountPageRecentEvents } from "../AccountPage.vm";
|
||||
import type { BrokerageEvent } from "../api/useBrokerageEvents";
|
||||
import { BrokerageEventCard } from "./BrokerageEventCard";
|
||||
|
||||
type RecentEventsProps = {
|
||||
events: AccountPageRecentEvents;
|
||||
type BrokerageEventsPreviewProps = {
|
||||
events: BrokerageEventsPreviewState;
|
||||
};
|
||||
|
||||
type RecentEventProps = {
|
||||
event: BrokerageEvent;
|
||||
};
|
||||
type BrokerageEventsPreviewState =
|
||||
| { 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 { t } = useTranslation();
|
||||
|
||||
@@ -37,7 +35,7 @@ const RecentEvents: FC<RecentEventsProps> = (props) => {
|
||||
) : null}
|
||||
{events.state === "loaded"
|
||||
? events.items.map((event) => (
|
||||
<RecentEvent
|
||||
<BrokerageEventCard
|
||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||
event={event}
|
||||
/>
|
||||
@@ -50,20 +48,4 @@ const RecentEvents: FC<RecentEventsProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const RecentEvent: FC<RecentEventProps> = (props) => {
|
||||
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 };
|
||||
export { BrokerageEventsPreview, type BrokerageEventsPreviewState };
|
||||
@@ -1,11 +1,12 @@
|
||||
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
||||
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(),
|
||||
}));
|
||||
|
||||
@@ -81,6 +82,8 @@ describe("AccountEventsPage", () => {
|
||||
projectedAmount: { currency: "RUB", value: "1250" },
|
||||
ticker: "OFZ",
|
||||
type: "coupon",
|
||||
logoName: "ofz.png",
|
||||
isin: "RU000A0JX0J2",
|
||||
},
|
||||
{
|
||||
date: "2026-08-25",
|
||||
@@ -91,6 +94,8 @@ describe("AccountEventsPage", () => {
|
||||
projectedAmount: null,
|
||||
ticker: "OFZ",
|
||||
type: "offer",
|
||||
logoName: null,
|
||||
isin: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -98,9 +103,26 @@ describe("AccountEventsPage", () => {
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
} as never);
|
||||
renderPage();
|
||||
const { container } = renderPage();
|
||||
expect(screen.getByText("1 250 ₽")).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", () => {
|
||||
|
||||
@@ -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 type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { AccountEventRow } from "./AccountEventRow.view";
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
BrokerageEventCard,
|
||||
} from "@/entities/brokerage-event";
|
||||
|
||||
type AccountEventsPageLoadedProps = { events: BrokerageEvent[] };
|
||||
|
||||
@@ -12,12 +14,14 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
<Stack maw={960} gap="lg">
|
||||
<Title order={1}>{t("events.title")}</Title>
|
||||
<Text c="dimmed">{t("events.hint")}</Text>
|
||||
{props.events.map((event) => (
|
||||
<AccountEventRow
|
||||
event={event}
|
||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||
/>
|
||||
))}
|
||||
<Stack gap="sm">
|
||||
{props.events.map((event) => (
|
||||
<BrokerageEventCard
|
||||
event={event}
|
||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
@@ -637,6 +638,110 @@ describe("AccountPage states", () => {
|
||||
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", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
brokerageAccountsQueryKey,
|
||||
} from "@/entities/brokerage-account";
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
type BrokerageEventsPreviewState,
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
||||
@@ -42,16 +42,7 @@ type AccountPageRecentOperations =
|
||||
items: BrokerageOperation[];
|
||||
};
|
||||
|
||||
type AccountPageRecentEvents =
|
||||
| {
|
||||
state: "loading" | "error" | "empty";
|
||||
to: string;
|
||||
}
|
||||
| {
|
||||
state: "loaded";
|
||||
to: string;
|
||||
items: BrokerageEvent[];
|
||||
};
|
||||
type AccountPageRecentEvents = BrokerageEventsPreviewState;
|
||||
|
||||
type AccountPageIncomeSummary =
|
||||
| { state: "loading" | "empty"; to: string }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Stack } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||
import { StateView } from "@/shared/ui";
|
||||
import type {
|
||||
AccountPageIncomeSummary,
|
||||
@@ -11,7 +12,6 @@ import type {
|
||||
import { AccountHeading } from "./AccountHeading.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
||||
import { RecentEvents } from "./RecentEvents.view";
|
||||
import { RecentOperations } from "./RecentOperations.view";
|
||||
|
||||
type AccountPageEmptyProps = {
|
||||
@@ -39,7 +39,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
text={t("portfolio.emptyText")}
|
||||
/>
|
||||
<RecentOperations operations={props.recentOperations} />
|
||||
<RecentEvents events={props.recentEvents} />
|
||||
<BrokerageEventsPreview events={props.recentEvents} />
|
||||
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Accordion, Alert, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import classes from "../AccountPage.module.css";
|
||||
@@ -16,7 +17,6 @@ import { CurrencyCards } from "./CurrencyCards.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
||||
import { PositionSection } from "./PositionSection.view";
|
||||
import { RecentEvents } from "./RecentEvents.view";
|
||||
import { RecentOperations } from "./RecentOperations.view";
|
||||
|
||||
type AccountPageLoadedProps = {
|
||||
@@ -91,7 +91,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
<Accordion.Item value="recentEvents">
|
||||
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<RecentEvents events={props.recentEvents} />
|
||||
<BrokerageEventsPreview events={props.recentEvents} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="incomeSummary">
|
||||
|
||||
Vendored
+2
@@ -312,6 +312,8 @@ export interface components {
|
||||
instrumentUid: string | null;
|
||||
ticker: string;
|
||||
displayName: string;
|
||||
isin: string | null;
|
||||
logoName: string | null;
|
||||
projectedAmount: components["schemas"]["MoneyDto"] | null;
|
||||
};
|
||||
UpcomingEventsDto: {
|
||||
|
||||
@@ -1659,6 +1659,14 @@
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"isin": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"logoName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"projectedAmount": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
@@ -1677,6 +1685,8 @@
|
||||
"instrumentUid",
|
||||
"ticker",
|
||||
"displayName",
|
||||
"isin",
|
||||
"logoName",
|
||||
"projectedAmount"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -144,6 +144,7 @@ const ru = {
|
||||
errorText: "Попробуйте запросить прогноз еще раз.",
|
||||
retry: "Повторить",
|
||||
noAmount: "Сумма неизвестна",
|
||||
projectedAmount: "Ожидаемая сумма",
|
||||
dateKind: { event: "Дата события", registry: "Дата реестра" },
|
||||
type: {
|
||||
coupon: "Купон",
|
||||
|
||||
+3
-3
@@ -9,11 +9,11 @@
|
||||
}
|
||||
|
||||
.identity,
|
||||
.amount {
|
||||
.value {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.amount {
|
||||
.value {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.amount {
|
||||
.value {
|
||||
grid-column: 2;
|
||||
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 { EntryCard } from "./EntryCard";
|
||||
export { LoadingSkeleton } from "./LoadingSkeleton";
|
||||
export { StateView } from "./StateView";
|
||||
|
||||
@@ -4,10 +4,10 @@ import { fn } from "storybook/test";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import preview from "../../.storybook/preview";
|
||||
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 RefreshStories from "../pages/account/ui/components/PortfolioRefreshState.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";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
Reference in New Issue
Block a user