refactor: share instrument search presentation
This commit is contained in:
@@ -54,8 +54,13 @@ describe("AppBreadcrumbs", () => {
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links to the account hierarchy from operations", () => {
|
||||
renderBreadcrumbs("/accounts/account-1/operations", [
|
||||
it.each([
|
||||
["operations", "Операции"],
|
||||
["OPERATIONS", "Операции"],
|
||||
["events", "Будущие события"],
|
||||
["income", "Пассивный доход"],
|
||||
])("links to the account hierarchy from %s", (section, title) => {
|
||||
renderBreadcrumbs(`/accounts/account-1/${section}`, [
|
||||
{ id: "account-1", name: "ИИС" },
|
||||
]);
|
||||
|
||||
@@ -66,7 +71,7 @@ describe("AppBreadcrumbs", () => {
|
||||
"href",
|
||||
"/accounts/account-1",
|
||||
);
|
||||
expect(screen.getByText("Операции")).toHaveAttribute(
|
||||
expect(screen.getByText(title)).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
@@ -86,7 +91,11 @@ describe("AppBreadcrumbs", () => {
|
||||
expect(screen.queryByText("account-1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["/login", "/unknown"])("does not render on %s", (path) => {
|
||||
it.each([
|
||||
"/login",
|
||||
"/unknown",
|
||||
"/accounts/account-1/unknown",
|
||||
])("does not render on %s", (path) => {
|
||||
renderBreadcrumbs(path);
|
||||
|
||||
expect(
|
||||
|
||||
@@ -14,14 +14,12 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
|
||||
const AppBreadcrumbs: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const operations = useMatch({
|
||||
end: true,
|
||||
path: "/accounts/:accountId/operations",
|
||||
});
|
||||
const account = useMatch("/accounts/:accountId/:section?");
|
||||
|
||||
const section = (["events", "income", "operations"] as const).find(
|
||||
value => value === account?.params.section?.toLowerCase(),
|
||||
);
|
||||
|
||||
const events = useMatch({ end: true, path: "/accounts/:accountId/events" });
|
||||
const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
|
||||
const account = useMatch({ end: true, path: "/accounts/:accountId" });
|
||||
const card = useMatch("/cards/:cardId");
|
||||
const cards = useMatch("/cards");
|
||||
const accounts = useMatch({ end: true, path: "/accounts" });
|
||||
@@ -32,56 +30,20 @@ const AppBreadcrumbs: FC = () => {
|
||||
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
|
||||
if (stock)
|
||||
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
|
||||
if (operations)
|
||||
if (account && (section || !account.params.section))
|
||||
return (
|
||||
<Stack gap={0} mb="md">
|
||||
<AccountBreadcrumbs
|
||||
accountId={operations.params.accountId ?? ""}
|
||||
section="operations"
|
||||
accountId={account.params.accountId ?? ""}
|
||||
section={section}
|
||||
/>
|
||||
|
||||
<AccountScopeSwitch
|
||||
accountId={operations.params.accountId ?? ""}
|
||||
section="operations"
|
||||
accountId={account.params.accountId ?? ""}
|
||||
section={section}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
if (events)
|
||||
return (
|
||||
<Stack gap={0} mb="md">
|
||||
<AccountBreadcrumbs
|
||||
accountId={events.params.accountId ?? ""}
|
||||
section="events"
|
||||
/>
|
||||
|
||||
<AccountScopeSwitch
|
||||
accountId={events.params.accountId ?? ""}
|
||||
section="events"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
if (income)
|
||||
return (
|
||||
<Stack gap={0} mb="md">
|
||||
<AccountBreadcrumbs
|
||||
accountId={income.params.accountId ?? ""}
|
||||
section="income"
|
||||
/>
|
||||
|
||||
<AccountScopeSwitch
|
||||
accountId={income.params.accountId ?? ""}
|
||||
section="income"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
if (account)
|
||||
return (
|
||||
<Stack gap={0} mb="md">
|
||||
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
|
||||
|
||||
<AccountScopeSwitch accountId={account.params.accountId ?? ""} />
|
||||
</Stack>
|
||||
);
|
||||
if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
|
||||
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
||||
if (accounts)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useInstrumentSearch } from "./model/useInstrumentSearch";
|
||||
export { InstrumentSearch } from "./ui/InstrumentSearch/InstrumentSearch.view";
|
||||
export { InstrumentSearchCard } from "./ui/InstrumentSearchCard/InstrumentSearchCard.view";
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ChangeEvent } from "react";
|
||||
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
function useInstrumentSearch(minLength: number) {
|
||||
const [parameters, setParameters] = useSearchParams();
|
||||
const query = parameters.get("q") ?? "";
|
||||
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
|
||||
|
||||
return {
|
||||
canSearch: query.trim().length >= minLength,
|
||||
debounced,
|
||||
isWaiting: query.trim() !== debounced,
|
||||
onQueryChange: (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const q = event.currentTarget.value;
|
||||
setParameters(q ? { q } : {}, { replace: true });
|
||||
},
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
export { useInstrumentSearch };
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { InstrumentSearch } from "./InstrumentSearch.view";
|
||||
|
||||
it("keeps cached results on failure, hides them during a new search and distinguishes partial from empty", () => {
|
||||
const vm = {
|
||||
canSearch: true,
|
||||
error: false,
|
||||
loading: false,
|
||||
onQueryChange: vi.fn(),
|
||||
partial: false,
|
||||
query: "SBER",
|
||||
ready: true,
|
||||
refreshing: false,
|
||||
retry: vi.fn(),
|
||||
};
|
||||
|
||||
const cached = [<a href="/stocks/SBER" key="SBER">Сбербанк</a>];
|
||||
|
||||
const view = (
|
||||
state: Partial<ComponentProps<typeof InstrumentSearch>["vm"]>,
|
||||
children = cached,
|
||||
) => (
|
||||
<InstrumentSearch kind="stock" vm={{ ...vm, ...state }}>
|
||||
{children}
|
||||
</InstrumentSearch>
|
||||
);
|
||||
|
||||
const { rerender } = render(view({}), {
|
||||
wrapper: ({ children }) => (
|
||||
<MantineProvider>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</MantineProvider>
|
||||
),
|
||||
});
|
||||
|
||||
rerender(view({ code: "INVALID_TBANK_TOKEN", error: true }));
|
||||
expect(screen.getByRole("link", { name: "Сбербанк" })).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
|
||||
).toHaveAttribute("href", "/settings");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
expect(vm.retry).toHaveBeenCalledOnce();
|
||||
|
||||
rerender(view({ loading: true, ready: false }));
|
||||
expect(screen.queryByRole("link", { name: "Сбербанк" })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Ищем акции")).toBeVisible();
|
||||
|
||||
rerender(view({ partial: true }, []));
|
||||
expect(screen.queryByText(/Акции не найдены/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toBeVisible();
|
||||
|
||||
rerender(view({}, []));
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Акции не найдены/)).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ChangeEvent, FC, ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Button,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||
|
||||
type InstrumentSearchProps = {
|
||||
children: ReactNode[];
|
||||
kind: "bond" | "stock";
|
||||
vm: {
|
||||
canSearch: boolean;
|
||||
code?: string;
|
||||
error: boolean;
|
||||
loading: boolean;
|
||||
onQueryChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
partial: boolean;
|
||||
query: string;
|
||||
ready: boolean;
|
||||
refreshing: boolean;
|
||||
retry: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
const InstrumentSearchResults: FC<InstrumentSearchProps> = (props) => {
|
||||
const { children, kind, vm } = props;
|
||||
const { t } = useTranslation();
|
||||
if (vm.loading) return (
|
||||
<LoadingSkeleton label={t(`${kind}.searching`)}>
|
||||
<Stack>
|
||||
<Skeleton h={78} />
|
||||
|
||||
<Skeleton h={78} />
|
||||
|
||||
<Skeleton h={78} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
if (!vm.ready) return;
|
||||
if (children.length > 0) return (
|
||||
<Stack gap="sm">
|
||||
<Text c="dimmed" size="xs">{t(`${kind}.searchVariants`)}</Text>
|
||||
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Text c="dimmed">
|
||||
{t(vm.partial ? "investment.partial" : `${kind}.noSearchResults`)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const InstrumentSearch: FC<InstrumentSearchProps> = (props) => {
|
||||
const { kind, vm } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={1}>{t(`${kind}.searchTitle`)}</Title>
|
||||
|
||||
<Text c="dimmed" mt="xs">
|
||||
{t(`${kind}.searchSubtitle`)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={vm.refreshing || !vm.canSearch}
|
||||
onClick={vm.retry}
|
||||
w="fit-content"
|
||||
>
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
|
||||
{!!vm.partial && <Alert role="status">{t("investment.partial")}</Alert>}
|
||||
|
||||
<TextInput
|
||||
label={t(`${kind}.searchLabel`)}
|
||||
maxLength={80}
|
||||
onChange={vm.onQueryChange}
|
||||
placeholder={t(`${kind}.searchPlaceholder`)}
|
||||
size="md"
|
||||
value={vm.query}
|
||||
/>
|
||||
|
||||
{!vm.canSearch && <Text c="dimmed">{t("stock.searchHint")}</Text>}
|
||||
|
||||
{!!vm.error && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
<Stack gap="xs">
|
||||
<Text>
|
||||
{t(
|
||||
isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected)
|
||||
|| isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken)
|
||||
? `${kind}.connect`
|
||||
: "stock.sectionError",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Anchor component={Link} to="/settings">
|
||||
{t("accounts.openSettings")}
|
||||
</Anchor>
|
||||
|
||||
<Button onClick={vm.retry} variant="default">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<InstrumentSearchResults {...props} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { InstrumentSearch };
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC, PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
import { Avatar, Paper, Text } from "@mantine/core";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import classes from "./InstrumentSearchCard.module.css";
|
||||
|
||||
type InstrumentSearchCardProps = PropsWithChildren<{
|
||||
instrument: { logoSrc: null | string; name: string; to: string };
|
||||
quote: ReactNode;
|
||||
}>;
|
||||
|
||||
const InstrumentSearchCard: FC<InstrumentSearchCardProps> = (props) => {
|
||||
const { children, instrument, quote } = props;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className={classes.card}
|
||||
component={Link}
|
||||
to={instrument.to}
|
||||
withBorder
|
||||
>
|
||||
<Avatar alt="" radius="md" size={48} src={instrument.logoSrc}>
|
||||
{instrument.name[0]}
|
||||
</Avatar>
|
||||
|
||||
<div className={classes.identity}>
|
||||
<Text fw={600} size="lg">{instrument.name}</Text>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className={classes.quote}>{quote}</div>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { InstrumentSearchCard };
|
||||
@@ -1,24 +1,12 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { Badge, Group, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||
import {
|
||||
InstrumentSearch,
|
||||
InstrumentSearchCard,
|
||||
} from "@/features/instrument-search";
|
||||
import {
|
||||
formatDate,
|
||||
formatDecimal,
|
||||
@@ -28,202 +16,101 @@ import {
|
||||
|
||||
import type { BondsPageVm } from "./BondsPage.vm";
|
||||
|
||||
import classes from "./BondsPage.module.css";
|
||||
|
||||
type BondsPageViewProps = { vm: BondsPageVm };
|
||||
|
||||
const BondsPageViewResults: FC<BondsPageViewProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { vm } = props;
|
||||
|
||||
if (vm.loading) {
|
||||
return (
|
||||
<LoadingSkeleton label={t("bond.searching")}>
|
||||
<Stack>
|
||||
<Skeleton h={78} />
|
||||
|
||||
<Skeleton h={78} />
|
||||
|
||||
<Skeleton h={78} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
|
||||
if (!vm.ready) return;
|
||||
|
||||
if (vm.items.length > 0) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.searchVariants")}
|
||||
</Text>
|
||||
|
||||
{vm.items.map(bond => (
|
||||
<Paper
|
||||
className={classes.card}
|
||||
component={Link}
|
||||
key={bond.instrumentUid}
|
||||
to={bond.to}
|
||||
withBorder
|
||||
>
|
||||
<Avatar alt="" radius="md" size={48} src={bond.logoSrc}>
|
||||
{bond.name[0]}
|
||||
</Avatar>
|
||||
|
||||
<div className={classes.identity}>
|
||||
<Text fw={600} size="lg">
|
||||
{bond.name}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{bond.ticker} ·{" "}
|
||||
{bond.perpetual
|
||||
? t("bond.perpetual")
|
||||
: t("bond.matures", {
|
||||
date: bond.maturityDate
|
||||
? formatDate(bond.maturityDate)
|
||||
: t("bond.unknown"),
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs" mt="sm">
|
||||
<Badge color="gray" variant="light">
|
||||
{t(`stock.exchanges.${bond.realExchange}`, {
|
||||
defaultValue: t("stock.exchangeUnknown"),
|
||||
})}
|
||||
</Badge>
|
||||
|
||||
{!!bond.classCode && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.classCode", { code: bond.classCode })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{!!bond.isin && (
|
||||
<Text c="dimmed" mt={4} size="xs">
|
||||
{t("stock.isin", { value: bond.isin })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text c="dimmed" mt={4} size="xs">
|
||||
{t("bond.nominal")}: {formatMoney(bond.nominal)} ·{" "}
|
||||
{t("bond.couponFrequency")}:{" "}
|
||||
{bond.couponQuantityPerYear ?? t("bond.unknown")}
|
||||
</Text>
|
||||
|
||||
{!!bond.floatingCoupon && (
|
||||
<Badge mt="xs" variant="light">
|
||||
{t("bond.floatingCoupon")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{bond.apiTradeAvailable === false && (
|
||||
<Text c="dimmed" mt={4} size="xs">
|
||||
{t("stock.apiUnavailable")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={classes.quote}>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.percentOfNominal")}
|
||||
</Text>
|
||||
|
||||
<Text fw={650} size="xl">
|
||||
{bond.pricePercent === null
|
||||
? t("bond.unknown")
|
||||
: `${formatDecimal(bond.pricePercent)} %`}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{bond.pricePercent !== null && bond.priceAt
|
||||
? t("stock.priceAt", {
|
||||
date: formatSnapshotTime(bond.priceAt),
|
||||
})
|
||||
: t("stock.priceUnavailable")}
|
||||
</Text>
|
||||
|
||||
{bond.apiTradeAvailable === true && (
|
||||
<Text c="green.8" mt="sm" size="xs">
|
||||
{t("stock.apiAvailable")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text c="dimmed">
|
||||
{t(vm.partial ? "investment.partial" : "bond.noSearchResults")}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const BondsPageView: FC<BondsPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={1}>{t("bond.searchTitle")}</Title>
|
||||
|
||||
<Text c="dimmed" mt="xs">
|
||||
{t("bond.searchSubtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={vm.refreshing || !vm.canSearch}
|
||||
onClick={vm.retry}
|
||||
w="fit-content"
|
||||
>
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
|
||||
{!!vm.partial && <Alert role="status">{t("investment.partial")}</Alert>}
|
||||
|
||||
<TextInput
|
||||
label={t("bond.searchLabel")}
|
||||
maxLength={80}
|
||||
onChange={vm.onQueryChange}
|
||||
placeholder={t("bond.searchPlaceholder")}
|
||||
size="md"
|
||||
value={vm.query}
|
||||
/>
|
||||
|
||||
{!vm.canSearch && <Text c="dimmed">{t("stock.searchHint")}</Text>}
|
||||
|
||||
{!!vm.error && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
<Stack gap="xs">
|
||||
<Text>
|
||||
{t(
|
||||
isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected)
|
||||
|| isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken)
|
||||
? "bond.connect"
|
||||
: "stock.sectionError",
|
||||
)}
|
||||
<InstrumentSearch kind="bond" vm={vm}>
|
||||
{vm.items.map((bond) => {
|
||||
const quote = (
|
||||
<>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.percentOfNominal")}
|
||||
</Text>
|
||||
|
||||
<Anchor component={Link} to="/settings">
|
||||
{t("accounts.openSettings")}
|
||||
</Anchor>
|
||||
<Text fw={650} size="xl">
|
||||
{bond.pricePercent === null
|
||||
? t("bond.unknown")
|
||||
: `${formatDecimal(bond.pricePercent)} %`}
|
||||
</Text>
|
||||
|
||||
<Button onClick={vm.retry} variant="default">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
<Text c="dimmed" size="xs">
|
||||
{bond.pricePercent !== null && bond.priceAt
|
||||
? t("stock.priceAt", {
|
||||
date: formatSnapshotTime(bond.priceAt),
|
||||
})
|
||||
: t("stock.priceUnavailable")}
|
||||
</Text>
|
||||
|
||||
<BondsPageViewResults {...props} />
|
||||
</Stack>
|
||||
{bond.apiTradeAvailable === true && (
|
||||
<Text c="green.8" mt="sm" size="xs">
|
||||
{t("stock.apiAvailable")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<InstrumentSearchCard
|
||||
instrument={bond}
|
||||
key={bond.instrumentUid}
|
||||
quote={quote}
|
||||
>
|
||||
<Text c="dimmed" size="sm">
|
||||
{bond.ticker} ·{" "}
|
||||
{bond.perpetual
|
||||
? t("bond.perpetual")
|
||||
: t("bond.matures", {
|
||||
date: bond.maturityDate
|
||||
? formatDate(bond.maturityDate)
|
||||
: t("bond.unknown"),
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs" mt="sm">
|
||||
<Badge color="gray" variant="light">
|
||||
{t(`stock.exchanges.${bond.realExchange}`, {
|
||||
defaultValue: t("stock.exchangeUnknown"),
|
||||
})}
|
||||
</Badge>
|
||||
|
||||
{!!bond.classCode && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.classCode", { code: bond.classCode })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{!!bond.isin && (
|
||||
<Text c="dimmed" mt={4} size="xs">
|
||||
{t("stock.isin", { value: bond.isin })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text c="dimmed" mt={4} size="xs">
|
||||
{t("bond.nominal")}: {formatMoney(bond.nominal)} ·{" "}
|
||||
{t("bond.couponFrequency")}:{" "}
|
||||
{bond.couponQuantityPerYear ?? t("bond.unknown")}
|
||||
</Text>
|
||||
|
||||
{!!bond.floatingCoupon && (
|
||||
<Badge mt="xs" variant="light">
|
||||
{t("bond.floatingCoupon")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{bond.apiTradeAvailable === false && (
|
||||
<Text c="dimmed" mt={4} size="xs">
|
||||
{t("stock.apiUnavailable")}
|
||||
</Text>
|
||||
)}
|
||||
</InstrumentSearchCard>
|
||||
);
|
||||
})}
|
||||
</InstrumentSearch>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import type { ChangeEvent } from "react";
|
||||
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
import { MIN_BOND_SEARCH_LENGTH, useBondSearch } from "@/entities/bond";
|
||||
import { useInstrumentSearch } from "@/features/instrument-search";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
type BondsPageVm = ReturnType<typeof useBondsPageVm>;
|
||||
|
||||
function useBondsPageVm() {
|
||||
const [parameters, setParameters] = useSearchParams();
|
||||
const query = parameters.get("q") ?? "";
|
||||
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
|
||||
const search = useInstrumentSearch(MIN_BOND_SEARCH_LENGTH);
|
||||
const { canSearch, debounced, isWaiting, query } = search;
|
||||
const result = useBondSearch(debounced);
|
||||
const sync = useScreenSync({ query: debounced, screen: "bonds" }, [result]);
|
||||
const canSearch = query.trim().length >= MIN_BOND_SEARCH_LENGTH;
|
||||
const isWaiting = query.trim() !== debounced;
|
||||
|
||||
return {
|
||||
canSearch,
|
||||
@@ -37,10 +28,7 @@ function useBondsPageVm() {
|
||||
|| (!result.data
|
||||
&& !result.isError
|
||||
&& result.meta?.sync.state !== "failed")),
|
||||
onQueryChange: (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const q = event.currentTarget.value;
|
||||
setParameters(q ? { q } : {}, { replace: true });
|
||||
},
|
||||
onQueryChange: search.onQueryChange,
|
||||
partial: result.meta?.availability === "partial",
|
||||
query,
|
||||
ready:
|
||||
|
||||
@@ -349,6 +349,12 @@ describe("CardPage", () => {
|
||||
it("archives without deleting history and restores financial actions", async () => {
|
||||
setup();
|
||||
await screen.findByText("Перевод на «Запасная»");
|
||||
server.use(http.patch("*/api/v1/bank-cards/a", () =>
|
||||
HttpResponse.json({ code: "BANK_WRITE_CONFLICT" }, { status: 409 }),
|
||||
));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Убрать в архив" }));
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.getByRole("button", { name: "Добавить операцию" })).toBeEnabled();
|
||||
let current = card;
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
@@ -376,6 +382,7 @@ describe("CardPage", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Убрать в архив" }));
|
||||
await screen.findByRole("button", { name: "Вернуть из архива" });
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Добавить операцию" }),
|
||||
).toBeDisabled();
|
||||
|
||||
@@ -34,7 +34,6 @@ function useCardPageVm() {
|
||||
|
||||
const card = useBankCard(cardId);
|
||||
const updateCard = useUpdateBankCard();
|
||||
const [archiveError, setArchiveError] = useState<unknown>();
|
||||
const cards = useBankCards(true);
|
||||
|
||||
const history = useBankOperations(cardId, {
|
||||
@@ -94,7 +93,7 @@ function useCardPageVm() {
|
||||
});
|
||||
|
||||
return {
|
||||
archiveError,
|
||||
archiveError: updateCard.isError ? updateCard.error : undefined,
|
||||
archivePending: updateCard.isPending,
|
||||
canAddOperation: !card.data?.archivedAt && Boolean(cards.data),
|
||||
card: card.data,
|
||||
@@ -149,19 +148,13 @@ function useCardPageVm() {
|
||||
loading: summary.isLoading,
|
||||
retry: () => void summary.refetch(),
|
||||
},
|
||||
toggleArchive: async () => {
|
||||
toggleArchive: () => {
|
||||
if (!card.data) return;
|
||||
setArchiveError(undefined);
|
||||
|
||||
try {
|
||||
await updateCard.mutateAsync({
|
||||
body: { archived: !card.data.archivedAt, version: card.data.version },
|
||||
id: card.data.id,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
setArchiveError(error);
|
||||
}
|
||||
updateCard.mutate({
|
||||
body: { archived: !card.data.archivedAt, version: card.data.version },
|
||||
id: card.data.id,
|
||||
});
|
||||
},
|
||||
totalPages:
|
||||
pageSize === "all"
|
||||
|
||||
@@ -4,25 +4,17 @@ import { DateInput } from "@mantine/dates";
|
||||
import { useController, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { OperationValues } from "./OperationForm.types";
|
||||
import type {
|
||||
OperationFieldsProps,
|
||||
OperationValues,
|
||||
} from "./OperationForm.types";
|
||||
|
||||
type Choice = { label: string; value: string };
|
||||
|
||||
type OperationFieldsProps = {
|
||||
cards: Choice[];
|
||||
categories: Choice[];
|
||||
dateInputValue: (value: string) => string;
|
||||
defaultCategory: string;
|
||||
editing: boolean;
|
||||
kind: OperationValues["kind"];
|
||||
kinds: Choice[];
|
||||
maxDate: () => string;
|
||||
refundCardId: string;
|
||||
targets: Choice[];
|
||||
validDate: (value: string) => boolean;
|
||||
};
|
||||
|
||||
function useOperationFieldsVm(props: OperationFieldsProps) {
|
||||
function useOperationFieldsVm(props: Pick<OperationFieldsProps,
|
||||
"dateInputValue"
|
||||
| "kind"
|
||||
| "refundCardId"
|
||||
| "validDate"
|
||||
>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { BankCard, BankOperation } from "@/entities/bank-card";
|
||||
|
||||
type Choice = { label: string; value: string };
|
||||
|
||||
type OperationFieldsProps = {
|
||||
cards: Choice[];
|
||||
categories: Choice[];
|
||||
dateInputValue: (value: string) => string;
|
||||
defaultCategory: string;
|
||||
editing: boolean;
|
||||
kind: OperationValues["kind"];
|
||||
kinds: Choice[];
|
||||
maxDate: () => string;
|
||||
refundCardId: string;
|
||||
targets: Choice[];
|
||||
validDate: (value: string) => boolean;
|
||||
};
|
||||
|
||||
type OperationFormProps = {
|
||||
card: BankCard;
|
||||
cards: BankCard[];
|
||||
@@ -18,4 +34,9 @@ type OperationValues = {
|
||||
targetCardId: string;
|
||||
};
|
||||
|
||||
export type { OperationFormProps, OperationValues };
|
||||
export type {
|
||||
Choice,
|
||||
OperationFieldsProps,
|
||||
OperationFormProps,
|
||||
OperationValues,
|
||||
};
|
||||
|
||||
@@ -17,28 +17,12 @@ import type { BankOperation } from "@/entities/bank-card";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
import type { OperationValues } from "./OperationForm.types";
|
||||
import type { Choice, OperationFieldsProps } from "./OperationForm.types";
|
||||
|
||||
import { useOperationFieldsVm } from "./OperationFields.vm";
|
||||
import classes from "./OperationForm.module.css";
|
||||
import { useRefundFieldsVm } from "./RefundFields.vm";
|
||||
|
||||
type Choice = { label: string; value: string };
|
||||
|
||||
type OperationFieldsProps = {
|
||||
cards: Choice[];
|
||||
categories: Choice[];
|
||||
dateInputValue: (value: string) => string;
|
||||
defaultCategory: string;
|
||||
editing: boolean;
|
||||
kind: OperationValues["kind"];
|
||||
kinds: Choice[];
|
||||
maxDate: () => string;
|
||||
refundCardId: string;
|
||||
targets: Choice[];
|
||||
validDate: (value: string) => boolean;
|
||||
};
|
||||
|
||||
const OperationFields: FC<OperationFieldsProps> = (props) => {
|
||||
const {
|
||||
cardIdField,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
.card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(150px, 210px);
|
||||
gap: var(--mantine-spacing-md);
|
||||
align-items: start;
|
||||
padding: var(--mantine-spacing-lg);
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--mantine-color-blue-4);
|
||||
background: var(--mantine-color-blue-0);
|
||||
}
|
||||
.card:focus-visible {
|
||||
outline: 2px solid var(--mantine-color-blue-6);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.identity {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.quote {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 40em) {
|
||||
.card {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
.quote {
|
||||
grid-column: 2;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -1,202 +1,89 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { Badge, Group, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||
import {
|
||||
InstrumentSearch,
|
||||
InstrumentSearchCard,
|
||||
} from "@/features/instrument-search";
|
||||
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
|
||||
import type { StocksPageVm } from "./StocksPage.vm";
|
||||
|
||||
import classes from "./StocksPage.module.css";
|
||||
|
||||
type StocksPageViewProps = { vm: StocksPageVm };
|
||||
|
||||
const StocksPageViewResults: FC<StocksPageViewProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { vm } = props;
|
||||
|
||||
if (vm.loading) {
|
||||
return (
|
||||
<LoadingSkeleton label={t("stock.searching")}>
|
||||
<Stack>
|
||||
<Skeleton h={78} />
|
||||
|
||||
<Skeleton h={78} />
|
||||
|
||||
<Skeleton h={78} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
|
||||
if (!vm.ready) return;
|
||||
|
||||
if (vm.items.length > 0) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.searchVariants")}
|
||||
</Text>
|
||||
|
||||
{vm.items.map(stock => (
|
||||
<Paper
|
||||
className={classes.card}
|
||||
component={Link}
|
||||
key={stock.instrumentUid}
|
||||
to={stock.to}
|
||||
withBorder
|
||||
>
|
||||
<Avatar alt="" radius="md" size={48} src={stock.logoSrc}>
|
||||
{stock.name[0]}
|
||||
</Avatar>
|
||||
|
||||
<div className={classes.identity}>
|
||||
<Text fw={600} size="lg">
|
||||
{stock.name}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{stock.ticker} ·{" "}
|
||||
{t(`stock.shareTypes.${stock.shareType}`, {
|
||||
defaultValue: t("stock.share"),
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs" mt="sm">
|
||||
<Badge color="gray" variant="light">
|
||||
{t(`stock.exchanges.${stock.realExchange}`, {
|
||||
defaultValue: t("stock.exchangeUnknown"),
|
||||
})}
|
||||
</Badge>
|
||||
|
||||
{!!stock.classCode && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.classCode", { code: stock.classCode })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{!!stock.isin && (
|
||||
<Text c="dimmed" mt={4} size="xs">
|
||||
{t("stock.isin", { value: stock.isin })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{stock.apiTradeAvailable === false && (
|
||||
<Text c="dimmed" mt={4} size="xs">
|
||||
{t("stock.apiUnavailable")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={classes.quote}>
|
||||
<Text fw={650} size="xl">
|
||||
{stock.price ? formatMoney(stock.price) : "—"}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{stock.price && stock.priceAt
|
||||
? t("stock.priceAt", {
|
||||
date: formatSnapshotTime(stock.priceAt),
|
||||
})
|
||||
: t("stock.priceUnavailable")}
|
||||
</Text>
|
||||
|
||||
{stock.apiTradeAvailable === true && (
|
||||
<Text c="green.8" mt="sm" size="xs">
|
||||
{t("stock.apiAvailable")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text c="dimmed">
|
||||
{t(vm.partial ? "investment.partial" : "stock.noSearchResults")}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const StocksPageView: FC<StocksPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={1}>{t("stock.searchTitle")}</Title>
|
||||
|
||||
<Text c="dimmed" mt="xs">
|
||||
{t("stock.searchSubtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={vm.refreshing || !vm.canSearch}
|
||||
onClick={vm.retry}
|
||||
w="fit-content"
|
||||
>
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
|
||||
{!!vm.partial && <Alert role="status">{t("investment.partial")}</Alert>}
|
||||
|
||||
<TextInput
|
||||
label={t("stock.searchLabel")}
|
||||
maxLength={80}
|
||||
onChange={vm.onQueryChange}
|
||||
placeholder={t("stock.searchPlaceholder")}
|
||||
size="md"
|
||||
value={vm.query}
|
||||
/>
|
||||
|
||||
{!vm.canSearch && <Text c="dimmed">{t("stock.searchHint")}</Text>}
|
||||
|
||||
{!!vm.error && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
<Stack gap="xs">
|
||||
<Text>
|
||||
{t(
|
||||
isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected)
|
||||
|| isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken)
|
||||
? "stock.connect"
|
||||
: "stock.sectionError",
|
||||
)}
|
||||
<InstrumentSearch kind="stock" vm={vm}>
|
||||
{vm.items.map((stock) => {
|
||||
const quote = (
|
||||
<>
|
||||
<Text fw={650} size="xl">
|
||||
{stock.price ? formatMoney(stock.price) : "—"}
|
||||
</Text>
|
||||
|
||||
<Anchor component={Link} to="/settings">
|
||||
{t("accounts.openSettings")}
|
||||
</Anchor>
|
||||
<Text c="dimmed" size="xs">
|
||||
{stock.price && stock.priceAt
|
||||
? t("stock.priceAt", {
|
||||
date: formatSnapshotTime(stock.priceAt),
|
||||
})
|
||||
: t("stock.priceUnavailable")}
|
||||
</Text>
|
||||
|
||||
<Button onClick={vm.retry} variant="default">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{stock.apiTradeAvailable === true && (
|
||||
<Text c="green.8" mt="sm" size="xs">
|
||||
{t("stock.apiAvailable")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
<StocksPageViewResults {...props} />
|
||||
</Stack>
|
||||
return (
|
||||
<InstrumentSearchCard
|
||||
instrument={stock}
|
||||
key={stock.instrumentUid}
|
||||
quote={quote}
|
||||
>
|
||||
<Text c="dimmed" size="sm">
|
||||
{stock.ticker} ·{" "}
|
||||
{t(`stock.shareTypes.${stock.shareType}`, {
|
||||
defaultValue: t("stock.share"),
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs" mt="sm">
|
||||
<Badge color="gray" variant="light">
|
||||
{t(`stock.exchanges.${stock.realExchange}`, {
|
||||
defaultValue: t("stock.exchangeUnknown"),
|
||||
})}
|
||||
</Badge>
|
||||
|
||||
{!!stock.classCode && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.classCode", { code: stock.classCode })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{!!stock.isin && (
|
||||
<Text c="dimmed" mt={4} size="xs">
|
||||
{t("stock.isin", { value: stock.isin })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{stock.apiTradeAvailable === false && (
|
||||
<Text c="dimmed" mt={4} size="xs">
|
||||
{t("stock.apiUnavailable")}
|
||||
</Text>
|
||||
)}
|
||||
</InstrumentSearchCard>
|
||||
);
|
||||
})}
|
||||
</InstrumentSearch>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import type { ChangeEvent } from "react";
|
||||
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
import { MIN_STOCK_SEARCH_LENGTH, useStockSearch } from "@/entities/stock";
|
||||
import { useInstrumentSearch } from "@/features/instrument-search";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
type StocksPageVm = ReturnType<typeof useStocksPageVm>;
|
||||
|
||||
function useStocksPageVm() {
|
||||
const [parameters, setParameters] = useSearchParams();
|
||||
const query = parameters.get("q") ?? "";
|
||||
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
|
||||
const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH);
|
||||
const { canSearch, debounced, isWaiting, query } = search;
|
||||
const result = useStockSearch(debounced);
|
||||
const sync = useScreenSync({ query: debounced, screen: "stocks" }, [result]);
|
||||
const canSearch = query.trim().length >= MIN_STOCK_SEARCH_LENGTH;
|
||||
const isWaiting = query.trim() !== debounced;
|
||||
|
||||
return {
|
||||
canSearch,
|
||||
@@ -37,10 +28,7 @@ function useStocksPageVm() {
|
||||
|| (!result.data
|
||||
&& !result.isError
|
||||
&& result.meta?.sync.state !== "failed")),
|
||||
onQueryChange: (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const q = event.currentTarget.value;
|
||||
setParameters(q ? { q } : {}, { replace: true });
|
||||
},
|
||||
onQueryChange: search.onQueryChange,
|
||||
partial: result.meta?.availability === "partial",
|
||||
query,
|
||||
ready:
|
||||
|
||||
Reference in New Issue
Block a user