refactor: share instrument search presentation
This commit is contained in:
@@ -54,8 +54,13 @@ describe("AppBreadcrumbs", () => {
|
|||||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("links to the account hierarchy from operations", () => {
|
it.each([
|
||||||
renderBreadcrumbs("/accounts/account-1/operations", [
|
["operations", "Операции"],
|
||||||
|
["OPERATIONS", "Операции"],
|
||||||
|
["events", "Будущие события"],
|
||||||
|
["income", "Пассивный доход"],
|
||||||
|
])("links to the account hierarchy from %s", (section, title) => {
|
||||||
|
renderBreadcrumbs(`/accounts/account-1/${section}`, [
|
||||||
{ id: "account-1", name: "ИИС" },
|
{ id: "account-1", name: "ИИС" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -66,7 +71,7 @@ describe("AppBreadcrumbs", () => {
|
|||||||
"href",
|
"href",
|
||||||
"/accounts/account-1",
|
"/accounts/account-1",
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Операции")).toHaveAttribute(
|
expect(screen.getByText(title)).toHaveAttribute(
|
||||||
"aria-current",
|
"aria-current",
|
||||||
"page",
|
"page",
|
||||||
);
|
);
|
||||||
@@ -86,7 +91,11 @@ describe("AppBreadcrumbs", () => {
|
|||||||
expect(screen.queryByText("account-1")).not.toBeInTheDocument();
|
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);
|
renderBreadcrumbs(path);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -14,14 +14,12 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
|
|||||||
const AppBreadcrumbs: FC = () => {
|
const AppBreadcrumbs: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const operations = useMatch({
|
const account = useMatch("/accounts/:accountId/:section?");
|
||||||
end: true,
|
|
||||||
path: "/accounts/:accountId/operations",
|
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 card = useMatch("/cards/:cardId");
|
||||||
const cards = useMatch("/cards");
|
const cards = useMatch("/cards");
|
||||||
const accounts = useMatch({ end: true, path: "/accounts" });
|
const accounts = useMatch({ end: true, path: "/accounts" });
|
||||||
@@ -32,56 +30,20 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
|
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
|
||||||
if (stock)
|
if (stock)
|
||||||
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
|
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
|
||||||
if (operations)
|
if (account && (section || !account.params.section))
|
||||||
return (
|
return (
|
||||||
<Stack gap={0} mb="md">
|
<Stack gap={0} mb="md">
|
||||||
<AccountBreadcrumbs
|
<AccountBreadcrumbs
|
||||||
accountId={operations.params.accountId ?? ""}
|
accountId={account.params.accountId ?? ""}
|
||||||
section="operations"
|
section={section}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AccountScopeSwitch
|
<AccountScopeSwitch
|
||||||
accountId={operations.params.accountId ?? ""}
|
accountId={account.params.accountId ?? ""}
|
||||||
section="operations"
|
section={section}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</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 (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
|
||||||
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
||||||
if (accounts)
|
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 type { FC } from "react";
|
||||||
|
|
||||||
import {
|
import { Badge, Group, Text } from "@mantine/core";
|
||||||
Alert,
|
|
||||||
Anchor,
|
|
||||||
Avatar,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
Skeleton,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
|
||||||
|
|
||||||
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
import {
|
||||||
|
InstrumentSearch,
|
||||||
|
InstrumentSearchCard,
|
||||||
|
} from "@/features/instrument-search";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
@@ -28,202 +16,101 @@ import {
|
|||||||
|
|
||||||
import type { BondsPageVm } from "./BondsPage.vm";
|
import type { BondsPageVm } from "./BondsPage.vm";
|
||||||
|
|
||||||
import classes from "./BondsPage.module.css";
|
|
||||||
|
|
||||||
type BondsPageViewProps = { vm: BondsPageVm };
|
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 BondsPageView: FC<BondsPageViewProps> = (props) => {
|
||||||
const { vm } = props;
|
const { vm } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<InstrumentSearch kind="bond" vm={vm}>
|
||||||
<div>
|
{vm.items.map((bond) => {
|
||||||
<Title order={1}>{t("bond.searchTitle")}</Title>
|
const quote = (
|
||||||
|
<>
|
||||||
<Text c="dimmed" mt="xs">
|
<Text c="dimmed" size="xs">
|
||||||
{t("bond.searchSubtitle")}
|
{t("bond.percentOfNominal")}
|
||||||
</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",
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Anchor component={Link} to="/settings">
|
<Text fw={650} size="xl">
|
||||||
{t("accounts.openSettings")}
|
{bond.pricePercent === null
|
||||||
</Anchor>
|
? t("bond.unknown")
|
||||||
|
: `${formatDecimal(bond.pricePercent)} %`}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Button onClick={vm.retry} variant="default">
|
<Text c="dimmed" size="xs">
|
||||||
{t("accounts.retry")}
|
{bond.pricePercent !== null && bond.priceAt
|
||||||
</Button>
|
? t("stock.priceAt", {
|
||||||
</Stack>
|
date: formatSnapshotTime(bond.priceAt),
|
||||||
</Alert>
|
})
|
||||||
)}
|
: t("stock.priceUnavailable")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<BondsPageViewResults {...props} />
|
{bond.apiTradeAvailable === true && (
|
||||||
</Stack>
|
<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 { MIN_BOND_SEARCH_LENGTH, useBondSearch } from "@/entities/bond";
|
||||||
|
import { useInstrumentSearch } from "@/features/instrument-search";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { instrumentLogoUrl } from "@/shared/lib";
|
import { instrumentLogoUrl } from "@/shared/lib";
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
|
||||||
|
|
||||||
type BondsPageVm = ReturnType<typeof useBondsPageVm>;
|
type BondsPageVm = ReturnType<typeof useBondsPageVm>;
|
||||||
|
|
||||||
function useBondsPageVm() {
|
function useBondsPageVm() {
|
||||||
const [parameters, setParameters] = useSearchParams();
|
const search = useInstrumentSearch(MIN_BOND_SEARCH_LENGTH);
|
||||||
const query = parameters.get("q") ?? "";
|
const { canSearch, debounced, isWaiting, query } = search;
|
||||||
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
|
|
||||||
const result = useBondSearch(debounced);
|
const result = useBondSearch(debounced);
|
||||||
const sync = useScreenSync({ query: debounced, screen: "bonds" }, [result]);
|
const sync = useScreenSync({ query: debounced, screen: "bonds" }, [result]);
|
||||||
const canSearch = query.trim().length >= MIN_BOND_SEARCH_LENGTH;
|
|
||||||
const isWaiting = query.trim() !== debounced;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canSearch,
|
canSearch,
|
||||||
@@ -37,10 +28,7 @@ function useBondsPageVm() {
|
|||||||
|| (!result.data
|
|| (!result.data
|
||||||
&& !result.isError
|
&& !result.isError
|
||||||
&& result.meta?.sync.state !== "failed")),
|
&& result.meta?.sync.state !== "failed")),
|
||||||
onQueryChange: (event: ChangeEvent<HTMLInputElement>) => {
|
onQueryChange: search.onQueryChange,
|
||||||
const q = event.currentTarget.value;
|
|
||||||
setParameters(q ? { q } : {}, { replace: true });
|
|
||||||
},
|
|
||||||
partial: result.meta?.availability === "partial",
|
partial: result.meta?.availability === "partial",
|
||||||
query,
|
query,
|
||||||
ready:
|
ready:
|
||||||
|
|||||||
@@ -349,6 +349,12 @@ describe("CardPage", () => {
|
|||||||
it("archives without deleting history and restores financial actions", async () => {
|
it("archives without deleting history and restores financial actions", async () => {
|
||||||
setup();
|
setup();
|
||||||
await screen.findByText("Перевод на «Запасная»");
|
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;
|
let current = card;
|
||||||
const bodies: unknown[] = [];
|
const bodies: unknown[] = [];
|
||||||
server.use(
|
server.use(
|
||||||
@@ -376,6 +382,7 @@ describe("CardPage", () => {
|
|||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Убрать в архив" }));
|
fireEvent.click(screen.getByRole("button", { name: "Убрать в архив" }));
|
||||||
await screen.findByRole("button", { name: "Вернуть из архива" });
|
await screen.findByRole("button", { name: "Вернуть из архива" });
|
||||||
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Добавить операцию" }),
|
screen.getByRole("button", { name: "Добавить операцию" }),
|
||||||
).toBeDisabled();
|
).toBeDisabled();
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ function useCardPageVm() {
|
|||||||
|
|
||||||
const card = useBankCard(cardId);
|
const card = useBankCard(cardId);
|
||||||
const updateCard = useUpdateBankCard();
|
const updateCard = useUpdateBankCard();
|
||||||
const [archiveError, setArchiveError] = useState<unknown>();
|
|
||||||
const cards = useBankCards(true);
|
const cards = useBankCards(true);
|
||||||
|
|
||||||
const history = useBankOperations(cardId, {
|
const history = useBankOperations(cardId, {
|
||||||
@@ -94,7 +93,7 @@ function useCardPageVm() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
archiveError,
|
archiveError: updateCard.isError ? updateCard.error : undefined,
|
||||||
archivePending: updateCard.isPending,
|
archivePending: updateCard.isPending,
|
||||||
canAddOperation: !card.data?.archivedAt && Boolean(cards.data),
|
canAddOperation: !card.data?.archivedAt && Boolean(cards.data),
|
||||||
card: card.data,
|
card: card.data,
|
||||||
@@ -149,19 +148,13 @@ function useCardPageVm() {
|
|||||||
loading: summary.isLoading,
|
loading: summary.isLoading,
|
||||||
retry: () => void summary.refetch(),
|
retry: () => void summary.refetch(),
|
||||||
},
|
},
|
||||||
toggleArchive: async () => {
|
toggleArchive: () => {
|
||||||
if (!card.data) return;
|
if (!card.data) return;
|
||||||
setArchiveError(undefined);
|
|
||||||
|
|
||||||
try {
|
updateCard.mutate({
|
||||||
await updateCard.mutateAsync({
|
body: { archived: !card.data.archivedAt, version: card.data.version },
|
||||||
body: { archived: !card.data.archivedAt, version: card.data.version },
|
id: card.data.id,
|
||||||
id: card.data.id,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
setArchiveError(error);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
totalPages:
|
totalPages:
|
||||||
pageSize === "all"
|
pageSize === "all"
|
||||||
|
|||||||
@@ -4,25 +4,17 @@ import { DateInput } from "@mantine/dates";
|
|||||||
import { useController, useFormContext } from "react-hook-form";
|
import { useController, useFormContext } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { OperationValues } from "./OperationForm.types";
|
import type {
|
||||||
|
OperationFieldsProps,
|
||||||
|
OperationValues,
|
||||||
|
} from "./OperationForm.types";
|
||||||
|
|
||||||
type Choice = { label: string; value: string };
|
function useOperationFieldsVm(props: Pick<OperationFieldsProps,
|
||||||
|
"dateInputValue"
|
||||||
type OperationFieldsProps = {
|
| "kind"
|
||||||
cards: Choice[];
|
| "refundCardId"
|
||||||
categories: Choice[];
|
| "validDate"
|
||||||
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) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import type { BankCard, BankOperation } from "@/entities/bank-card";
|
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 = {
|
type OperationFormProps = {
|
||||||
card: BankCard;
|
card: BankCard;
|
||||||
cards: BankCard[];
|
cards: BankCard[];
|
||||||
@@ -18,4 +34,9 @@ type OperationValues = {
|
|||||||
targetCardId: string;
|
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 { formatMoney } from "@/shared/lib";
|
||||||
|
|
||||||
import type { OperationValues } from "./OperationForm.types";
|
import type { Choice, OperationFieldsProps } from "./OperationForm.types";
|
||||||
|
|
||||||
import { useOperationFieldsVm } from "./OperationFields.vm";
|
import { useOperationFieldsVm } from "./OperationFields.vm";
|
||||||
import classes from "./OperationForm.module.css";
|
import classes from "./OperationForm.module.css";
|
||||||
import { useRefundFieldsVm } from "./RefundFields.vm";
|
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 OperationFields: FC<OperationFieldsProps> = (props) => {
|
||||||
const {
|
const {
|
||||||
cardIdField,
|
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 type { FC } from "react";
|
||||||
|
|
||||||
import {
|
import { Badge, Group, Text } from "@mantine/core";
|
||||||
Alert,
|
|
||||||
Anchor,
|
|
||||||
Avatar,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
Skeleton,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
|
||||||
import { useTranslation } from "react-i18next";
|
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 { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||||
|
|
||||||
import type { StocksPageVm } from "./StocksPage.vm";
|
import type { StocksPageVm } from "./StocksPage.vm";
|
||||||
|
|
||||||
import classes from "./StocksPage.module.css";
|
|
||||||
|
|
||||||
type StocksPageViewProps = { vm: StocksPageVm };
|
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 StocksPageView: FC<StocksPageViewProps> = (props) => {
|
||||||
const { vm } = props;
|
const { vm } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<InstrumentSearch kind="stock" vm={vm}>
|
||||||
<div>
|
{vm.items.map((stock) => {
|
||||||
<Title order={1}>{t("stock.searchTitle")}</Title>
|
const quote = (
|
||||||
|
<>
|
||||||
<Text c="dimmed" mt="xs">
|
<Text fw={650} size="xl">
|
||||||
{t("stock.searchSubtitle")}
|
{stock.price ? formatMoney(stock.price) : "—"}
|
||||||
</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",
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Anchor component={Link} to="/settings">
|
<Text c="dimmed" size="xs">
|
||||||
{t("accounts.openSettings")}
|
{stock.price && stock.priceAt
|
||||||
</Anchor>
|
? t("stock.priceAt", {
|
||||||
|
date: formatSnapshotTime(stock.priceAt),
|
||||||
|
})
|
||||||
|
: t("stock.priceUnavailable")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Button onClick={vm.retry} variant="default">
|
{stock.apiTradeAvailable === true && (
|
||||||
{t("accounts.retry")}
|
<Text c="green.8" mt="sm" size="xs">
|
||||||
</Button>
|
{t("stock.apiAvailable")}
|
||||||
</Stack>
|
</Text>
|
||||||
</Alert>
|
)}
|
||||||
)}
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
<StocksPageViewResults {...props} />
|
return (
|
||||||
</Stack>
|
<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 { MIN_STOCK_SEARCH_LENGTH, useStockSearch } from "@/entities/stock";
|
||||||
|
import { useInstrumentSearch } from "@/features/instrument-search";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { instrumentLogoUrl } from "@/shared/lib";
|
import { instrumentLogoUrl } from "@/shared/lib";
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
|
||||||
|
|
||||||
type StocksPageVm = ReturnType<typeof useStocksPageVm>;
|
type StocksPageVm = ReturnType<typeof useStocksPageVm>;
|
||||||
|
|
||||||
function useStocksPageVm() {
|
function useStocksPageVm() {
|
||||||
const [parameters, setParameters] = useSearchParams();
|
const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH);
|
||||||
const query = parameters.get("q") ?? "";
|
const { canSearch, debounced, isWaiting, query } = search;
|
||||||
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
|
|
||||||
const result = useStockSearch(debounced);
|
const result = useStockSearch(debounced);
|
||||||
const sync = useScreenSync({ query: debounced, screen: "stocks" }, [result]);
|
const sync = useScreenSync({ query: debounced, screen: "stocks" }, [result]);
|
||||||
const canSearch = query.trim().length >= MIN_STOCK_SEARCH_LENGTH;
|
|
||||||
const isWaiting = query.trim() !== debounced;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canSearch,
|
canSearch,
|
||||||
@@ -37,10 +28,7 @@ function useStocksPageVm() {
|
|||||||
|| (!result.data
|
|| (!result.data
|
||||||
&& !result.isError
|
&& !result.isError
|
||||||
&& result.meta?.sync.state !== "failed")),
|
&& result.meta?.sync.state !== "failed")),
|
||||||
onQueryChange: (event: ChangeEvent<HTMLInputElement>) => {
|
onQueryChange: search.onQueryChange,
|
||||||
const q = event.currentTarget.value;
|
|
||||||
setParameters(q ? { q } : {}, { replace: true });
|
|
||||||
},
|
|
||||||
partial: result.meta?.availability === "partial",
|
partial: result.meta?.availability === "partial",
|
||||||
query,
|
query,
|
||||||
ready:
|
ready:
|
||||||
|
|||||||
Reference in New Issue
Block a user