chore(release): 1.0.0 #48

Merged
codex-bot merged 37 commits from release/1.0.0 into main 2026-10-07 18:37:35 +03:00
18 changed files with 489 additions and 551 deletions
Showing only changes of commit 32a77860c8 - Show all commits
@@ -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(
+10 -48
View File
@@ -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)
+3
View File
@@ -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 };
+89 -202
View File
@@ -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>
);
};
+4 -16
View File
@@ -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();
+6 -13
View File
@@ -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: