refactor: align frontend ownership boundaries
Close audit gaps across API drift, query policy, UI, and tests.
This commit is contained in:
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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 +1 @@
|
||||
export {
|
||||
AccountsPage,
|
||||
AccountsPageEmpty,
|
||||
AccountsPageError,
|
||||
AccountsPageLoading,
|
||||
} from "./ui/AccountsPage";
|
||||
export { AccountsPage } from "./ui/AccountsPage";
|
||||
|
||||
@@ -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("Нет инвестиционных счетов");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user