refactor: align frontend ownership boundaries

Close audit gaps across API drift, query policy, UI, and tests.
This commit is contained in:
2026-09-06 18:20:05 +03:00
parent 175727bc93
commit c97f62bd98
118 changed files with 2190 additions and 2254 deletions
@@ -1,33 +1,10 @@
import {
Button,
Card,
Group,
Skeleton,
Stack,
Text,
Title,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { formatMoney } from "@/entities/portfolio";
import { LoadingSkeleton, StateView } from "@/shared/ui";
type AccountEventsPageErrorProps = {
retry: () => void;
};
type AccountEventsPageLoadedProps = {
events: BrokerageEvent[];
};
type AccountEventRowProps = {
event: BrokerageEvent;
};
import { useBrokerageEvents } from "@/entities/brokerage-event";
import { AccountEventsPageEmpty } from "./components/AccountEventsPageEmpty.view";
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
const AccountEventsPage: FC = () => {
const { accountId } = useParams();
@@ -40,98 +17,4 @@ const AccountEventsPage: FC = () => {
return <AccountEventsPageLoaded events={events.data.events} />;
};
const AccountEventsPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="40%" />
<Skeleton height={18} width="70%" />
{[0, 1, 2, 3].map((index) => (
<Skeleton key={index} height={82} radius="md" />
))}
</LoadingSkeleton>
);
};
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { retry } = props;
const { t } = useTranslation();
return (
<StateView
error
title={t("events.errorTitle")}
text={t("events.errorText")}
action={<Button onClick={retry}>{t("events.retry")}</Button>}
/>
);
};
const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title>
<Text c="dimmed">{t("events.hint")}</Text>
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
</Stack>
);
};
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { events } = props;
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title>
<Text c="dimmed">{t("events.hint")}</Text>
{events.map((event) => (
<AccountEventRow
event={event}
key={`${event.type}-${event.date}-${event.ticker}`}
/>
))}
</Stack>
);
};
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 { AccountEventsPage };
@@ -0,0 +1,44 @@
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 };
@@ -0,0 +1,17 @@
import { Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title>
<Text c="dimmed">{t("events.hint")}</Text>
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
</Stack>
);
};
export { AccountEventsPageEmpty };
@@ -0,0 +1,20 @@
import { Button } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
type AccountEventsPageErrorProps = { retry: () => void };
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation();
return (
<StateView
error
title={t("events.errorTitle")}
text={t("events.errorText")}
action={<Button onClick={props.retry}>{t("events.retry")}</Button>}
/>
);
};
export { AccountEventsPageError };
@@ -0,0 +1,25 @@
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";
type AccountEventsPageLoadedProps = { events: BrokerageEvent[] };
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { t } = useTranslation();
return (
<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>
);
};
export { AccountEventsPageLoaded };
@@ -0,0 +1,19 @@
import { Skeleton } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
const AccountEventsPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="40%" />
<Skeleton height={18} width="70%" />
{[0, 1, 2, 3].map((index) => (
<Skeleton key={index} height={82} radius="md" />
))}
</LoadingSkeleton>
);
};
export { AccountEventsPageLoading };
@@ -1,13 +1,14 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { IncomeAnalytics } from "@/entities/income";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { AccountIncomePage } from "./AccountIncomePage";
import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm";
const analytics = {
accountId: "account-1",
@@ -41,7 +42,7 @@ const analytics = {
},
],
entries: [entry()],
};
} satisfies IncomeAnalytics;
function money(currency: string, value: string) {
return { currency, value };
@@ -65,7 +66,7 @@ function comparison(currency: string) {
};
}
function entry() {
function entry(): IncomeAnalytics["entries"][number] {
return {
id: "coupon-1",
occurredAt: "2026-08-01T12:00:00Z",
@@ -91,36 +92,45 @@ function renderPage() {
refresh: async () => true,
clear: vi.fn(),
});
const Wrapper: FC<PropsWithChildren> = (props) => (
<MantineProvider>
<QueryClientProvider client={new QueryClient()}>
{props.children}
</QueryClientProvider>
</MantineProvider>
);
const { wrapper: QueryWrapper } = createQueryWrapper();
return render(
<Wrapper>
<MemoryRouter
initialEntries={[
"/accounts/account-1/income?from=2026-08-01&to=2026-08-27",
]}
>
<Routes>
<Route
path="/accounts/:accountId/income"
element={<AccountIncomePage />}
/>
<Route path="/accounts/:accountId/events" element={<h1>Events</h1>} />
</Routes>
</MemoryRouter>
</Wrapper>,
<MantineProvider>
<QueryWrapper>
<MemoryRouter
initialEntries={[
"/accounts/account-1/income?from=2026-08-01&to=2026-08-27",
]}
>
<Routes>
<Route
path="/accounts/:accountId/income"
element={<AccountIncomePage />}
/>
<Route
path="/accounts/:accountId/events"
element={<h1>Events</h1>}
/>
</Routes>
</MemoryRouter>
</QueryWrapper>
</MantineProvider>,
);
}
describe("AccountIncomePage", () => {
afterEach(() => {
cleanup();
configureAuthClient(null);
it("rejects malformed calendar dates without throwing", () => {
expect(isDate("2026-99-99")).toBe(false);
expect(isDate("2026-02-29")).toBe(false);
expect(isDate("2024-02-29")).toBe(true);
});
it("groups income entries once in the view model", () => {
const display = toIncomeDisplayModel(analytics);
expect(display.instruments[0].entries).toEqual([analytics.entries[0]]);
expect(display.trend[0].buckets[0].total).toEqual(
analytics.buckets[0].totalsByCurrency[0],
);
});
it("renders realized analytics, drill-down, and the independent forecast", async () => {
@@ -1,10 +1,8 @@
import type { FC } from "react";
import {
AccountIncomePageError,
AccountIncomePageLoaded,
AccountIncomePageLoading,
} from "./AccountIncomePage.view";
import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm";
import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
const AccountIncomePage: FC = () => {
const viewModel = useAccountIncomePageViewModel();
@@ -1,388 +0,0 @@
import {
Accordion,
Alert,
Button,
Group,
Paper,
SegmentedControl,
Skeleton,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import type { FC } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import type { IncomeAnalytics } from "@/entities/income";
import { formatMoney } from "@/entities/portfolio";
import { LoadingSkeleton, StateView } from "@/shared/ui";
import styles from "./AccountIncomePage.module.css";
import type { AccountIncomePageViewModel } from "./AccountIncomePage.vm";
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
const AccountIncomePageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="40%" />
<Skeleton height={120} radius="md" />
<Skeleton height={260} radius="md" />
</LoadingSkeleton>
);
};
const AccountIncomePageError: FC<ErrorProps> = (props) => {
const { t } = useTranslation();
return (
<StateView
error
title={t("income.errorTitle")}
text={t("income.errorText")}
action={<Button onClick={props.retry}>{t("income.retry")}</Button>}
/>
);
};
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
const { analytics, form, forecast, onSubmit, onReset } = props;
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("income.title")}</Title>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Group align="end">
<TextInput
error={form.formState.errors.from?.message}
label={t("income.from")}
type="date"
{...form.register("from")}
/>
<TextInput
error={form.formState.errors.to?.message}
label={t("income.to")}
type="date"
{...form.register("to")}
/>
<Button type="submit">{t("income.apply")}</Button>
<Button onClick={onReset} variant="subtle">
{t("income.reset")}
</Button>
</Group>
</form>
<Text c="dimmed">
{analytics.period.from} — {analytics.period.to}
</Text>
{analytics.totalsByCurrency.length ? (
<IncomeSummary totals={analytics.totalsByCurrency} />
) : (
<StateView
title={t("income.emptyTitle")}
text={t("income.emptyText")}
/>
)}
<Alert color="blue">{t("income.taxBoundary")}</Alert>
<IncomeComparisons analytics={analytics} />
<IncomeTrend analytics={analytics} />
<IncomeBreakdown analytics={analytics} />
<IncomeForecast forecast={forecast} />
</Stack>
);
};
const IncomeSummary: FC<{ totals: IncomeAnalytics["totalsByCurrency"] }> = (
props,
) => {
const { t } = useTranslation();
return (
<Stack>
{props.totals.map((total) => (
<Paper key={total.currency} p="md" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
<Text c="dimmed" size="sm">
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(total.dividend)}
</Text>
</Paper>
))}
</Stack>
);
};
const IncomeComparisons: FC<{ analytics: IncomeAnalytics }> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<Title order={2}>{t("income.comparisons")}</Title>
<Comparison
title={t("income.previous", {
from: props.analytics.previous.period.from,
to: props.analytics.previous.period.to,
})}
values={props.analytics.previous.totalsByCurrency}
/>
{props.analytics.yearComparison ? (
<Comparison
title={t("income.yearAgo", {
from: props.analytics.yearComparison.period.from,
to: props.analytics.yearComparison.period.to,
})}
values={props.analytics.yearComparison.totalsByCurrency}
/>
) : (
<Text c="dimmed">{t("income.yearUnavailable")}</Text>
)}
</Stack>
);
};
const Comparison: FC<{
title: string;
values: IncomeAnalytics["previous"]["totalsByCurrency"];
}> = (props) => {
const { t } = useTranslation();
return (
<Paper p="sm" withBorder>
<Text fw={700}>{props.title}</Text>
{props.values.map((value) => (
<Stack gap={2} key={value.currency}>
<Text size="sm">{value.currency}</Text>
<Text size="sm">
{t("income.selected")}: {t("income.coupon")}{" "}
{formatMoney(value.selected.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.selected.dividend)} · {t("income.total")}{" "}
{formatMoney(value.selected.total)}
</Text>
<Text size="sm">
{t("income.compared")}: {t("income.coupon")}{" "}
{formatMoney(value.compared.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.compared.dividend)} · {t("income.total")}{" "}
{formatMoney(value.compared.total)}
</Text>
<Text size="sm">
{t("income.difference")}: {t("income.coupon")}{" "}
{formatMoney(value.difference.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.difference.dividend)} · {t("income.total")}{" "}
{formatMoney(value.difference.total)}
</Text>
</Stack>
))}
</Paper>
);
};
const IncomeTrend: FC<{ analytics: IncomeAnalytics }> = (props) => {
const currencies = props.analytics.totalsByCurrency.map(
(total) => total.currency,
);
const [currency, setCurrency] = useState(currencies[0] ?? "");
const { t } = useTranslation();
const buckets = props.analytics.buckets.map((bucket) => ({
period: bucket.period,
total:
bucket.totalsByCurrency.find((item) => item.currency === currency) ??
zeroSummary(currency),
}));
const values = buckets.map((bucket) => ({
...bucket,
coupon: Number(bucket.total.coupon.value),
dividend: Number(bucket.total.dividend.value),
}));
const scale = Math.max(
1,
...values.map((value) =>
Math.max(
positive(value.coupon) + positive(value.dividend),
negative(value.coupon) + negative(value.dividend),
),
),
);
return (
<Stack>
<Title order={2}>{t("income.trend")}</Title>
{currencies.length > 1 ? (
<SegmentedControl
aria-label={t("income.currency")}
data={currencies}
onChange={setCurrency}
value={currency}
/>
) : null}
<div aria-hidden="true" className={styles.trendChart}>
{values.map((value) => (
<div className={styles.trendColumn} key={value.period.from}>
<span className={styles.baseline} />
<div className={styles.positiveStack}>
{value.coupon > 0 ? (
<span
className={styles.coupon}
data-testid={`trend-positive-coupon-${value.period.from}`}
style={{ flexBasis: chartHeight(value.coupon, scale) }}
/>
) : null}
{value.dividend > 0 ? (
<span
className={styles.dividend}
data-testid={`trend-positive-dividend-${value.period.from}`}
style={{ flexBasis: chartHeight(value.dividend, scale) }}
/>
) : null}
</div>
<div className={styles.negativeStack}>
{value.coupon < 0 ? (
<span
className={styles.coupon}
data-testid={`trend-negative-coupon-${value.period.from}`}
style={{ flexBasis: chartHeight(value.coupon, scale) }}
/>
) : null}
{value.dividend < 0 ? (
<span
className={styles.dividend}
data-testid={`trend-negative-dividend-${value.period.from}`}
style={{ flexBasis: chartHeight(value.dividend, scale) }}
/>
) : null}
</div>
</div>
))}
</div>
<Stack gap={2}>
{buckets.map((bucket) => (
<Text key={bucket.period.from} size="sm">
{bucket.period.from} — {bucket.period.to}: {t("income.coupon")}{" "}
{formatMoney(bucket.total?.coupon ?? null)}, {t("income.dividend")}{" "}
{formatMoney(bucket.total?.dividend ?? null)}
</Text>
))}
</Stack>
</Stack>
);
};
const IncomeBreakdown: FC<{ analytics: IncomeAnalytics }> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<Title order={2}>{t("income.instruments")}</Title>
<Accordion>
{props.analytics.instruments.map((instrument) => (
<Accordion.Item key={instrument.key} value={instrument.key}>
<Accordion.Control>
{instrument.displayName} · {formatMoney(instrument.total)} ·{" "}
{instrument.receiptCount}
</Accordion.Control>
<Accordion.Panel>
<Text size="sm">
{t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(instrument.dividend)}
</Text>
<Stack mt="sm">
{props.analytics.entries
.filter((entry) => entryKey(entry) === instrument.key)
.map((entry) => (
<LedgerEntryRow key={entry.id} operation={entry} />
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</Stack>
);
};
const IncomeForecast: FC<{ forecast: LoadedProps["forecast"] }> = (props) => {
const { t } = useTranslation();
if (props.forecast.state === "loading")
return <Text c="dimmed">{t("income.forecastLoading")}</Text>;
if (props.forecast.state === "error")
return (
<StateView
error
title={t("income.forecastError")}
text={t("income.errorText")}
action={
<Button onClick={props.forecast.retry}>{t("income.retry")}</Button>
}
/>
);
if (props.forecast.state === "empty")
return (
<StateView
title={t("income.forecastTitle")}
text={t("income.forecastEmpty")}
/>
);
if (props.forecast.state !== "loaded") return null;
return (
<Stack>
<Title order={2}>{t("income.forecastTitle")}</Title>
<Alert color="yellow">{t("income.forecastWarning")}</Alert>
{props.forecast.totals.map((total) => (
<Text key={total.currency} fw={700}>
{formatMoney(total)}
</Text>
))}
{props.forecast.events.map((event) => (
<ForecastEvent
event={event}
key={`${event.type}-${event.date}-${event.ticker}`}
/>
))}
<Button component={Link} to="../events" variant="subtle">
{t("events.open")}
</Button>
</Stack>
);
};
const ForecastEvent: FC<{ event: BrokerageEvent }> = (props) => (
<Paper p="sm" withBorder>
<Text fw={700}>{props.event.displayName}</Text>
<Text size="sm">
{props.event.date} · {formatMoney(props.event.projectedAmount)}
</Text>
</Paper>
);
function entryKey(entry: IncomeAnalytics["entries"][number]): string {
const base = entry.instrumentUid
? `uid:${entry.instrumentUid}`
: entry.figi
? `figi:${entry.figi}`
: entry.ticker || entry.instrumentType
? `ticker:${entry.ticker ?? ""}:${entry.instrumentType ?? ""}`
: `operation:${entry.id}`;
return `${base}:${entry.payment?.currency ?? ""}`;
}
function zeroSummary(
currency: string,
): IncomeAnalytics["totalsByCurrency"][number] {
const zero = { currency, value: "0" };
return { currency, coupon: zero, dividend: zero, total: zero };
}
function positive(value: number): number {
return Math.max(0, value);
}
function negative(value: number): number {
return Math.max(0, -value);
}
function chartHeight(value: number, scale: number): string {
return `${(Math.abs(value) / scale) * 100}%`;
}
export {
AccountIncomePageError,
AccountIncomePageLoaded,
AccountIncomePageLoading,
};
@@ -20,12 +20,26 @@ type IncomeForecast =
events: BrokerageEvent[];
};
type IncomeDisplayModel = {
trend: {
currency: string;
buckets: {
period: IncomeAnalytics["buckets"][number]["period"];
total: IncomeAnalytics["totalsByCurrency"][number];
}[];
}[];
instruments: (IncomeAnalytics["instruments"][number] & {
entries: IncomeAnalytics["entries"];
})[];
};
type AccountIncomePageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void; period: IncomePeriod }
| {
state: "loaded";
analytics: IncomeAnalytics;
display: IncomeDisplayModel;
forecast: IncomeForecast;
form: ReturnType<typeof useForm<IncomeForm>>;
onReset: () => void;
@@ -81,6 +95,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
return {
state: "loaded",
analytics: analytics.data,
display: toIncomeDisplayModel(analytics.data),
forecast: toForecast(events),
form,
onSubmit,
@@ -126,12 +141,59 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod {
}
function isDate(value: string): boolean {
if (!datePattern.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return (
datePattern.test(value) &&
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value
Number.isFinite(parsed.valueOf()) &&
parsed.toISOString().slice(0, 10) === value
);
}
function toIncomeDisplayModel(analytics: IncomeAnalytics): IncomeDisplayModel {
const entriesByInstrument = new Map<string, IncomeAnalytics["entries"]>();
for (const entry of analytics.entries) {
const key = incomeEntryKey(entry);
entriesByInstrument.set(key, [
...(entriesByInstrument.get(key) ?? []),
entry,
]);
}
return {
trend: analytics.totalsByCurrency.map(({ currency }) => ({
currency,
buckets: analytics.buckets.map((bucket) => ({
period: bucket.period,
total:
bucket.totalsByCurrency.find((item) => item.currency === currency) ??
zeroSummary(currency),
})),
})),
instruments: analytics.instruments.map((instrument) => ({
...instrument,
entries: entriesByInstrument.get(instrument.key) ?? [],
})),
};
}
function incomeEntryKey(entry: IncomeAnalytics["entries"][number]): string {
const base = entry.instrumentUid
? `uid:${entry.instrumentUid}`
: entry.figi
? `figi:${entry.figi}`
: entry.ticker || entry.instrumentType
? `ticker:${entry.ticker ?? ""}:${entry.instrumentType ?? ""}`
: `operation:${entry.id}`;
return `${base}:${entry.payment?.currency ?? ""}`;
}
function zeroSummary(
currency: string,
): IncomeAnalytics["totalsByCurrency"][number] {
const zero = { currency, value: "0" };
return { currency, coupon: zero, dividend: zero, total: zero };
}
function moscowDate(value: Date): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Moscow",
@@ -144,4 +206,9 @@ function moscowDate(value: Date): string {
return `${part("year")}-${part("month")}-${part("day")}`;
}
export { type AccountIncomePageViewModel, useAccountIncomePageViewModel };
export {
type AccountIncomePageViewModel,
isDate,
toIncomeDisplayModel,
useAccountIncomePageViewModel,
};
@@ -0,0 +1,21 @@
import { Button } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
const AccountIncomePageError: FC<ErrorProps> = (props) => {
const { t } = useTranslation();
return (
<StateView
error
title={t("income.errorTitle")}
text={t("income.errorText")}
action={<Button onClick={props.retry}>{t("income.retry")}</Button>}
/>
);
};
export { AccountIncomePageError };
@@ -0,0 +1,68 @@
import {
Alert,
Button,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import { IncomeBreakdown } from "./IncomeBreakdown.view";
import { IncomeComparisons } from "./IncomeComparisons.view";
import { IncomeForecast } from "./IncomeForecast.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { IncomeTrend } from "./IncomeTrend.view";
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
const { analytics, display, form, forecast, onSubmit, onReset } = props;
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("income.title")}</Title>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Group align="end">
<TextInput
error={form.formState.errors.from?.message}
label={t("income.from")}
type="date"
{...form.register("from")}
/>
<TextInput
error={form.formState.errors.to?.message}
label={t("income.to")}
type="date"
{...form.register("to")}
/>
<Button type="submit">{t("income.apply")}</Button>
<Button onClick={onReset} variant="subtle">
{t("income.reset")}
</Button>
</Group>
</form>
<Text c="dimmed">
{analytics.period.from} — {analytics.period.to}
</Text>
{analytics.totalsByCurrency.length ? (
<IncomeSummary totals={analytics.totalsByCurrency} />
) : (
<StateView
title={t("income.emptyTitle")}
text={t("income.emptyText")}
/>
)}
<Alert color="blue">{t("income.taxBoundary")}</Alert>
<IncomeComparisons analytics={analytics} />
<IncomeTrend trend={display.trend} />
<IncomeBreakdown instruments={display.instruments} />
<IncomeForecast forecast={forecast} />
</Stack>
);
};
export { AccountIncomePageLoaded };
@@ -0,0 +1,17 @@
import { Skeleton } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
const AccountIncomePageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="40%" />
<Skeleton height={120} radius="md" />
<Skeleton height={260} radius="md" />
</LoadingSkeleton>
);
};
export { AccountIncomePageLoading };
@@ -0,0 +1,43 @@
import { Accordion, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { formatMoney } from "@/shared/lib";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
type IncomeBreakdownProps = {
instruments: LoadedProps["display"]["instruments"];
};
const IncomeBreakdown: FC<IncomeBreakdownProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<Title order={2}>{t("income.instruments")}</Title>
<Accordion>
{props.instruments.map((instrument) => (
<Accordion.Item key={instrument.key} value={instrument.key}>
<Accordion.Control>
{instrument.displayName} · {formatMoney(instrument.total)} ·{" "}
{instrument.receiptCount}
</Accordion.Control>
<Accordion.Panel>
<Text size="sm">
{t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(instrument.dividend)}
</Text>
<Stack mt="sm">
{instrument.entries.map((entry) => (
<LedgerEntryRow key={entry.id} operation={entry} />
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</Stack>
);
};
export { IncomeBreakdown };
@@ -0,0 +1,72 @@
import { Paper, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { IncomeAnalytics } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
type IncomeComparisonsProps = { analytics: IncomeAnalytics };
type ComparisonProps = {
title: string;
values: IncomeAnalytics["previous"]["totalsByCurrency"];
};
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<Title order={2}>{t("income.comparisons")}</Title>
<Comparison
title={t("income.previous", {
from: props.analytics.previous.period.from,
to: props.analytics.previous.period.to,
})}
values={props.analytics.previous.totalsByCurrency}
/>
{props.analytics.yearComparison ? (
<Comparison
title={t("income.yearAgo", {
from: props.analytics.yearComparison.period.from,
to: props.analytics.yearComparison.period.to,
})}
values={props.analytics.yearComparison.totalsByCurrency}
/>
) : (
<Text c="dimmed">{t("income.yearUnavailable")}</Text>
)}
</Stack>
);
};
const Comparison: FC<ComparisonProps> = (props) => {
const { t } = useTranslation();
return (
<Paper p="sm" withBorder>
<Text fw={700}>{props.title}</Text>
{props.values.map((value) => (
<Stack gap={2} key={value.currency}>
<Text size="sm">{value.currency}</Text>
<Text size="sm">
{t("income.selected")}: {t("income.coupon")}{" "}
{formatMoney(value.selected.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.selected.dividend)} · {t("income.total")}{" "}
{formatMoney(value.selected.total)}
</Text>
<Text size="sm">
{t("income.compared")}: {t("income.coupon")}{" "}
{formatMoney(value.compared.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.compared.dividend)} · {t("income.total")}{" "}
{formatMoney(value.compared.total)}
</Text>
<Text size="sm">
{t("income.difference")}: {t("income.coupon")}{" "}
{formatMoney(value.difference.coupon)} · {t("income.dividend")}{" "}
{formatMoney(value.difference.dividend)} · {t("income.total")}{" "}
{formatMoney(value.difference.total)}
</Text>
</Stack>
))}
</Paper>
);
};
export { IncomeComparisons };
@@ -0,0 +1,68 @@
import { Alert, Button, Paper, Stack, Text, Title } 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 { StateView } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
type IncomeForecastProps = { forecast: LoadedProps["forecast"] };
type ForecastEventProps = { event: BrokerageEvent };
const IncomeForecast: FC<IncomeForecastProps> = (props) => {
const { t } = useTranslation();
if (props.forecast.state === "loading")
return <Text c="dimmed">{t("income.forecastLoading")}</Text>;
if (props.forecast.state === "error")
return (
<StateView
error
title={t("income.forecastError")}
text={t("income.errorText")}
action={
<Button onClick={props.forecast.retry}>{t("income.retry")}</Button>
}
/>
);
if (props.forecast.state === "empty")
return (
<StateView
title={t("income.forecastTitle")}
text={t("income.forecastEmpty")}
/>
);
if (props.forecast.state !== "loaded") return null;
return (
<Stack>
<Title order={2}>{t("income.forecastTitle")}</Title>
<Alert color="yellow">{t("income.forecastWarning")}</Alert>
{props.forecast.totals.map((total) => (
<Text key={total.currency} fw={700}>
{formatMoney(total)}
</Text>
))}
{props.forecast.events.map((event) => (
<ForecastEvent
event={event}
key={`${event.type}-${event.date}-${event.ticker}`}
/>
))}
<Button component={Link} to="../events" variant="subtle">
{t("events.open")}
</Button>
</Stack>
);
};
const ForecastEvent: FC<ForecastEventProps> = (props) => (
<Paper p="sm" withBorder>
<Text fw={700}>{props.event.displayName}</Text>
<Text size="sm">
{props.event.date} · {formatMoney(props.event.projectedAmount)}
</Text>
</Paper>
);
export { IncomeForecast };
@@ -0,0 +1,26 @@
import { Paper, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { IncomeAnalytics } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
type IncomeSummaryProps = { totals: IncomeAnalytics["totalsByCurrency"] };
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
{props.totals.map((total) => (
<Paper key={total.currency} p="md" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
<Text c="dimmed" size="sm">
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(total.dividend)}
</Text>
</Paper>
))}
</Stack>
);
};
export { IncomeSummary };
@@ -0,0 +1,107 @@
import { SegmentedControl, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import styles from "../AccountIncomePage.module.css";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
type IncomeTrendProps = { trend: LoadedProps["display"]["trend"] };
const IncomeTrend: FC<IncomeTrendProps> = (props) => {
const currencies = props.trend.map(({ currency }) => currency);
const [currency, setCurrency] = useState(currencies[0] ?? "");
const { t } = useTranslation();
const buckets =
props.trend.find((item) => item.currency === currency)?.buckets ?? [];
const values = buckets.map((bucket) => ({
...bucket,
coupon: Number(bucket.total.coupon.value),
dividend: Number(bucket.total.dividend.value),
}));
const scale = Math.max(
1,
...values.map((value) =>
Math.max(
positive(value.coupon) + positive(value.dividend),
negative(value.coupon) + negative(value.dividend),
),
),
);
return (
<Stack>
<Title order={2}>{t("income.trend")}</Title>
{currencies.length > 1 ? (
<SegmentedControl
aria-label={t("income.currency")}
data={currencies}
onChange={setCurrency}
value={currency}
/>
) : null}
<div aria-hidden="true" className={styles.trendChart}>
{values.map((value) => (
<div className={styles.trendColumn} key={value.period.from}>
<span className={styles.baseline} />
<div className={styles.positiveStack}>
{value.coupon > 0 ? (
<span
className={styles.coupon}
data-testid={`trend-positive-coupon-${value.period.from}`}
style={{ flexBasis: chartHeight(value.coupon, scale) }}
/>
) : null}
{value.dividend > 0 ? (
<span
className={styles.dividend}
data-testid={`trend-positive-dividend-${value.period.from}`}
style={{ flexBasis: chartHeight(value.dividend, scale) }}
/>
) : null}
</div>
<div className={styles.negativeStack}>
{value.coupon < 0 ? (
<span
className={styles.coupon}
data-testid={`trend-negative-coupon-${value.period.from}`}
style={{ flexBasis: chartHeight(value.coupon, scale) }}
/>
) : null}
{value.dividend < 0 ? (
<span
className={styles.dividend}
data-testid={`trend-negative-dividend-${value.period.from}`}
style={{ flexBasis: chartHeight(value.dividend, scale) }}
/>
) : null}
</div>
</div>
))}
</div>
<Stack gap={2}>
{buckets.map((bucket) => (
<Text key={bucket.period.from} size="sm">
{bucket.period.from} — {bucket.period.to}: {t("income.coupon")}{" "}
{formatMoney(bucket.total.coupon)}, {t("income.dividend")}{" "}
{formatMoney(bucket.total.dividend)}
</Text>
))}
</Stack>
</Stack>
);
};
function positive(value: number): number {
return Math.max(0, value);
}
function negative(value: number): number {
return Math.max(0, -value);
}
function chartHeight(value: number, scale: number): string {
return `${(Math.abs(value) / scale) * 100}%`;
}
export { IncomeTrend };
@@ -1,19 +1,17 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation";
import { describe, expect, it, vi } from "vitest";
import {
type BrokerageOperation,
LedgerEntryRow,
} from "@/entities/broker-operation";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { AccountOperationsPage, OperationRow } from "./AccountOperationsPage";
import { AccountOperationsPage } from "./AccountOperationsPage";
const operation: BrokerageOperation = {
id: "operation-1",
@@ -41,14 +39,29 @@ function configureTestAuth() {
});
}
afterEach(cleanup);
function renderPage() {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
}
describe("OperationRow", () => {
describe("LedgerEntryRow", () => {
it("marks cashless operations without rendering operation details", () => {
render(
<MantineProvider>
<MemoryRouter>
<OperationRow operation={operation} />
<LedgerEntryRow operation={operation} />
</MemoryRouter>
</MantineProvider>,
);
@@ -65,7 +78,7 @@ describe("OperationRow", () => {
render(
<MantineProvider>
<MemoryRouter>
<OperationRow
<LedgerEntryRow
operation={{
...operation,
payment: { currency: "RUB", value: "15" },
@@ -83,7 +96,7 @@ describe("OperationRow", () => {
render(
<MantineProvider>
<MemoryRouter>
<OperationRow
<LedgerEntryRow
operation={{
...operation,
type: "OPERATION_TYPE_COUPON",
@@ -103,7 +116,7 @@ describe("OperationRow", () => {
render(
<MantineProvider>
<MemoryRouter>
<OperationRow
<LedgerEntryRow
operation={{
...operation,
type: "OPERATION_TYPE_DIVIDEND",
@@ -123,7 +136,7 @@ describe("OperationRow", () => {
render(
<MantineProvider>
<MemoryRouter>
<OperationRow
<LedgerEntryRow
operation={{
...operation,
type: "OPERATION_TYPE_SERVICE_FEE",
@@ -143,7 +156,7 @@ describe("OperationRow", () => {
render(
<MantineProvider>
<MemoryRouter>
<OperationRow
<LedgerEntryRow
operation={{
...operation,
payment: { currency: "RUB", value: "0" },
@@ -161,7 +174,7 @@ describe("OperationRow", () => {
render(
<MantineProvider>
<MemoryRouter>
<OperationRow operation={operation} />
<LedgerEntryRow operation={operation} />
</MemoryRouter>
</MantineProvider>,
);
@@ -171,8 +184,6 @@ describe("OperationRow", () => {
});
describe("AccountOperationsPage", () => {
afterEach(() => configureAuthClient(null));
it("resets cursor pagination when filters change", async () => {
configureTestAuth();
const searches: string[] = [];
@@ -192,20 +203,7 @@ describe("AccountOperationsPage", () => {
},
),
);
render(
<QueryClientProvider client={new QueryClient()}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
renderPage();
expect(
await screen.findByRole("heading", { name: "Журнал операций" }),
@@ -241,20 +239,7 @@ describe("AccountOperationsPage", () => {
},
),
);
render(
<QueryClientProvider client={new QueryClient()}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
renderPage();
expect(
await screen.findByRole("heading", { name: "Журнал операций" }),
@@ -287,20 +272,7 @@ describe("AccountOperationsPage", () => {
},
),
);
render(
<QueryClientProvider client={new QueryClient()}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
renderPage();
expect(
await screen.findByRole("heading", { name: "Журнал операций" }),
@@ -323,20 +295,7 @@ describe("AccountOperationsPage", () => {
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
);
render(
<QueryClientProvider client={new QueryClient()}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
renderPage();
expect(
await screen.findByText("Не удалось загрузить журнал"),
).toBeInTheDocument();
@@ -1,5 +1,4 @@
import type { FC } from "react";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { useAccountOperationsPageVm } from "./AccountOperationsPage.vm";
import { AccountOperationsPageError } from "./components/AccountOperationsPageError.view";
import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view";
@@ -19,4 +18,4 @@ const AccountOperationsPage: FC = () => {
return <AccountOperationsPageLoaded {...viewModel} />;
};
export { AccountOperationsPage, LedgerEntryRow as OperationRow };
export { AccountOperationsPage };
@@ -3,10 +3,10 @@ import { useState } from "react";
import { useParams } from "react-router";
import {
type BrokerageOperation,
brokerageOperationsQueryOptions,
type OperationCategory,
useBrokerageOperations,
} from "@/entities/broker-operation";
import { apiQueryClient } from "@/shared/api";
type AccountOperationsPageViewModel =
| { state: "loading" }
@@ -51,11 +51,9 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
...(cursor ? { cursor } : {}),
limit: 100,
});
const resetQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/operations",
{ params: { path: { accountId: accountId ?? "" }, query: { limit: 100 } } },
).queryKey;
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
limit: 100,
}).queryKey;
const resetCursors = () => setCursors([]);
+7 -32
View File
@@ -32,61 +32,36 @@
color: var(--mantine-color-red-6);
}
.stockCard {
.expandedCard {
padding: var(--mantine-spacing-md);
}
.stockMain {
.expandedMain {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--mantine-spacing-sm);
align-items: center;
}
.stockIdentity,
.stockValue {
.expandedIdentity,
.expandedValue {
min-width: 0;
}
.stockIdentifiers {
.expandedIdentifiers {
overflow-wrap: anywhere;
}
.stockValue {
.expandedValue {
white-space: nowrap;
}
.stockMeta,
.bondMeta {
.expandedMeta {
padding-top: var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-sm);
border-top: 1px solid var(--mantine-color-gray-2);
}
.bondCard {
padding: var(--mantine-spacing-md);
}
.bondMain {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--mantine-spacing-sm);
align-items: center;
}
.bondIdentity,
.bondValue {
min-width: 0;
}
.bondIdentifiers {
overflow-wrap: anywhere;
}
.bondValue {
white-space: nowrap;
}
.mono {
font-family: var(--mantine-font-family-monospace);
}
+16 -20
View File
@@ -1,28 +1,28 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { describe, expect, it, vi } from "vitest";
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import {
AccountPage,
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
} from "./AccountPage";
import { AccountPage } from "./AccountPage";
import type {
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "./AccountPage.vm";
import { toAccountPagePositionSections } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading.view";
function renderPage(ui: React.ReactNode) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter>{ui}</MemoryRouter>
</MantineProvider>
@@ -113,10 +113,6 @@ function emptyProps() {
}
describe("AccountPage states", () => {
afterEach(() => {
configureAuthClient(null);
cleanup();
});
it("renders loading and empty states", () => {
renderPage(<AccountPageLoading />);
expect(screen.getByRole("status")).toBeInTheDocument();
@@ -528,12 +524,12 @@ describe("AccountPage states", () => {
);
}),
);
const queryClient = new QueryClient();
const queryClient = createTestQueryClient();
renderAccountRoute(queryClient);
await screen.findByText("Вторая");
void queryClient.invalidateQueries({
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"],
queryKey: portfolioQueryOptions("account-1").queryKey,
});
expect(
@@ -563,12 +559,12 @@ describe("AccountPage states", () => {
});
}),
);
const queryClient = new QueryClient();
const queryClient = createTestQueryClient();
renderAccountRoute(queryClient);
await screen.findByText("Вторая");
void queryClient.invalidateQueries({
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"],
queryKey: portfolioQueryOptions("account-1").queryKey,
});
await waitFor(() => expect(requestCount).toBe(2));
+5 -13
View File
@@ -1,11 +1,9 @@
import type { FC } from "react";
import {
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
} from "./AccountPage.view";
import { useAccountPageViewModel } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading.view";
const AccountPage: FC = () => {
const viewModel = useAccountPageViewModel();
@@ -38,10 +36,4 @@ const AccountPage: FC = () => {
);
};
export {
AccountPage,
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
};
export { AccountPage };
-244
View File
@@ -1,244 +0,0 @@
import { Accordion, 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 { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import { formatSnapshotTime } from "@/entities/portfolio";
import { LoadingSkeleton, StateView } from "@/shared/ui";
import classes from "./AccountPage.module.css";
import type {
AccountPageIncomeSummary,
AccountPagePositionSection,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "./AccountPage.vm";
import {
AccountHeading,
CashBalances,
IncomeSummary,
PortfolioRefreshState,
PortfolioTotals,
PositionSection,
RecentEvents,
RecentOperations,
} from "./components";
type AccountPageErrorProps = {
code?: string;
retry: () => void;
};
type AccountPageEmptyProps = {
account?: BrokerageAccount;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
};
type AccountPageLoadedProps = {
account?: BrokerageAccount;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
};
const AccountPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="35%" />
<Skeleton height={120} radius="md" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={52} radius="sm" />
))}
</LoadingSkeleton>
);
};
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code, retry } = props;
const { t } = useTranslation();
if (code === "NOT_FOUND")
return (
<StateView
error
title={t("portfolio.accountUnavailableTitle")}
text={t("portfolio.accountUnavailableText")}
action={
<Button component={Link} to="/accounts">
{t("portfolio.back")}
</Button>
}
/>
);
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("portfolio.connectionErrorTitle")}
text={t("portfolio.connectionErrorText")}
action={
<Button component={Link} to="/settings">
{t("accounts.openSettings")}
</Button>
}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
};
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
const {
account,
refreshing = false,
refreshError = false,
retry,
recentOperations,
recentEvents,
incomeSummary,
} = props;
const { t } = useTranslation();
return (
<Stack>
<AccountHeading account={account} />
<PortfolioRefreshState
refreshing={refreshing}
refreshError={refreshError}
retry={retry}
/>
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
<RecentOperations operations={recentOperations} />
<RecentEvents events={recentEvents} />
{incomeSummary ? <IncomeSummary {...incomeSummary} /> : null}
</Stack>
);
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const {
account,
portfolio,
positionSections,
refreshing = false,
refreshError = false,
retry,
recentOperations,
recentEvents,
incomeSummary,
} = props;
const { t } = useTranslation();
return (
<Stack className={classes.page} gap="lg">
<AccountHeading account={account} />
<PortfolioRefreshState
refreshing={refreshing}
refreshError={refreshError}
retry={retry}
/>
{portfolio.isStale ? (
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null}
<Text c="dimmed" size="sm">
{t("portfolio.updatedAt", {
date: formatSnapshotTime(portfolio.lastSuccessfulAt),
})}
</Text>
<PortfolioTotals totals={portfolio.totalsByCurrency} />
<Accordion
multiple
defaultValue={[
...positionSections.map((s) => s.kind),
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
]}
classNames={{
control: classes.accordionControl,
label: classes.accordionLabel,
chevron: classes.accordionChevron,
panel: classes.accordionPanel,
}}
>
{positionSections.map((section) => (
<Accordion.Item key={section.kind} value={section.kind}>
<Accordion.Control>
{t(positionSectionTitleKey(section.kind))}
</Accordion.Control>
<Accordion.Panel>
<PositionSection section={section} />
</Accordion.Panel>
</Accordion.Item>
))}
<Accordion.Item value="cash">
<Accordion.Control>{t("portfolio.cashBalances")}</Accordion.Control>
<Accordion.Panel>
<CashBalances balances={portfolio.cashBalances} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentOperations">
<Accordion.Control>{t("ledger.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentOperations operations={recentOperations} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentEvents">
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentEvents events={recentEvents} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="incomeSummary">
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
<Accordion.Panel>
{incomeSummary ? <IncomeSummary {...incomeSummary} /> : null}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
};
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks";
if (kind === "bonds") return "portfolio.bonds";
return "portfolio.otherPositions";
}
export {
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
};
+19 -18
View File
@@ -1,12 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { useAccountPageViewModel } from "./AccountPage.vm";
@@ -63,21 +63,21 @@ function configureTestAuth() {
});
}
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<QueryClientProvider client={new QueryClient()}>
function createWrapper() {
const { wrapper: QueryWrapper } = createQueryWrapper();
const Wrapper: FC<PropsWithChildren> = (props) => (
<QueryWrapper>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1"]}>
<Routes>
<Route path="/accounts/:accountId" element={children} />
<Route path="/accounts/:accountId" element={props.children} />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>
</QueryWrapper>
);
};
return Wrapper;
}
function mockLoadedAccountPage() {
server.use(
@@ -130,13 +130,13 @@ function mockLoadedAccountPage() {
}
describe("useAccountPageViewModel", () => {
afterEach(() => configureAuthClient(null));
it("prepares grouped position sections with derived display data", async () => {
configureTestAuth();
mockLoadedAccountPage();
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.state).toBe("loaded"));
const loaded = result.current as Extract<
@@ -146,7 +146,6 @@ describe("useAccountPageViewModel", () => {
expect(loaded.positionSections).toMatchObject([
{
kind: "stocks",
wide: true,
positions: [
{
displayName: "Акция",
@@ -159,7 +158,6 @@ describe("useAccountPageViewModel", () => {
},
{
kind: "bonds",
wide: true,
positions: [
{
displayName: "Облигация",
@@ -172,7 +170,6 @@ describe("useAccountPageViewModel", () => {
},
{
kind: "other",
wide: false,
positions: [
{
displayName: "Фонд",
@@ -190,7 +187,9 @@ describe("useAccountPageViewModel", () => {
configureTestAuth();
mockLoadedAccountPage();
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() =>
expect(
@@ -233,7 +232,9 @@ describe("useAccountPageViewModel", () => {
),
);
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() =>
expect(result.current).toMatchObject({
+12 -18
View File
@@ -12,13 +12,9 @@ import {
} from "@/entities/brokerage-event";
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio";
import {
formatMoney,
formatShare,
instrumentLogoUrl,
portfolioErrorCode,
usePortfolio,
} from "@/entities/portfolio";
import { formatShare, usePortfolio } from "@/entities/portfolio";
import { apiErrorCode } from "@/shared/api";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
type PositionSectionKind = "stocks" | "bonds" | "other";
type AccountPageReturnTone = "positive" | "negative";
@@ -32,7 +28,6 @@ type AccountPagePosition = Portfolio["positions"][number] & {
type AccountPagePositionSection = {
kind: PositionSectionKind;
wide: boolean;
positions: AccountPagePosition[];
};
@@ -108,7 +103,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
if (portfolio.isError)
return {
state: "error" as const,
code: portfolioErrorCode(portfolio.error),
code: apiErrorCode(portfolio.error),
retry: () => portfolio.refetch(),
};
return { state: "loading" as const };
@@ -188,9 +183,9 @@ function toAccountPagePositionSections(
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
);
}
const stocks = toSection("stocks", true, portfolio, denominators);
const bonds = toSection("bonds", true, portfolio, denominators);
const other = toSection("other", false, portfolio, denominators);
const stocks = toSection("stocks", portfolio, denominators);
const bonds = toSection("bonds", portfolio, denominators);
const other = toSection("other", portfolio, denominators);
return [stocks, bonds, other].filter(
(section) => section.positions.length > 0,
);
@@ -198,28 +193,27 @@ function toAccountPagePositionSections(
function toSection(
kind: PositionSectionKind,
wide: boolean,
portfolio: Portfolio,
denominators: Map<string, number>,
): AccountPagePositionSection {
return {
kind,
wide,
positions: portfolio.positions
.filter((position) => {
if (kind === "stocks") return position.type === "STOCK";
if (kind === "bonds") return position.type === "BOND";
return position.type !== "STOCK" && position.type !== "BOND";
})
.map((position) => toPosition(position, denominators, wide)),
.map((position) => toPosition(position, denominators, kind)),
};
}
function toPosition(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
wide: boolean,
kind: PositionSectionKind,
): AccountPagePosition {
const expanded = kind !== "other";
const returnValue = position.absoluteReturn
? Number(position.absoluteReturn.value)
: null;
@@ -238,10 +232,10 @@ function toPosition(
})
: null,
returnText: position.absoluteReturn
? `${wide && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
? `${expanded && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
: null,
returnTone,
logoSrc: wide ? instrumentLogoUrl(position.logoName) : null,
logoSrc: expanded ? instrumentLogoUrl(position.logoName) : null,
};
}
@@ -0,0 +1,48 @@
import { Stack } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { StateView } from "@/shared/ui";
import type {
AccountPageIncomeSummary,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../AccountPage.vm";
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 = {
account?: BrokerageAccount;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
};
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<AccountHeading account={props.account} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
refreshError={props.refreshError ?? false}
retry={props.retry}
/>
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
<RecentOperations operations={props.recentOperations} />
<RecentEvents events={props.recentEvents} />
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
</Stack>
);
};
export { AccountPageEmpty };
@@ -0,0 +1,57 @@
import { Button } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { StateView } from "@/shared/ui";
type AccountPageErrorProps = { code?: string; retry: () => void };
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code, retry } = props;
const { t } = useTranslation();
if (code === "NOT_FOUND")
return (
<StateView
error
title={t("portfolio.accountUnavailableTitle")}
text={t("portfolio.accountUnavailableText")}
action={
<Button component={Link} to="/accounts">
{t("portfolio.back")}
</Button>
}
/>
);
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("portfolio.connectionErrorTitle")}
text={t("portfolio.connectionErrorText")}
action={
<Button component={Link} to="/settings">
{t("accounts.openSettings")}
</Button>
}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
};
export { AccountPageError };
@@ -0,0 +1,117 @@
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 type { Portfolio } from "@/entities/portfolio";
import { formatSnapshotTime } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type {
AccountPageIncomeSummary,
AccountPagePositionSection,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../AccountPage.vm";
import { AccountHeading } from "./AccountHeading.view";
import { CashBalances } from "./CashBalances.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
import { PortfolioTotals } from "./PortfolioTotals.view";
import { PositionSection } from "./PositionSection.view";
import { RecentEvents } from "./RecentEvents.view";
import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = {
account?: BrokerageAccount;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
incomeSummary?: AccountPageIncomeSummary;
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation();
return (
<Stack className={classes.page} gap="lg">
<AccountHeading account={props.account} />
<PortfolioRefreshState
refreshing={props.refreshing ?? false}
refreshError={props.refreshError ?? false}
retry={props.retry}
/>
{props.portfolio.isStale ? (
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null}
<Text c="dimmed" size="sm">
{t("portfolio.updatedAt", {
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
})}
</Text>
<PortfolioTotals totals={props.portfolio.totalsByCurrency} />
<Accordion
multiple
defaultValue={[
...props.positionSections.map((section) => section.kind),
"cash",
"recentOperations",
"recentEvents",
"incomeSummary",
]}
classNames={{
control: classes.accordionControl,
label: classes.accordionLabel,
chevron: classes.accordionChevron,
panel: classes.accordionPanel,
}}
>
{props.positionSections.map((section) => (
<Accordion.Item key={section.kind} value={section.kind}>
<Accordion.Control>
{t(positionSectionTitleKey(section.kind))}
</Accordion.Control>
<Accordion.Panel>
<PositionSection section={section} />
</Accordion.Panel>
</Accordion.Item>
))}
<Accordion.Item value="cash">
<Accordion.Control>{t("portfolio.cashBalances")}</Accordion.Control>
<Accordion.Panel>
<CashBalances balances={props.portfolio.cashBalances} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentOperations">
<Accordion.Control>{t("ledger.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentOperations operations={props.recentOperations} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="recentEvents">
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentEvents events={props.recentEvents} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="incomeSummary">
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
<Accordion.Panel>
{props.incomeSummary ? (
<IncomeSummary {...props.incomeSummary} />
) : null}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
};
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks";
if (kind === "bonds") return "portfolio.bonds";
return "portfolio.otherPositions";
}
export { AccountPageLoaded };
@@ -0,0 +1,19 @@
import { Skeleton } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
const AccountPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="35%" />
<Skeleton height={120} radius="md" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={52} radius="sm" />
))}
</LoadingSkeleton>
);
};
export { AccountPageLoading };
@@ -1,7 +1,7 @@
import { Paper, Text } from "@mantine/core";
import type { FC } from "react";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
type CashBalancesProps = {
@@ -3,7 +3,7 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import { StateView } from "@/shared/ui";
type IncomeSummaryProps =
@@ -1,7 +1,7 @@
import { Paper, SimpleGrid, Text } from "@mantine/core";
import type { FC } from "react";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
type PortfolioTotalsProps = {
@@ -1,44 +1,28 @@
import { Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import classes from "../AccountPage.module.css";
import type { AccountPagePosition } from "../AccountPage.vm";
import type {
AccountPagePosition,
AccountPagePositionSection,
} from "../AccountPage.vm";
import { PositionLogo } from "./PositionLogo.view";
type PositionCardProps = {
kind: AccountPagePositionSection["kind"];
position: AccountPagePosition;
wide?: boolean;
variant?: "stock" | "bond";
};
const PositionCard: FC<PositionCardProps> = (props) => {
const { position, wide = false, variant } = props;
const { kind, position } = props;
const { t } = useTranslation();
const isExpanded = variant === "stock" || variant === "bond";
const ec =
variant === "bond"
? {
card: classes.bondCard,
main: classes.bondMain,
identity: classes.bondIdentity,
identifiers: classes.bondIdentifiers,
value: classes.bondValue,
meta: classes.bondMeta,
}
: {
card: classes.stockCard,
main: classes.stockMain,
identity: classes.stockIdentity,
identifiers: classes.stockIdentifiers,
value: classes.stockValue,
meta: classes.stockMeta,
};
const isExpanded = kind !== "other";
const returnClass = position.returnTone
? classes[position.returnTone]
: undefined;
const returnText =
position.returnText && wide
position.returnText && isExpanded
? position.returnText
: position.returnText
? t("portfolio.return", {
@@ -46,7 +30,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
})
: null;
const identifier =
variant === "stock" ? (position.ticker ?? position.isin) : position.isin;
kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
const identifierText = [
t("portfolio.quantity", { value: position.quantity }),
identifier,
@@ -68,22 +52,22 @@ const PositionCard: FC<PositionCardProps> = (props) => {
if (isExpanded)
return (
<Card className={`${classes.tile} ${ec.card}`} withBorder>
<div className={ec.main}>
<Card className={`${classes.tile} ${classes.expandedCard}`} withBorder>
<div className={classes.expandedMain}>
<PositionLogo
key={position.logoSrc ?? "no-logo"}
src={position.logoSrc}
label={position.displayName}
/>
<Stack gap={2} miw={0} className={ec.identity}>
<Stack gap={2} miw={0} className={classes.expandedIdentity}>
<Text fw={600} lineClamp={1}>
{position.displayName}
</Text>
<Text c="dimmed" size="xs" className={ec.identifiers}>
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
{identifierText}
</Text>
</Stack>
<Stack gap={2} align="flex-end" className={ec.value}>
<Stack gap={2} align="flex-end" className={classes.expandedValue}>
{position.currentValue ? (
<Text fw={700} size="lg">
{formatMoney(position.currentValue)}
@@ -97,7 +81,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
</Stack>
</div>
{secondaryText ? (
<Text c="dimmed" size="xs" className={ec.meta}>
<Text c="dimmed" size="xs" className={classes.expandedMeta}>
{secondaryText}
</Text>
) : null}
@@ -106,13 +90,6 @@ const PositionCard: FC<PositionCardProps> = (props) => {
return (
<Card className={classes.tile} withBorder>
<Group justify="space-between" align="flex-start" wrap="nowrap">
{wide ? (
<PositionLogo
key={position.logoSrc ?? "no-logo"}
src={position.logoSrc}
label={position.displayName}
/>
) : null}
<Stack gap={2} miw={0}>
<Text fw={700} lineClamp={1}>
{position.displayName}
@@ -9,21 +9,15 @@ type PositionSectionProps = {
const PositionSection: FC<PositionSectionProps> = (props) => {
const { section } = props;
const expanded = section.kind !== "other";
return (
<SimpleGrid cols={section.wide ? 1 : { base: 1, sm: 2, md: 3 }}>
<SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}>
{section.positions.map((position) => (
<PositionCard
kind={section.kind}
key={`${position.figi}-${position.displayName}-${position.quantity}`}
position={position}
wide={section.wide}
variant={
section.kind === "stocks"
? "stock"
: section.kind === "bonds"
? "bond"
: undefined
}
/>
))}
</SimpleGrid>
@@ -3,7 +3,7 @@ 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 "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "../AccountPage.module.css";
import type { AccountPageRecentEvents } from "../AccountPage.vm";
-8
View File
@@ -1,8 +0,0 @@
export { AccountHeading } from "./AccountHeading.view";
export { CashBalances } from "./CashBalances.view";
export { IncomeSummary } from "./IncomeSummary.view";
export { PortfolioRefreshState } from "./PortfolioRefreshState.view";
export { PortfolioTotals } from "./PortfolioTotals.view";
export { PositionSection } from "./PositionSection.view";
export { RecentEvents } from "./RecentEvents.view";
export { RecentOperations } from "./RecentOperations.view";
+1 -6
View File
@@ -1,6 +1 @@
export {
AccountsPage,
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoading,
} from "./ui/AccountsPage";
export { AccountsPage } from "./ui/AccountsPage";
+40 -9
View File
@@ -1,13 +1,17 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { AccountsPage } from "./AccountsPage";
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
import { AccountsPageError } from "./components/AccountsPageError.view";
import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
const account = {
id: "account-1",
@@ -38,9 +42,36 @@ function renderPage(queryClient: QueryClient) {
}
describe("AccountsPage", () => {
afterEach(() => {
configureAuthClient(null);
cleanup();
it.each([
["loading", AccountsPageLoading, "status"],
["empty", AccountsPageEmpty, undefined],
] as const)("renders only the %s state", (_name, State, role) => {
render(
<MantineProvider>
<State />
</MantineProvider>,
);
if (role) expect(screen.getByRole(role)).toBeInTheDocument();
else
expect(
screen.getByRole("heading", { name: "Нет инвестиционных счетов" }),
).toBeInTheDocument();
});
it("routes connection errors to settings and keeps retry available", () => {
const retry = vi.fn();
render(
<MantineProvider>
<MemoryRouter>
<AccountsPageError code="TBANK_UNAVAILABLE" retry={retry} />
</MemoryRouter>
</MantineProvider>,
);
screen.getByRole("button", { name: "Повторить" }).click();
expect(retry).toHaveBeenCalledOnce();
expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings");
});
it("keeps the cached account list visible during a background refresh", async () => {
@@ -55,7 +86,7 @@ describe("AccountsPage", () => {
});
}),
);
const queryClient = new QueryClient();
const queryClient = createTestQueryClient();
renderPage(queryClient);
await screen.findByText(account.name);
@@ -86,7 +117,7 @@ describe("AccountsPage", () => {
return HttpResponse.json([account]);
}),
);
const queryClient = new QueryClient();
const queryClient = createTestQueryClient();
renderPage(queryClient);
await screen.findByText(account.name);
@@ -116,7 +147,7 @@ describe("AccountsPage", () => {
);
}),
);
const queryClient = new QueryClient();
const queryClient = createTestQueryClient();
renderPage(queryClient);
await screen.findByText("Нет инвестиционных счетов");
+5 -12
View File
@@ -1,11 +1,9 @@
import type { FC } from "react";
import {
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoaded,
AccountsPageLoading,
} from "./AccountsPage.view";
import { useAccountsPageViewModel } from "./AccountsPage.vm";
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
import { AccountsPageError } from "./components/AccountsPageError.view";
import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view";
import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
const AccountsPage: FC = () => {
const viewModel = useAccountsPageViewModel();
@@ -29,9 +27,4 @@ const AccountsPage: FC = () => {
);
};
export {
AccountsPage,
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoading,
};
export { AccountsPage };
-175
View File
@@ -1,175 +0,0 @@
import {
Alert,
Button,
Group,
Loader,
Skeleton,
Stack,
Text,
Title,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageAccountCard } from "@/entities/brokerage-account";
import { BrokerageAccountList } from "@/entities/brokerage-account";
import { LoadingSkeleton, StateView } from "@/shared/ui";
type AccountsPageErrorProps = {
code?: string;
retry?: () => void;
};
type AccountsPageEmptyProps = {
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
};
type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
};
type AccountsRefreshStateProps = {
refreshing: boolean;
refreshError: boolean;
retry?: () => void;
};
const AccountsPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="30%" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={116} radius="md" />
))}
</LoadingSkeleton>
);
};
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
const { code, retry } = props;
const { t } = useTranslation();
const settingsAction = (
<Button component={Link} to="/settings" variant="light">
{t("accounts.openSettings")}
</Button>
);
if (code === "TBANK_NOT_CONNECTED")
return (
<StateView
error
title={t("accounts.notConnectedTitle")}
text={t("accounts.notConnectedText")}
action={settingsAction}
/>
);
if (code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("accounts.invalidTokenTitle")}
text={t("accounts.invalidTokenText")}
action={settingsAction}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={
<Stack align="flex-start">
<Button onClick={retry}>{t("accounts.retry")}</Button>
{settingsAction}
</Stack>
}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
};
const AccountsPageEmpty: FC<AccountsPageEmptyProps> = (props) => {
const { refreshing = false, refreshError = false, retry } = props;
const { t } = useTranslation();
return (
<Stack>
<AccountsRefreshState
refreshing={refreshing}
refreshError={refreshError}
retry={retry}
/>
<StateView
title={t("accounts.emptyTitle")}
text={t("accounts.emptyText")}
/>
</Stack>
);
};
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { accounts, refreshing = false, refreshError = false, retry } = props;
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("accounts.title")}</Title>
<AccountsRefreshState
refreshing={refreshing}
refreshError={refreshError}
retry={retry}
/>
<BrokerageAccountList accounts={accounts} />
</Stack>
);
};
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
const { refreshing, refreshError, retry } = props;
const { t } = useTranslation();
if (!refreshing && !refreshError) return null;
return (
<>
{refreshing ? (
<Group gap="xs" role="status" aria-label={t("accounts.refreshing")}>
<Loader size="sm" />
<Text c="dimmed" size="sm">
{t("accounts.refreshing")}
</Text>
</Group>
) : null}
{refreshError ? (
<Alert color="orange">
<Stack gap="sm">
<Text>{t("accounts.refreshError")}</Text>
<Button onClick={retry} variant="light">
{t("accounts.retry")}
</Button>
</Stack>
</Alert>
) : null}
</>
);
};
export {
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoaded,
AccountsPageLoading,
};
+2 -2
View File
@@ -1,8 +1,8 @@
import {
brokerageAccountCards,
errorCode,
useBrokerageAccounts,
} from "@/entities/brokerage-account";
import { apiErrorCode } from "@/shared/api";
function useAccountsPageViewModel() {
const accounts = useBrokerageAccounts();
@@ -11,7 +11,7 @@ function useAccountsPageViewModel() {
if (accounts.isError)
return {
state: "error" as const,
code: errorCode(accounts.error),
code: apiErrorCode(accounts.error),
retry: () => accounts.refetch(),
};
}
@@ -0,0 +1,23 @@
import { Stack } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
import {
AccountsRefreshState,
type AccountsRefreshStateProps,
} from "./AccountsRefreshState.view";
const AccountsPageEmpty: FC<AccountsRefreshStateProps> = (props) => {
const { t } = useTranslation();
return (
<Stack>
<AccountsRefreshState {...props} />
<StateView
title={t("accounts.emptyTitle")}
text={t("accounts.emptyText")}
/>
</Stack>
);
};
export { AccountsPageEmpty };
@@ -0,0 +1,59 @@
import { Button, Stack } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { StateView } from "@/shared/ui";
type AccountsPageErrorProps = { code?: string; retry?: () => void };
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
const { code, retry } = props;
const { t } = useTranslation();
const settingsAction = (
<Button component={Link} to="/settings" variant="light">
{t("accounts.openSettings")}
</Button>
);
if (code === "TBANK_NOT_CONNECTED")
return (
<StateView
error
title={t("accounts.notConnectedTitle")}
text={t("accounts.notConnectedText")}
action={settingsAction}
/>
);
if (code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("accounts.invalidTokenTitle")}
text={t("accounts.invalidTokenText")}
action={settingsAction}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={
<Stack align="flex-start">
<Button onClick={retry}>{t("accounts.retry")}</Button>
{settingsAction}
</Stack>
}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
};
export { AccountsPageError };
@@ -0,0 +1,29 @@
import { Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
type BrokerageAccountCard,
BrokerageAccountList,
} from "@/entities/brokerage-account";
import {
AccountsRefreshState,
type AccountsRefreshStateProps,
} from "./AccountsRefreshState.view";
type AccountsPageLoadedProps = AccountsRefreshStateProps & {
accounts: BrokerageAccountCard[];
};
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { accounts, ...refresh } = props;
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("accounts.title")}</Title>
<AccountsRefreshState {...refresh} />
<BrokerageAccountList accounts={accounts} />
</Stack>
);
};
export { AccountsPageLoaded };
@@ -0,0 +1,18 @@
import { Skeleton } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
const AccountsPageLoading: FC = () => {
const { t } = useTranslation();
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="30%" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={116} radius="md" />
))}
</LoadingSkeleton>
);
};
export { AccountsPageLoading };
@@ -0,0 +1,39 @@
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
type AccountsRefreshStateProps = {
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
};
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
const { refreshing = false, refreshError = false, retry } = props;
const { t } = useTranslation();
if (!refreshing && !refreshError) return null;
return (
<>
{refreshing ? (
<Group gap="xs" role="status" aria-label={t("accounts.refreshing")}>
<Loader size="sm" />
<Text c="dimmed" size="sm">
{t("accounts.refreshing")}
</Text>
</Group>
) : null}
{refreshError ? (
<Alert color="orange">
<Stack gap="sm">
<Text>{t("accounts.refreshError")}</Text>
<Button onClick={retry} variant="light">
{t("accounts.retry")}
</Button>
</Stack>
</Alert>
) : null}
</>
);
};
export { AccountsRefreshState, type AccountsRefreshStateProps };
+4 -10
View File
@@ -1,20 +1,14 @@
import { Paper, Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoginForm } from "@/features/auth";
import { AuthPageLayout, LoginForm } from "@/features/auth";
const LoginPage: FC = () => {
const { t } = useTranslation();
return (
<main className="auth-page">
<Paper className="auth-card" p="xl" radius="lg" withBorder>
<Stack gap="lg">
<Title order={1}>{t("auth.login")}</Title>
<LoginForm />
</Stack>
</Paper>
</main>
<AuthPageLayout title={t("auth.login")}>
<LoginForm />
</AuthPageLayout>
);
};
+2 -2
View File
@@ -1,8 +1,8 @@
import { Stack, Title } from "@mantine/core";
import { Button, Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Button, StateView } from "@/shared/ui";
import { StateView } from "@/shared/ui";
const NotFoundPage: FC = () => {
const { t } = useTranslation();
+4 -10
View File
@@ -1,20 +1,14 @@
import { Paper, Stack, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { RegisterForm } from "@/features/auth";
import { AuthPageLayout, RegisterForm } from "@/features/auth";
const RegisterPage: FC = () => {
const { t } = useTranslation();
return (
<main className="auth-page">
<Paper className="auth-card" p="xl" radius="lg" withBorder>
<Stack gap="lg">
<Title order={1}>{t("auth.register")}</Title>
<RegisterForm />
</Stack>
</Paper>
</main>
<AuthPageLayout title={t("auth.register")}>
<RegisterForm />
</AuthPageLayout>
);
};