276 lines
6.0 KiB
TypeScript
276 lines
6.0 KiB
TypeScript
import type { ChangeEvent, FC } from "react";
|
|
|
|
import {
|
|
Alert,
|
|
Anchor,
|
|
Avatar,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Fieldset,
|
|
Group,
|
|
NumberInput,
|
|
Pagination,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
Title,
|
|
} from "@mantine/core";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router";
|
|
|
|
import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
|
|
|
|
import type { Criteria, StocksPageVm } from "./StocksPage.vm";
|
|
|
|
import { useStocksPagePresentationVm } from "./StocksPagePresentation.vm";
|
|
|
|
type CriteriaField = {
|
|
label: keyof Criteria;
|
|
options?: readonly string[];
|
|
type?: "number";
|
|
};
|
|
|
|
type StockCardProps = { item: StocksPageVm["items"][number] };
|
|
|
|
type StockCriteriaFieldProps = {
|
|
criteria: Criteria;
|
|
field: CriteriaField;
|
|
onChange: (name: keyof Criteria, value: string) => void;
|
|
};
|
|
|
|
type StocksPageViewProps = { vm: StocksPageVm };
|
|
|
|
const criteriaFields: CriteriaField[] = [
|
|
{ label: "query" },
|
|
{
|
|
label: "sector",
|
|
options: [
|
|
"consumer",
|
|
"energy",
|
|
"financial",
|
|
"health_care",
|
|
"industrials",
|
|
"it",
|
|
"materials",
|
|
"real_estate",
|
|
"telecom",
|
|
"utilities",
|
|
],
|
|
},
|
|
{
|
|
label: "shareType",
|
|
options: [
|
|
"SHARE_TYPE_ADR",
|
|
"SHARE_TYPE_COMMON",
|
|
"SHARE_TYPE_GDR",
|
|
"SHARE_TYPE_PREFERRED",
|
|
],
|
|
},
|
|
{ label: "currency", options: ["RUB", "USD", "EUR", "CNY", "HKD", "TRY"] },
|
|
{
|
|
label: "realExchange",
|
|
options: [
|
|
"REAL_EXCHANGE_MOEX",
|
|
"REAL_EXCHANGE_RTS",
|
|
"REAL_EXCHANGE_OTC",
|
|
"REAL_EXCHANGE_DEALER",
|
|
],
|
|
},
|
|
{ label: "countryOfRisk", options: ["RU", "US", "CN", "KZ", "HK", "TR"] },
|
|
{ label: "maxLot", type: "number" },
|
|
];
|
|
|
|
function numberChange(
|
|
field: keyof Criteria,
|
|
onChange: StockCriteriaFieldProps["onChange"],
|
|
) {
|
|
return (value: number | string) => onChange(field, String(value));
|
|
}
|
|
|
|
function selectChange(
|
|
field: keyof Criteria,
|
|
onChange: StockCriteriaFieldProps["onChange"],
|
|
) {
|
|
return (value: null | string) => onChange(field, value ?? "");
|
|
}
|
|
|
|
function selectOptions(field: CriteriaField, translate: ReturnType<typeof useTranslation>["t"]) {
|
|
return field.options?.map(value => ({
|
|
label: translate(`stock.screener.options.${field.label}.${value}`, {
|
|
defaultValue: value,
|
|
}),
|
|
value,
|
|
}));
|
|
}
|
|
|
|
function textChange(
|
|
field: keyof Criteria,
|
|
onChange: StockCriteriaFieldProps["onChange"],
|
|
) {
|
|
return (event: ChangeEvent<HTMLInputElement>) =>
|
|
onChange(field, event.currentTarget.value);
|
|
}
|
|
|
|
const StockCriteriaField: FC<StockCriteriaFieldProps> = (props) => {
|
|
const { criteria, field, onChange } = props;
|
|
const { t } = useTranslation();
|
|
const options = selectOptions(field, t);
|
|
if (field.type === "number") return (
|
|
<NumberInput
|
|
label={t(`stock.screener.${field.label}`)}
|
|
min={1}
|
|
onChange={numberChange(field.label, onChange)}
|
|
value={criteria[field.label]}
|
|
/>
|
|
);
|
|
|
|
if (options) return (
|
|
<Select
|
|
clearable
|
|
data={options}
|
|
label={t(`stock.screener.${field.label}`)}
|
|
onChange={selectChange(field.label, onChange)}
|
|
value={criteria[field.label] || null}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<TextInput
|
|
label={t(`stock.screener.${field.label}`)}
|
|
onChange={textChange(field.label, onChange)}
|
|
value={criteria[field.label]}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const StockCard: FC<StockCardProps> = (props) => {
|
|
const { item } = props;
|
|
const { t } = useTranslation();
|
|
|
|
const exchange = item.realExchange
|
|
? t(`stock.exchanges.${item.realExchange}`, {
|
|
defaultValue: item.realExchange,
|
|
})
|
|
: undefined;
|
|
|
|
const lot = item.lot === null
|
|
? undefined
|
|
: t("stock.lot", { value: item.lot });
|
|
|
|
return (
|
|
<Card p="md" withBorder>
|
|
<Group gap="sm" wrap="nowrap">
|
|
<Avatar alt="" radius="md" size={40} src={instrumentLogoUrl(item.logoName)}>
|
|
{item.name[0]}
|
|
</Avatar>
|
|
|
|
<Stack gap={2} miw={0}>
|
|
<Anchor
|
|
c="inherit"
|
|
component={Link}
|
|
fw={600}
|
|
to={`/stocks/${item.instrumentUid}`}
|
|
>
|
|
{item.name}
|
|
</Anchor>
|
|
|
|
<Text c="dimmed" size="xs">
|
|
{item.ticker ?? "-"} · {item.shareType ?? "-"}
|
|
</Text>
|
|
|
|
<Group gap="xs">
|
|
{exchange && <Badge color="gray" variant="light">{exchange}</Badge>}
|
|
|
|
{item.currency && (
|
|
<Badge color="gray" variant="light">{item.currency}</Badge>
|
|
)}
|
|
|
|
{lot && <Text c="dimmed" size="xs">{lot}</Text>}
|
|
</Group>
|
|
|
|
{item.isin && (
|
|
<Text c="dimmed" size="xs">
|
|
{t("stock.isin", { value: item.isin })}
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
</Group>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
const StocksPageView: FC<StocksPageViewProps> = (props) => {
|
|
const { vm } = props;
|
|
const { t } = useTranslation();
|
|
const { getControlProps, getItemProps } = useStocksPagePresentationVm();
|
|
|
|
return (
|
|
<Stack gap="lg">
|
|
<div>
|
|
<Title order={1}>{t("stock.screenerTitle")}</Title>
|
|
|
|
<Text c="dimmed" mt="xs">{t("stock.screenerSubtitle")}</Text>
|
|
</div>
|
|
|
|
<Fieldset disabled={vm.loading} variant="unstyled">
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
|
{criteriaFields.map(field => (
|
|
<StockCriteriaField
|
|
criteria={vm.criteria}
|
|
field={field}
|
|
key={field.label}
|
|
onChange={vm.onCriteriaChange}
|
|
/>
|
|
))}
|
|
</SimpleGrid>
|
|
|
|
<Group mt="lg">
|
|
<Button loading={vm.loading} onClick={vm.onSearch}>
|
|
{t("stock.screener.search")}
|
|
</Button>
|
|
</Group>
|
|
</Fieldset>
|
|
|
|
{!!vm.error && (
|
|
<Alert color="orange" role="status">{t("stock.screener.error")}</Alert>
|
|
)}
|
|
|
|
{vm.resultTime && (
|
|
<Text c="dimmed" size="sm">
|
|
{t("stock.screener.asOf", {
|
|
date: formatSnapshotTime(vm.resultTime),
|
|
})}
|
|
</Text>
|
|
)}
|
|
|
|
{vm.searched && !vm.loading && !vm.error && vm.items.length === 0 && (
|
|
<Text c="dimmed">{t("stock.screener.empty")}</Text>
|
|
)}
|
|
|
|
<Stack gap="sm">
|
|
{vm.items.map(item => (
|
|
<StockCard item={item} key={item.instrumentUid} />
|
|
))}
|
|
</Stack>
|
|
|
|
{vm.totalPages > 1 && (
|
|
<Group aria-label={t("stock.screener.pagination")} component="nav" justify="center">
|
|
<Pagination
|
|
disabled={vm.loading}
|
|
getControlProps={getControlProps}
|
|
getItemProps={getItemProps}
|
|
onChange={vm.onPage}
|
|
total={vm.totalPages}
|
|
value={vm.page ?? 1}
|
|
/>
|
|
</Group>
|
|
)}
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export { StocksPageView };
|