From 32a77860c8eaea4a59a26e55b2d66976fd8f9484 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 29 Sep 2026 05:14:28 +0300 Subject: [PATCH] refactor: share instrument search presentation --- .../ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx | 17 +- src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx | 58 +--- src/features/instrument-search/index.ts | 3 + .../model/useInstrumentSearch.ts | 25 ++ .../InstrumentSearch.test.tsx | 61 ++++ .../InstrumentSearch.view.tsx | 129 ++++++++ .../InstrumentSearchCard.module.css} | 0 .../InstrumentSearchCard.view.tsx | 38 +++ .../bonds/ui/BondsPage/BondsPage.view.tsx | 291 ++++++------------ src/pages/bonds/ui/BondsPage/BondsPage.vm.ts | 20 +- src/pages/card/ui/CardPage/CardPage.test.tsx | 7 + src/pages/card/ui/CardPage/CardPage.vm.ts | 19 +- .../ui/OperationForm/OperationFields.vm.ts | 28 +- .../ui/OperationForm/OperationForm.types.ts | 23 +- .../ui/OperationForm/OperationForm.view.tsx | 18 +- .../ui/StocksPage/StocksPage.module.css | 36 --- .../stocks/ui/StocksPage/StocksPage.view.tsx | 247 ++++----------- .../stocks/ui/StocksPage/StocksPage.vm.ts | 20 +- 18 files changed, 489 insertions(+), 551 deletions(-) create mode 100644 src/features/instrument-search/index.ts create mode 100644 src/features/instrument-search/model/useInstrumentSearch.ts create mode 100644 src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx create mode 100644 src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx rename src/{pages/bonds/ui/BondsPage/BondsPage.module.css => features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.module.css} (100%) create mode 100644 src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.view.tsx delete mode 100644 src/pages/stocks/ui/StocksPage/StocksPage.module.css diff --git a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx index cb82e76..e08231d 100644 --- a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx +++ b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx @@ -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( diff --git a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx index 5f178af..51e9593 100644 --- a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx @@ -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 ; if (stock) return ; - if (operations) + if (account && (section || !account.params.section)) return ( ); - if (events) - return ( - - - - - - ); - if (income) - return ( - - - - - - ); - if (account) - return ( - - - - - - ); if (card) return ; if (cards) return ; if (accounts) diff --git a/src/features/instrument-search/index.ts b/src/features/instrument-search/index.ts new file mode 100644 index 0000000..62c661e --- /dev/null +++ b/src/features/instrument-search/index.ts @@ -0,0 +1,3 @@ +export { useInstrumentSearch } from "./model/useInstrumentSearch"; +export { InstrumentSearch } from "./ui/InstrumentSearch/InstrumentSearch.view"; +export { InstrumentSearchCard } from "./ui/InstrumentSearchCard/InstrumentSearchCard.view"; diff --git a/src/features/instrument-search/model/useInstrumentSearch.ts b/src/features/instrument-search/model/useInstrumentSearch.ts new file mode 100644 index 0000000..19f94af --- /dev/null +++ b/src/features/instrument-search/model/useInstrumentSearch.ts @@ -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) => { + const q = event.currentTarget.value; + setParameters(q ? { q } : {}, { replace: true }); + }, + query, + }; +} + +export { useInstrumentSearch }; diff --git a/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx new file mode 100644 index 0000000..4f16310 --- /dev/null +++ b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx @@ -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 = [Сбербанк]; + + const view = ( + state: Partial["vm"]>, + children = cached, + ) => ( + + {children} + + ); + + const { rerender } = render(view({}), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + 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(); +}); diff --git a/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx new file mode 100644 index 0000000..e56ff5f --- /dev/null +++ b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx @@ -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) => void; + partial: boolean; + query: string; + ready: boolean; + refreshing: boolean; + retry: () => void; + }; +}; + +const InstrumentSearchResults: FC = (props) => { + const { children, kind, vm } = props; + const { t } = useTranslation(); + if (vm.loading) return ( + + + + + + + + + + ); + if (!vm.ready) return; + if (children.length > 0) return ( + + {t(`${kind}.searchVariants`)} + + {children} + + ); + + return ( + + {t(vm.partial ? "investment.partial" : `${kind}.noSearchResults`)} + + ); +}; + +const InstrumentSearch: FC = (props) => { + const { kind, vm } = props; + const { t } = useTranslation(); + + return ( + +
+ {t(`${kind}.searchTitle`)} + + + {t(`${kind}.searchSubtitle`)} + +
+ + + + {!!vm.partial && {t("investment.partial")}} + + + + {!vm.canSearch && {t("stock.searchHint")}} + + {!!vm.error && ( + + + + {t( + isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) + || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) + ? `${kind}.connect` + : "stock.sectionError", + )} + + + + {t("accounts.openSettings")} + + + + + + )} + + +
+ ); +}; + +export { InstrumentSearch }; diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.module.css b/src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.module.css similarity index 100% rename from src/pages/bonds/ui/BondsPage/BondsPage.module.css rename to src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.module.css diff --git a/src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.view.tsx b/src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.view.tsx new file mode 100644 index 0000000..70623f4 --- /dev/null +++ b/src/features/instrument-search/ui/InstrumentSearchCard/InstrumentSearchCard.view.tsx @@ -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 = (props) => { + const { children, instrument, quote } = props; + + return ( + + + {instrument.name[0]} + + +
+ {instrument.name} + + {children} +
+ +
{quote}
+
+ ); +}; + +export { InstrumentSearchCard }; diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx index 5378658..8d114bc 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx +++ b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx @@ -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 = (props) => { - const { t } = useTranslation(); - const { vm } = props; - - if (vm.loading) { - return ( - - - - - - - - - - ); - } - - if (!vm.ready) return; - - if (vm.items.length > 0) { - return ( - - - {t("bond.searchVariants")} - - - {vm.items.map(bond => ( - - - {bond.name[0]} - - -
- - {bond.name} - - - - {bond.ticker} ·{" "} - {bond.perpetual - ? t("bond.perpetual") - : t("bond.matures", { - date: bond.maturityDate - ? formatDate(bond.maturityDate) - : t("bond.unknown"), - })} - - - - - {t(`stock.exchanges.${bond.realExchange}`, { - defaultValue: t("stock.exchangeUnknown"), - })} - - - {!!bond.classCode && ( - - {t("stock.classCode", { code: bond.classCode })} - - )} - - - {!!bond.isin && ( - - {t("stock.isin", { value: bond.isin })} - - )} - - - {t("bond.nominal")}: {formatMoney(bond.nominal)} ·{" "} - {t("bond.couponFrequency")}:{" "} - {bond.couponQuantityPerYear ?? t("bond.unknown")} - - - {!!bond.floatingCoupon && ( - - {t("bond.floatingCoupon")} - - )} - - {bond.apiTradeAvailable === false && ( - - {t("stock.apiUnavailable")} - - )} -
- -
- - {t("bond.percentOfNominal")} - - - - {bond.pricePercent === null - ? t("bond.unknown") - : `${formatDecimal(bond.pricePercent)} %`} - - - - {bond.pricePercent !== null && bond.priceAt - ? t("stock.priceAt", { - date: formatSnapshotTime(bond.priceAt), - }) - : t("stock.priceUnavailable")} - - - {bond.apiTradeAvailable === true && ( - - {t("stock.apiAvailable")} - - )} -
-
- ))} -
- ); - } - - return ( - - {t(vm.partial ? "investment.partial" : "bond.noSearchResults")} - - ); -}; - const BondsPageView: FC = (props) => { const { vm } = props; const { t } = useTranslation(); return ( - -
- {t("bond.searchTitle")} - - - {t("bond.searchSubtitle")} - -
- - - - {!!vm.partial && {t("investment.partial")}} - - - - {!vm.canSearch && {t("stock.searchHint")}} - - {!!vm.error && ( - - - - {t( - isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) - || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) - ? "bond.connect" - : "stock.sectionError", - )} + + {vm.items.map((bond) => { + const quote = ( + <> + + {t("bond.percentOfNominal")} - - {t("accounts.openSettings")} - + + {bond.pricePercent === null + ? t("bond.unknown") + : `${formatDecimal(bond.pricePercent)} %`} + - - - - )} + + {bond.pricePercent !== null && bond.priceAt + ? t("stock.priceAt", { + date: formatSnapshotTime(bond.priceAt), + }) + : t("stock.priceUnavailable")} + - -
+ {bond.apiTradeAvailable === true && ( + + {t("stock.apiAvailable")} + + )} + + ); + + return ( + + + {bond.ticker} ·{" "} + {bond.perpetual + ? t("bond.perpetual") + : t("bond.matures", { + date: bond.maturityDate + ? formatDate(bond.maturityDate) + : t("bond.unknown"), + })} + + + + + {t(`stock.exchanges.${bond.realExchange}`, { + defaultValue: t("stock.exchangeUnknown"), + })} + + + {!!bond.classCode && ( + + {t("stock.classCode", { code: bond.classCode })} + + )} + + + {!!bond.isin && ( + + {t("stock.isin", { value: bond.isin })} + + )} + + + {t("bond.nominal")}: {formatMoney(bond.nominal)} ·{" "} + {t("bond.couponFrequency")}:{" "} + {bond.couponQuantityPerYear ?? t("bond.unknown")} + + + {!!bond.floatingCoupon && ( + + {t("bond.floatingCoupon")} + + )} + + {bond.apiTradeAvailable === false && ( + + {t("stock.apiUnavailable")} + + )} + + ); + })} + ); }; diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts index d02b48f..a44a835 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts +++ b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts @@ -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; 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) => { - const q = event.currentTarget.value; - setParameters(q ? { q } : {}, { replace: true }); - }, + onQueryChange: search.onQueryChange, partial: result.meta?.availability === "partial", query, ready: diff --git a/src/pages/card/ui/CardPage/CardPage.test.tsx b/src/pages/card/ui/CardPage/CardPage.test.tsx index 540b536..8140403 100644 --- a/src/pages/card/ui/CardPage/CardPage.test.tsx +++ b/src/pages/card/ui/CardPage/CardPage.test.tsx @@ -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(); diff --git a/src/pages/card/ui/CardPage/CardPage.vm.ts b/src/pages/card/ui/CardPage/CardPage.vm.ts index fa8993e..e8b067c 100644 --- a/src/pages/card/ui/CardPage/CardPage.vm.ts +++ b/src/pages/card/ui/CardPage/CardPage.vm.ts @@ -34,7 +34,6 @@ function useCardPageVm() { const card = useBankCard(cardId); const updateCard = useUpdateBankCard(); - const [archiveError, setArchiveError] = useState(); 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" diff --git a/src/pages/card/ui/OperationForm/OperationFields.vm.ts b/src/pages/card/ui/OperationForm/OperationFields.vm.ts index 53cc5dd..3477599 100644 --- a/src/pages/card/ui/OperationForm/OperationFields.vm.ts +++ b/src/pages/card/ui/OperationForm/OperationFields.vm.ts @@ -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) { const { t } = useTranslation(); const { diff --git a/src/pages/card/ui/OperationForm/OperationForm.types.ts b/src/pages/card/ui/OperationForm/OperationForm.types.ts index 09ba82e..ea4adad 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.types.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.types.ts @@ -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, +}; diff --git a/src/pages/card/ui/OperationForm/OperationForm.view.tsx b/src/pages/card/ui/OperationForm/OperationForm.view.tsx index 02db9f9..8fb73c4 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.view.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.view.tsx @@ -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 = (props) => { const { cardIdField, diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.module.css b/src/pages/stocks/ui/StocksPage/StocksPage.module.css deleted file mode 100644 index ab07ae0..0000000 --- a/src/pages/stocks/ui/StocksPage/StocksPage.module.css +++ /dev/null @@ -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; - } -} diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx index edcb503..6056e71 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx +++ b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx @@ -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 = (props) => { - const { t } = useTranslation(); - const { vm } = props; - - if (vm.loading) { - return ( - - - - - - - - - - ); - } - - if (!vm.ready) return; - - if (vm.items.length > 0) { - return ( - - - {t("stock.searchVariants")} - - - {vm.items.map(stock => ( - - - {stock.name[0]} - - -
- - {stock.name} - - - - {stock.ticker} ·{" "} - {t(`stock.shareTypes.${stock.shareType}`, { - defaultValue: t("stock.share"), - })} - - - - - {t(`stock.exchanges.${stock.realExchange}`, { - defaultValue: t("stock.exchangeUnknown"), - })} - - - {!!stock.classCode && ( - - {t("stock.classCode", { code: stock.classCode })} - - )} - - - {!!stock.isin && ( - - {t("stock.isin", { value: stock.isin })} - - )} - - {stock.apiTradeAvailable === false && ( - - {t("stock.apiUnavailable")} - - )} -
- -
- - {stock.price ? formatMoney(stock.price) : "—"} - - - - {stock.price && stock.priceAt - ? t("stock.priceAt", { - date: formatSnapshotTime(stock.priceAt), - }) - : t("stock.priceUnavailable")} - - - {stock.apiTradeAvailable === true && ( - - {t("stock.apiAvailable")} - - )} -
-
- ))} -
- ); - } - - return ( - - {t(vm.partial ? "investment.partial" : "stock.noSearchResults")} - - ); -}; - const StocksPageView: FC = (props) => { const { vm } = props; const { t } = useTranslation(); return ( - -
- {t("stock.searchTitle")} - - - {t("stock.searchSubtitle")} - -
- - - - {!!vm.partial && {t("investment.partial")}} - - - - {!vm.canSearch && {t("stock.searchHint")}} - - {!!vm.error && ( - - - - {t( - isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) - || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) - ? "stock.connect" - : "stock.sectionError", - )} + + {vm.items.map((stock) => { + const quote = ( + <> + + {stock.price ? formatMoney(stock.price) : "—"} - - {t("accounts.openSettings")} - + + {stock.price && stock.priceAt + ? t("stock.priceAt", { + date: formatSnapshotTime(stock.priceAt), + }) + : t("stock.priceUnavailable")} + - - - - )} + {stock.apiTradeAvailable === true && ( + + {t("stock.apiAvailable")} + + )} + + ); - -
+ return ( + + + {stock.ticker} ·{" "} + {t(`stock.shareTypes.${stock.shareType}`, { + defaultValue: t("stock.share"), + })} + + + + + {t(`stock.exchanges.${stock.realExchange}`, { + defaultValue: t("stock.exchangeUnknown"), + })} + + + {!!stock.classCode && ( + + {t("stock.classCode", { code: stock.classCode })} + + )} + + + {!!stock.isin && ( + + {t("stock.isin", { value: stock.isin })} + + )} + + {stock.apiTradeAvailable === false && ( + + {t("stock.apiUnavailable")} + + )} + + ); + })} + ); }; diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts index 5c6520c..5da6ec0 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts +++ b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts @@ -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; 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) => { - const q = event.currentTarget.value; - setParameters(q ? { q } : {}, { replace: true }); - }, + onQueryChange: search.onQueryChange, partial: result.meta?.availability === "partial", query, ready: