3 Commits
Author SHA1 Message Date
ksv741 db4ad138fc feat: clarify grouped coupon payouts (WealthLedger/frontend#162)
Frontend verify / commits (pull_request) Successful in 33s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m41s
2026-11-07 10:34:22 +03:00
ksv741 7b4e6367e6 test: align upcoming events empty state with new label (WealthLedger/frontend#161)
Frontend verify / commits (pull_request) Successful in 30s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m27s
2026-11-07 10:34:15 +03:00
ksv741 6333426ba2 fix: unify history and upcoming events labels (WealthLedger/frontend#161) 2026-11-07 10:29:25 +03:00
15 changed files with 177 additions and 96 deletions
@@ -227,9 +227,9 @@ beforeEach(() => {
describe("All accounts routes", () => { describe("All accounts routes", () => {
it.each([ it.each([
["/accounts/all", "Все счета"], ["/accounts/all", "Все счета"],
["/accounts/all/operations", "Журнал операций"], ["/accounts/all/operations", "История операций"],
["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"], ["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"],
["/accounts/all/events", "Будущие события"], ["/accounts/all/events", "Предстоящие события"],
])("opens %s directly", async (path, title) => { ])("opens %s directly", async (path, title) => {
renderPage(path); renderPage(path);
expect( expect(
@@ -311,9 +311,9 @@ describe("All accounts routes", () => {
); );
for (const [suffix, tab] of [ for (const [suffix, tab] of [
["operations", "Операции"], ["operations", "История операций"],
["income", "Доход"], ["income", "Доход"],
["events", "События"], ["events", "Предстоящие события"],
]) { ]) {
fireEvent.click(screen.getByRole("tab", { name: tab })); fireEvent.click(screen.getByRole("tab", { name: tab }));
expect(screen.getByRole("link", { name: "Подробнее" })) expect(screen.getByRole("link", { name: "Подробнее" }))
@@ -55,9 +55,9 @@ describe("AppBreadcrumbs", () => {
}); });
it.each([ it.each([
["operations", "Операции"], ["operations", "История операций"],
["OPERATIONS", "Операции"], ["OPERATIONS", "История операций"],
["events", "Будущие события"], ["events", "Предстоящие события"],
["income", "Пассивный доход"], ["income", "Пассивный доход"],
])("links to the account hierarchy from %s", (section, title) => { ])("links to the account hierarchy from %s", (section, title) => {
renderBreadcrumbs(`/accounts/account-1/${section}`, [ renderBreadcrumbs(`/accounts/account-1/${section}`, [
@@ -49,6 +49,10 @@ function entryLabelKey(operation: BrokerageOperation): string {
return `ledger.category.${operation.category}`; return `ledger.category.${operation.category}`;
} }
function isCouponPayment(operation: BrokerageOperation): boolean {
return isOperationType(operation.type, OperationType.Coupon);
}
function isIncomePayment(operation: BrokerageOperation): boolean { function isIncomePayment(operation: BrokerageOperation): boolean {
return [OperationType.Coupon, OperationType.Dividend].some(type => return [OperationType.Coupon, OperationType.Dividend].some(type =>
isOperationType(operation.type, type), isOperationType(operation.type, type),
@@ -131,6 +135,7 @@ function paymentPresentation(
export { export {
brokerageOperationGroupKey, brokerageOperationGroupKey,
entryLabelKey, entryLabelKey,
isCouponPayment,
isIncomePayment, isIncomePayment,
isZeroDecimal, isZeroDecimal,
operationLabelKey, operationLabelKey,
@@ -55,23 +55,27 @@ function renderGroup(
} }
describe("OperationGroup", () => { describe("OperationGroup", () => {
it("shows the header, exact total and separate card rows per account", () => { it("shows the received coupon and positive totals in flat account rows", () => {
renderGroup([ renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, "0.1"), { ...operation("1", { id: "broker", name: "Брокерский" }, "0.1"), description: "Выплата купонов СФО ВТБ РКС Эталон 04" },
operation("2", { id: "iis", name: "ИИС" }, "0.2"), operation("2", { id: "iis", name: "ИИС" }, "0.2"),
]); ]);
expect(screen.getByText("Купон · BOND")).toBeInTheDocument(); expect(screen.getByText("Выплата купонов СФО ВТБ РКС Эталон 04")).toBeInTheDocument();
expect(screen.getByText("+0,3 ₽")).toBeInTheDocument(); expect(screen.getByText("+0,3 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { const accounts = within(screen.getByRole("list", {
name: /Купон · BOND, Исполнено 14\.08\.2026.*, \+0,3 ₽/, name: /Выплата купонов СФО ВТБ РКС Эталон 04, Исполнено 14\.08\.2026.*, \+0,3 ₽/,
})); }));
expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" })) expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" }))
.toHaveTextContent("Купон"); .toHaveTextContent("Купон");
expect(accounts.getByText("+0,1 ₽")).toHaveClass(/positive/);
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" })) expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
.toHaveTextContent("Купон"); .toHaveTextContent("Купон");
expect(accounts.getByText("+0,2 ₽")).toHaveClass(/positive/);
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
.not.toHaveClass("EntryGroup_accountCardRow");
expect(accounts.queryByText(/Исполнено/)).not.toBeInTheDocument(); expect(accounts.queryByText(/Исполнено/)).not.toBeInTheDocument();
expect(accounts.queryByRole("link")).not.toBeInTheDocument(); expect(accounts.queryByRole("link")).not.toBeInTheDocument();
}); });
@@ -83,8 +87,10 @@ describe("OperationGroup", () => {
]); ]);
expect(screen.getByRole("list", { expect(screen.getByRole("list", {
name: /Купон · BOND, Исполнено .*Итог недоступен/, name: /Выплата купонов BOND, Исполнено .*Итог недоступен/,
})).toBeInTheDocument(); })).toBeInTheDocument();
expect(screen.getByText("Итог недоступен")).toBeInTheDocument();
expect(screen.getByText("—")).not.toHaveClass(/positive/);
}); });
it.each(["0", "0.000"])( it.each(["0", "0.000"])(
@@ -114,6 +120,7 @@ describe("OperationGroup", () => {
operation("2", { id: "iis", name: "ИИС" }, "-100"), operation("2", { id: "iis", name: "ИИС" }, "-100"),
]); ]);
expect(screen.getByText("0 ₽")).toBeInTheDocument(); expect(screen.getByText("0 ₽")).toBeInTheDocument();
expect(screen.getByText("−100 ₽")).toHaveClass(/negative/);
}); });
it("renders repeated ids inside one group as separate rows", () => { it("renders repeated ids inside one group as separate rows", () => {
@@ -123,7 +130,7 @@ describe("OperationGroup", () => {
]); ]);
expect(screen.getByText("+30 ₽")).toBeInTheDocument(); expect(screen.getByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { name: /Купон/ })); const accounts = within(screen.getByRole("list", { name: /Выплата купонов/ }));
expect(accounts.getAllByText("Брокерский")).toHaveLength(1); expect(accounts.getAllByText("Брокерский")).toHaveLength(1);
expect(accounts.getAllByText("ИИС")).toHaveLength(1); expect(accounts.getAllByText("ИИС")).toHaveLength(1);
expect(accounts.getAllByRole("listitem")).toHaveLength(2); expect(accounts.getAllByRole("listitem")).toHaveLength(2);
@@ -9,12 +9,13 @@ import {
sumMoney, sumMoney,
totalText, totalText,
} from "@/shared/lib"; } from "@/shared/lib";
import { AccountCardRow, EntryGroup, EntryGroupTotal } from "@/shared/ui"; import { AccountCardRow, AccountRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations"; import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { import {
entryLabelKey, entryLabelKey,
isCouponPayment,
isZeroDecimal, isZeroDecimal,
operationLabelKey, operationLabelKey,
paymentPresentation, paymentPresentation,
@@ -37,6 +38,8 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
? entryLabelKey(first) ? entryLabelKey(first)
: operationLabelKey(first.type)); : operationLabelKey(first.type));
const isCoupon = isCouponPayment(first);
const instrument const instrument
= first.ticker ?? first.instrumentType ?? t("ledger.noInstrument"); = first.ticker ?? first.instrumentType ?? t("ledger.noInstrument");
@@ -52,9 +55,21 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
: null; : null;
const date = formatSnapshotDay(first.occurredAt); const date = formatSnapshotDay(first.occurredAt);
const title = `${typeLabel} · ${instrument}`; const description = first.description.trim();
let title = `${typeLabel} · ${instrument}`;
if (isCoupon) {
title = description && description.toLocaleLowerCase() !== "купон"
? description
: t("ledger.couponPayment", { instrument });
}
const totalsText = totalText(totals, true); const totalsText = totalText(totals, true);
const totalTone = isCoupon && totals?.length && totals.every(total => !isZeroDecimal(total.value) && !total.value.startsWith("-"))
? "positive"
: "neutral";
const accessibleName = [ const accessibleName = [
title, title,
t("payout.executedAt", { date }), t("payout.executedAt", { date }),
@@ -63,13 +78,14 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
return ( return (
<EntryGroup <EntryGroup
flat={isCoupon}
header={( header={(
<EntryCard <EntryCard
imageSrc={instrumentLogoUrl(first.logoName)} imageSrc={instrumentLogoUrl(first.logoName)}
initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()} initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()}
metadata={t("payout.executedAt", { date })} metadata={t("payout.executedAt", { date })}
title={title} title={title}
tone="neutral" tone={totalTone}
value={<EntryGroupTotal isSigned totals={totals} />} value={<EntryGroupTotal isSigned totals={totals} />}
valueLabel={typeLabel} valueLabel={typeLabel}
wrap wrap
@@ -80,14 +96,15 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
{props.entries.map(({ key, operation }) => { {props.entries.map(({ key, operation }) => {
const payment = paymentPresentation(operation); const payment = paymentPresentation(operation);
return ( const accountRowProps = {
<AccountCardRow accountName: operation.account?.name ?? "—",
accountName={operation.account?.name ?? "—"} value: payment.amount,
key={key} valueLabel: t(payment.label),
value={payment.amount} };
valueLabel={t(payment.label)}
/> return isCoupon
); ? <AccountRow key={key} {...accountRowProps} tone={payment.tone} />
: <AccountCardRow key={key} {...accountRowProps} />;
})} })}
</EntryGroup> </EntryGroup>
); );
@@ -330,7 +330,7 @@ describe("Upcoming event date filters", () => {
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement(); expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect( expect(
screen.getByText("За выбранный период событий не найдено"), screen.getByText("На выбранный период предстоящих событий не найдено"),
).toBeVisible(); ).toBeVisible();
}, },
); );
@@ -174,7 +174,7 @@ describe("AccountEventsPage", () => {
it("renders empty and error states with retry", async () => { it("renders empty and error states with retry", async () => {
const { rerender } = renderPage(); const { rerender } = renderPage();
expect( expect(
screen.getByText("За выбранный период событий не найдено"), screen.getByText("На выбранный период предстоящих событий не найдено"),
).toBeInTheDocument(); ).toBeInTheDocument();
query.mockReturnValue({ query.mockReturnValue({
data: undefined, data: undefined,
@@ -239,7 +239,7 @@ describe("AccountEventsPage", () => {
"true", "true",
); );
expect( expect(
screen.queryByText("За выбранный период событий не найдено"), screen.queryByText("На выбранный период предстоящих событий не найдено"),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
@@ -817,11 +817,11 @@ describe("income period and snapshot boundary", () => {
); );
renderPage(undefined, "all"); renderPage(undefined, "all");
expect(await screen.findByText("Купон · BOND")).toBeInTheDocument(); expect(await screen.findByText("Выплата купонов BOND")).toBeInTheDocument();
expect(screen.getByText("+30 ₽")).toBeInTheDocument(); expect(screen.getByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { const accounts = within(screen.getByRole("list", {
name: /Купон · BOND, Исполнено/, name: /Выплата купонов BOND, Исполнено/,
})); }));
expect(accounts.getByText("Брокерский")).toBeInTheDocument(); expect(accounts.getByText("Брокерский")).toBeInTheDocument();
@@ -856,7 +856,7 @@ describe("income period and snapshot boundary", () => {
expect(await screen.findByText("+30 ₽")).toBeInTheDocument(); expect(await screen.findByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { const accounts = within(screen.getByRole("list", {
name: /Купон · BOND/, name: /Выплата купонов BOND/,
})); }));
expect(accounts.getAllByRole("listitem")).toHaveLength(2); expect(accounts.getAllByRole("listitem")).toHaveLength(2);
@@ -889,7 +889,7 @@ describe("income period and snapshot boundary", () => {
); );
const view = renderPage(undefined, "all"); const view = renderPage(undefined, "all");
await screen.findByText("Купон · BOND"); await screen.findByText("Выплата купонов BOND");
expect(screen.queryByText("+30 ₽")).not.toBeInTheDocument(); expect(screen.queryByText("+30 ₽")).not.toBeInTheDocument();
view.unmount(); view.unmount();
@@ -440,7 +440,7 @@ describe("AccountOperationsPage", () => {
renderPage(); renderPage();
expect( expect(
await screen.findByRole("heading", { name: "Журнал операций" }), await screen.findByRole("heading", { name: "История операций" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.queryByRole("link", { name: "К счету" }), screen.queryByRole("link", { name: "К счету" }),
@@ -482,7 +482,7 @@ describe("AccountOperationsPage", () => {
renderPage(); renderPage();
expect( expect(
await screen.findByRole("heading", { name: "Журнал операций" }), await screen.findByRole("heading", { name: "История операций" }),
).toBeInTheDocument(); ).toBeInTheDocument();
await waitFor(() => expect(searches.length).toBeGreaterThan(0)); await waitFor(() => expect(searches.length).toBeGreaterThan(0));
const countBefore = searches.length; const countBefore = searches.length;
@@ -521,7 +521,7 @@ describe("AccountOperationsPage", () => {
renderPage(); renderPage();
expect( expect(
await screen.findByRole("heading", { name: "Журнал операций" }), await screen.findByRole("heading", { name: "История операций" }),
).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.click(screen.getByRole("combobox", { name: "Категории" })); fireEvent.click(screen.getByRole("combobox", { name: "Категории" }));
fireEvent.click(await screen.findByText("Налог")); fireEvent.click(await screen.findByText("Налог"));
@@ -579,7 +579,7 @@ describe("AccountOperationsPage", () => {
); );
renderPage(); renderPage();
expect( expect(
await screen.findByText("Не удалось загрузить журнал", undefined, { await screen.findByText("Не удалось загрузить историю операций", undefined, {
timeout: RETRIES_EXHAUSTED_MS, timeout: RETRIES_EXHAUSTED_MS,
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -787,10 +787,10 @@ describe("all-account operation groups", () => {
fireEvent.click(screen.getByRole("button", { name: "Далее" })); fireEvent.click(screen.getByRole("button", { name: "Далее" }));
const accounts = await screen.findByRole("list", { const accounts = await screen.findByRole("list", {
name: /Купон · BOND, Исполнено/, name: /Выплата купонов BOND, Исполнено/,
}); });
expect(screen.getByText("Купон · BOND")).toBeInTheDocument(); expect(screen.getByText("Выплата купонов BOND")).toBeInTheDocument();
expect(screen.getByText("+200 ₽")).toBeInTheDocument(); expect(screen.getByText("+200 ₽")).toBeInTheDocument();
expect(within(accounts).getAllByRole("listitem")).toHaveLength(2); expect(within(accounts).getAllByRole("listitem")).toHaveLength(2);
@@ -850,9 +850,9 @@ describe("AccountPage states", () => {
expect( expect(
screen.getByRole("heading", { name: "Портфель пока пуст" }), screen.getByRole("heading", { name: "Портфель пока пуст" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Открыть журнал" })) expect(screen.getByRole("link", { name: "Открыть историю операций" }))
.toBeInTheDocument(); .toBeInTheDocument();
expect(screen.getByRole("link", { name: "Открыть будущие события" })) expect(screen.getByRole("link", { name: "Открыть предстоящие события" }))
.toBeInTheDocument(); .toBeInTheDocument();
expect(screen.getByRole("link", { name: "Открыть пассивный доход" })) expect(screen.getByRole("link", { name: "Открыть пассивный доход" }))
.toBeInTheDocument(); .toBeInTheDocument();
@@ -893,10 +893,10 @@ describe("AccountPage states", () => {
}), }),
).toBeVisible(); ).toBeVisible();
expect( expect(
screen.getByRole("status", { name: "Загрузка будущих событий" }), screen.getByRole("status", { name: "Загрузка предстоящих событий" }),
).toBeVisible(); ).toBeVisible();
expect( expect(
screen.queryByText("На ближайшие 30 дней событий не найдено."), screen.queryByText("На ближайшие 30 дней предстоящих событий не найдено."),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.getByText("Загрузка дохода текущего месяца")).toBeVisible(); expect(screen.getByText("Загрузка дохода текущего месяца")).toBeVisible();
expect(screen.queryByText("Нет текущих сумм")).not.toBeInTheDocument(); expect(screen.queryByText("Нет текущих сумм")).not.toBeInTheDocument();
@@ -1076,7 +1076,7 @@ describe("AccountPage states", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("tab", { name: "Операции" })); fireEvent.click(screen.getByRole("tab", { name: "История операций" }));
expect( expect(
screen.getByText("Пока недоступно"), screen.getByText("Пока недоступно"),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -1115,7 +1115,7 @@ describe("AccountPage states", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("tab", { name: "Операции" })); fireEvent.click(screen.getByRole("tab", { name: "История операций" }));
expect(screen.getByText("Сделка")).toBeInTheDocument(); expect(screen.getByText("Сделка")).toBeInTheDocument();
expect(screen.getByText("−12 ₽")).toBeInTheDocument(); expect(screen.getByText("−12 ₽")).toBeInTheDocument();
}); });
@@ -1169,8 +1169,8 @@ describe("AccountPage states", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("tab", { name: "События" })); fireEvent.click(screen.getByRole("tab", { name: "Предстоящие события" }));
const section = screen.getByRole("tabpanel", { name: "События" }); const section = screen.getByRole("tabpanel", { name: "Предстоящие события" });
const content = within(section); const content = within(section);
const text = section.textContent ?? ""; const text = section.textContent ?? "";
@@ -1211,15 +1211,15 @@ describe("AccountPage states", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("tab", { name: "События" })); fireEvent.click(screen.getByRole("tab", { name: "Предстоящие события" }));
const content = within( const content = within(
screen.getByRole("tabpanel", { name: "События" }), screen.getByRole("tabpanel", { name: "Предстоящие события" }),
); );
if (state === "loading") { if (state === "loading") {
const skeleton = content.getByRole("status", { const skeleton = content.getByRole("status", {
name: "Загрузка будущих событий", name: "Загрузка предстоящих событий",
}); });
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
@@ -1230,7 +1230,7 @@ describe("AccountPage states", () => {
else { else {
// eslint-disable-next-line vitest/no-conditional-expect -- The table case deterministically selects the empty state. // eslint-disable-next-line vitest/no-conditional-expect -- The table case deterministically selects the empty state.
expect( expect(
content.getByText("На ближайшие 30 дней событий не найдено."), content.getByText("На ближайшие 30 дней предстоящих событий не найдено."),
).toBeVisible(); ).toBeVisible();
} }
@@ -1251,7 +1251,7 @@ describe("AccountPage states", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("tab", { name: "События" })); fireEvent.click(screen.getByRole("tab", { name: "Предстоящие события" }));
expect( expect(
screen.getByText("Данные появятся здесь, когда чтение восстановится."), screen.getByText("Данные появятся здесь, когда чтение восстановится."),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -1522,8 +1522,8 @@ describe("AccountPage states", () => {
.toBeVisible(); .toBeVisible();
expect(screen.queryByText("Вторая")).not.toBeInTheDocument(); expect(screen.queryByText("Вторая")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "Операции" })); fireEvent.click(screen.getByRole("tab", { name: "История операций" }));
expect(screen.getByText("Последних событий пока нет.")).toBeVisible(); expect(screen.getByText("Последних операций пока нет.")).toBeVisible();
}); });
it("links operation, event and income previews to their detail pages", () => { it("links operation, event and income previews to their detail pages", () => {
@@ -1538,8 +1538,8 @@ describe("AccountPage states", () => {
); );
for (const [tab, href] of [ for (const [tab, href] of [
["Операции", "/accounts/account-1/operations"], ["История операций", "/accounts/account-1/operations"],
["События", "/accounts/account-1/events"], ["Предстоящие события", "/accounts/account-1/events"],
["Доход", "/accounts/account-1/income"], ["Доход", "/accounts/account-1/income"],
]) { ]) {
fireEvent.click(screen.getByRole("tab", { name: tab })); fireEvent.click(screen.getByRole("tab", { name: tab }));
@@ -1655,7 +1655,7 @@ describe("AccountPage states", () => {
await screen.findByRole("heading", { level: 1, name: "Все счета" }); await screen.findByRole("heading", { level: 1, name: "Все счета" });
const operationGroup = screen.getByRole("list", { const operationGroup = screen.getByRole("list", {
name: /Купон · BOND, Исполнено/, name: /Выплата купонов BOND, Исполнено/,
}); });
expect(screen.getByText("+500 ₽")).toBeInTheDocument(); expect(screen.getByText("+500 ₽")).toBeInTheDocument();
+1 -1
View File
@@ -351,7 +351,7 @@ describe("bond page", () => {
screen.queryByRole("heading", { name: "Моя позиция" }), screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect( expect(
screen.getByRole("heading", { name: "Моя история событий" }), screen.getByRole("heading", { name: "История операций" }),
).toBeVisible(); ).toBeVisible();
}); });
it( it(
@@ -490,7 +490,7 @@ describe("stock page", () => {
fireEvent.click(screen.getByRole("button", { name: "Далее" })); fireEvent.click(screen.getByRole("button", { name: "Далее" }));
controls.recoverOperations(); controls.recoverOperations();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Моя история событий" })).toBeVisible(); expect(screen.getByRole("heading", { name: "История операций" })).toBeVisible();
}, },
); );
it("keeps the last quote without manual refresh", async () => { it("keeps the last quote without manual refresh", async () => {
+26 -25
View File
@@ -274,7 +274,7 @@ const ru = {
COUPON_TYPE_VARIABLE: "Переменный", COUPON_TYPE_VARIABLE: "Переменный",
}, },
couponWindow: couponWindow:
"Календарь выпуска с {{from}} по {{to}}. Личные зачисления показаны в истории событий.", "Календарь выпуска с {{from}} по {{to}}. Полученные выплаты показаны в истории операций.",
couponYield: "Текущая купонная доходность, годовых", couponYield: "Текущая купонная доходность, годовых",
couponYieldNote: couponYieldNote:
"Купонная доходность = ближайший купон × 365 / дней в купонном периоде / цена без НКД × 100 %. Это годовая оценка при сохранении размера купона и номинала, без налогов, комиссий и реинвестирования; для плавающего или переменного купона будущий доход изменится.", "Купонная доходность = ближайший купон × 365 / дней в купонном периоде / цена без НКД × 100 %. Это годовая оценка при сохранении размера купона и номинала, без налогов, комиссий и реинвестирования; для плавающего или переменного купона будущий доход изменится.",
@@ -343,10 +343,10 @@ const ru = {
breadcrumbs: { breadcrumbs: {
accountFallback: "Счет", accountFallback: "Счет",
accounts: "Брокерские счета", accounts: "Брокерские счета",
events: "Будущие события", events: "Предстоящие события",
income: "Пассивный доход", income: "Пассивный доход",
label: "Хлебные крошки", label: "Хлебные крошки",
operations: "Операции", operations: "История операций",
settings: "Настройки", settings: "Настройки",
}, },
entryGroup: { entryGroup: {
@@ -356,20 +356,20 @@ const ru = {
dateKind: { event: "Дата события", registry: "Дата реестра" }, dateKind: { event: "Дата события", registry: "Дата реестра" },
emptyText: emptyText:
"Доступные источники не сообщили о купонах, дивидендах, погашениях или офертах в этом периоде.", "Доступные источники не сообщили о купонах, дивидендах, погашениях или офертах в этом периоде.",
emptyTitle: "За выбранный период событий не найдено", emptyTitle: "На выбранный период предстоящих событий не найдено",
errorText: "Попробуйте запросить прогноз еще раз.", errorText: "Попробуйте загрузить предстоящие события ещё раз.",
errorTitle: "Не удалось загрузить будущие события", errorTitle: "Не удалось загрузить предстоящие события",
forecastWindow: "Прогноз от {{date}} на {{from}} – {{to}}", forecastWindow: "Прогноз от {{date}} на {{from}} – {{to}}",
hint: "Прогноз рассчитан по текущей позиции до налогов. Покрытие зависит от доступных данных MOEX.", hint: "Прогноз рассчитан по текущей позиции до налогов. Покрытие зависит от доступных данных MOEX.",
noAmount: "Сумма неизвестна", noAmount: "Сумма неизвестна",
open: "Открыть будущие события", open: "Открыть предстоящие события",
projectedAmount: "Ожидаемая сумма", projectedAmount: "Ожидаемая сумма",
recentEmpty: "На ближайшие 30 дней событий не найдено.", recentEmpty: "На ближайшие 30 дней предстоящих событий не найдено.",
recentError: "Не удалось загрузить будущие события.", recentError: "Не удалось загрузить предстоящие события.",
recentLoading: "Загрузка будущих событий", recentLoading: "Загрузка предстоящих событий",
recentTitle: "Будущие события", recentTitle: "Предстоящие события",
retry: "Повторить", retry: "Повторить",
title: "Будущие события", title: "Предстоящие события",
type: { type: {
coupon: "Купон", coupon: "Купон",
dividend: "Дивиденд", dividend: "Дивиденд",
@@ -443,6 +443,7 @@ const ru = {
transfer: "Перевод", transfer: "Перевод",
}, },
clearPeriod: "Очистить период", clearPeriod: "Очистить период",
couponPayment: "Выплата купонов {{instrument}}",
emptyText: "Измените период или фильтры.", emptyText: "Измените период или фильтры.",
emptyTitle: "Операций нет", emptyTitle: "Операций нет",
entryLabel: { entryLabel: {
@@ -451,14 +452,14 @@ const ru = {
dividend: "Дивиденд", dividend: "Дивиденд",
}, },
}, },
errorText: "Попробуйте запросить операции еще раз.", errorText: "Попробуйте загрузить историю операций ещё раз.",
errorTitle: "Не удалось загрузить журнал", errorTitle: "Не удалось загрузить историю операций",
from: "С", from: "С",
next: "Далее", next: "Далее",
noInstrument: "Без инструмента", noInstrument: "Без инструмента",
noMoney: "Без движения денег", noMoney: "Без движения денег",
occurredAt: "Исполнено {{date}}", occurredAt: "Исполнено {{date}}",
open: "Открыть журнал", open: "Открыть историю операций",
operationType: { operationType: {
buy: "Покупка", buy: "Покупка",
coupon: "Купон", coupon: "Купон",
@@ -475,13 +476,13 @@ const ru = {
previous: "Назад", previous: "Назад",
price: "Цена: {{value}}", price: "Цена: {{value}}",
quantity: "{{value}} шт.", quantity: "{{value}} шт.",
recentEmpty: "Последних событий пока нет.", recentEmpty: "Последних операций пока нет.",
recentError: "Не удалось загрузить последние события.", recentError: "Не удалось загрузить последние операции.",
recentLoading: "Загрузка последних событий", recentLoading: "Загрузка последних операций",
recentTitle: "Последние события", recentTitle: "Последние операции",
reset: "Сбросить", reset: "Сбросить",
retry: "Повторить", retry: "Повторить",
title: "Журнал операций", title: "История операций",
to: "По", to: "По",
}, },
navigation: { navigation: {
@@ -582,9 +583,9 @@ const ru = {
}, },
tabs: { tabs: {
cash: "Деньги", cash: "Деньги",
events: "События", events: "Предстоящие события",
income: "Доход", income: "Доход",
operations: "Операции", operations: "История операций",
positions: "Позиции", positions: "Позиции",
}, },
title: "Портфель", title: "Портфель",
@@ -637,10 +638,10 @@ const ru = {
dividendNote: dividendNote:
"Личная сумма — оценка по текущему количеству, не гарантия выплаты. Налоги не сопоставлены; дата выплаты компании может отличаться от дня зачисления на ваш счёт.", "Личная сумма — оценка по текущему количеству, не гарантия выплаты. Налоги не сопоставлены; дата выплаты компании может отличаться от дня зачисления на ваш счёт.",
dividendWindow: dividendWindow:
"События компании с {{from}} по {{to}}. Личные зачисления показаны отдельно в истории событий.", "События компании с {{from}} по {{to}}. Полученные выплаты показаны отдельно в истории операций.",
estimateUnavailable: estimateUnavailable:
"Данные позиции не удалось обновить. Личная оценка недоступна или рассчитана по сохранённой позиции.", "Данные позиции не удалось обновить. Личная оценка недоступна или рассчитана по сохранённой позиции.",
eventFilter: "Фильтр событий", eventFilter: "Фильтр операций",
eventPrice: "Цена: {{value}}", eventPrice: "Цена: {{value}}",
eventQuantity: "{{value}} шт.", eventQuantity: "{{value}} шт.",
exchanges: { exchanges: {
@@ -656,7 +657,7 @@ const ru = {
trades: "Сделки", trades: "Сделки",
}, },
forming: "Формируется", forming: "Формируется",
history: "Моя история событий", history: "История операций",
historyNote: historyNote:
"Операции по этой акции на доступных открытых счетах, включая периоды до текущего года. Дата сделки — дата операции брокера; количество — исполненная часть. История закрытых счетов не включена.", "Операции по этой акции на доступных открытых счетах, включая периоды до текущего года. Дата сделки — дата операции брокера; количество — исполненная часть. История закрытых счетов не включена.",
incomplete: "Последняя свеча ещё формируется: её цена может измениться.", incomplete: "Последняя свеча ещё формируется: её цена может измениться.",
@@ -4,6 +4,40 @@
background: var(--wl-color-subtle); background: var(--wl-color-subtle);
} }
.flat {
background: var(--wl-color-surface);
}
.flat > :first-child {
border: 0;
background: transparent;
}
.flat .accounts {
padding-bottom: var(--mantine-spacing-md);
}
.flat .accountRow {
padding-block: var(--mantine-spacing-md);
}
.flat .accountRow:last-child {
padding-bottom: 0;
}
.flat .accountRow > :first-child {
min-width: 0;
overflow-wrap: anywhere;
}
.positive {
color: var(--wl-color-positive);
}
.negative {
color: var(--wl-color-negative);
}
.accounts { .accounts {
padding: 0 var(--mantine-spacing-md) var(--mantine-spacing-sm); padding: 0 var(--mantine-spacing-md) var(--mantine-spacing-sm);
list-style: none; list-style: none;
@@ -38,4 +72,12 @@
.accountRow { .accountRow {
align-items: flex-start; align-items: flex-start;
} }
.flat .accountRow {
flex-wrap: wrap;
}
.flat .accountValue {
white-space: normal;
}
} }
+23 -14
View File
@@ -9,11 +9,15 @@ import classes from "./EntryGroup.module.css";
type AccountRowProps = { type AccountRowProps = {
accountName: string; accountName: string;
tone?: "negative"
| "neutral"
| "positive";
value: string; value: string;
valueLabel: string; valueLabel: string;
}; };
type EntryGroupProps = PropsWithChildren<{ type EntryGroupProps = PropsWithChildren<{
flat?: boolean;
header: ReactNode; header: ReactNode;
label: string; label: string;
}>; }>;
@@ -25,13 +29,14 @@ type EntryGroupTotalProps = {
const AccountRow: FC<AccountRowProps> = (props) => { const AccountRow: FC<AccountRowProps> = (props) => {
const accessibleName = `${props.accountName}, ${props.value}`; const accessibleName = `${props.accountName}, ${props.value}`;
const valueClass = props.tone ? classes[props.tone] : undefined;
return ( return (
<li aria-label={accessibleName} className={classes.accountRow}> <li aria-label={accessibleName} className={classes.accountRow}>
<Text>{props.accountName}</Text> <Text>{props.accountName}</Text>
<Stack className={classes.accountValue} gap={2}> <Stack className={classes.accountValue} gap={2}>
<Text fw={700}>{props.value}</Text> <Text className={valueClass} fw={700}>{props.value}</Text>
<Text c="dimmed" size="xs">{props.valueLabel}</Text> <Text c="dimmed" size="xs">{props.valueLabel}</Text>
</Stack> </Stack>
@@ -53,20 +58,24 @@ const AccountCardRow: FC<AccountRowProps> = props => (
</li> </li>
); );
const EntryGroup: FC<EntryGroupProps> = props => ( const EntryGroup: FC<EntryGroupProps> = (props) => {
<Paper className={classes.group} component="li" withBorder> const groupClass = props.flat ? `${classes.group} ${classes.flat}` : classes.group;
{props.header}
<Stack return (
aria-label={props.label} <Paper className={groupClass} component="li" withBorder>
className={classes.accounts} {props.header}
component="ul"
gap={0} <Stack
> aria-label={props.label}
{props.children} className={classes.accounts}
</Stack> component="ul"
</Paper> gap={0}
); >
{props.children}
</Stack>
</Paper>
);
};
const EntryGroupTotal: FC<EntryGroupTotalProps> = (props) => { const EntryGroupTotal: FC<EntryGroupTotalProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();