From 561222e4686bde1dc98454a4216025c4cbc02d65 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 10 Sep 2026 21:23:41 +0300 Subject: [PATCH] feat: add stock pages and ticker navigation Add stock search and detail pages with charts, dividends and shared event rows. Use verified ticker links for portfolio names and logos, numeric dates and refreshed account cards. Update the generated API contract. --- .storybook/preview.tsx | 1 + src/app/routes/AppRoutes.tsx | 24 + src/app/ui/AppBreadcrumbs.tsx | 4 + src/app/ui/StockBreadcrumbs.tsx | 20 + .../api/useBrokerageOperations.ts | 1 + .../broker-operation/model/display.ts | 38 +- .../broker-operation/ui/LedgerEntryRow.tsx | 47 +- .../brokerage-account/model/display.ts | 15 +- .../ui/BrokerageAccountList.module.css | 147 +- .../ui/BrokerageAccountList.stories.tsx | 76 +- .../ui/BrokerageAccountList.test.tsx | 29 +- .../ui/BrokerageAccountList.tsx | 79 +- .../brokerage-event/ui/BrokerageEventCard.tsx | 7 +- src/entities/stock/api/queries.ts | 110 ++ src/entities/stock/index.ts | 11 + .../model/connection-boundary.ts | 4 +- .../ui/TbankConnectionSection.test.tsx | 8 + src/main.tsx | 1 + .../ui/AccountEventsPage.test.tsx | 4 +- .../ui/AccountIncomePage.test.tsx | 8 +- .../account-income/ui/AccountIncomePage.vm.ts | 9 +- src/pages/account/ui/AccountPage.test.tsx | 81 +- src/pages/account/ui/AccountPage.vm.ts | 48 +- .../ui/components/PortfolioStructure.view.tsx | 1 - .../ui/components/PositionCard.view.tsx | 14 +- .../ui/components/PositionGroupCard.view.tsx | 10 +- .../ui/components/PositionLogo.view.tsx | 30 +- .../ui/components/AccountsPageLoaded.view.tsx | 11 +- .../components/AccountsPageLoading.view.tsx | 27 +- src/pages/stock/index.ts | 1 + src/pages/stock/ui/StockPage.module.css | 109 ++ src/pages/stock/ui/StockPage.test.tsx | 330 ++++ src/pages/stock/ui/StockPage.tsx | 51 + src/pages/stock/ui/StockPage.view.tsx | 224 +++ src/pages/stock/ui/StockPage.vm.ts | 175 ++ .../stock/ui/components/StockChart.view.tsx | 173 ++ .../stock/ui/components/StockEvents.view.tsx | 233 +++ .../ui/components/StockPosition.view.tsx | 135 ++ .../stock/ui/components/StockSection.view.tsx | 73 + src/pages/stocks/index.ts | 1 + src/pages/stocks/ui/StocksPage.module.css | 36 + src/pages/stocks/ui/StocksPage.test.tsx | 63 + src/pages/stocks/ui/StocksPage.tsx | 7 + src/pages/stocks/ui/StocksPage.view.tsx | 152 ++ src/pages/stocks/ui/StocksPage.vm.ts | 33 + src/shared/api/authenticated-query.test.tsx | 91 +- src/shared/api/authenticated-query.ts | 35 +- src/shared/api/generated/schema.d.ts | 820 +++++++++- src/shared/api/index.ts | 5 +- src/shared/api/spec/openapi.json | 1412 ++++++++++++++++- src/shared/i18n/locales/ru.ts | 177 ++- src/shared/lib/display.test.ts | 15 +- src/shared/lib/display.ts | 19 +- src/shared/lib/index.ts | 1 + src/shared/ui/AppShell.tsx | 1 + src/shared/ui/EntryCard.tsx | 4 +- 56 files changed, 5051 insertions(+), 190 deletions(-) create mode 100644 src/app/ui/StockBreadcrumbs.tsx create mode 100644 src/entities/stock/api/queries.ts create mode 100644 src/entities/stock/index.ts create mode 100644 src/pages/stock/index.ts create mode 100644 src/pages/stock/ui/StockPage.module.css create mode 100644 src/pages/stock/ui/StockPage.test.tsx create mode 100644 src/pages/stock/ui/StockPage.tsx create mode 100644 src/pages/stock/ui/StockPage.view.tsx create mode 100644 src/pages/stock/ui/StockPage.vm.ts create mode 100644 src/pages/stock/ui/components/StockChart.view.tsx create mode 100644 src/pages/stock/ui/components/StockEvents.view.tsx create mode 100644 src/pages/stock/ui/components/StockPosition.view.tsx create mode 100644 src/pages/stock/ui/components/StockSection.view.tsx create mode 100644 src/pages/stocks/index.ts create mode 100644 src/pages/stocks/ui/StocksPage.module.css create mode 100644 src/pages/stocks/ui/StocksPage.test.tsx create mode 100644 src/pages/stocks/ui/StocksPage.tsx create mode 100644 src/pages/stocks/ui/StocksPage.view.tsx create mode 100644 src/pages/stocks/ui/StocksPage.vm.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index f7314f6..aac21cb 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,4 +1,5 @@ import "@mantine/core/styles.css"; +import "@mantine/charts/styles.css"; import "@mantine/dates/styles.css"; import "dayjs/locale/ru"; import "../src/shared/i18n"; diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes.tsx index 36c4f40..0a77f7d 100644 --- a/src/app/routes/AppRoutes.tsx +++ b/src/app/routes/AppRoutes.tsx @@ -4,6 +4,14 @@ import { Navigate, Route, Routes } from "react-router"; import { LazyPage } from "./RouteFallback"; import { Protected, Public } from "./RouteGuards"; +const StockPage = lazy(async () => { + const { StockPage } = await import("@/pages/stock"); + return { default: StockPage }; +}); +const StocksPage = lazy(async () => { + const { StocksPage } = await import("@/pages/stocks"); + return { default: StocksPage }; +}); const AccountPage = lazy(async () => { const { AccountPage } = await import("@/pages/account"); return { default: AccountPage }; @@ -77,6 +85,22 @@ const AppRoutes: FC = () => ( } > + + + + } + /> + + + + } + /> { const { t } = useTranslation(); @@ -18,6 +19,9 @@ const AppBreadcrumbs: FC = () => { const accounts = useMatch({ path: "/accounts", end: true }); const settings = useMatch({ path: "/settings", end: true }); + const stock = useMatch({ path: "/stocks/:stockId", end: true }); + if (stock) + return ; if (operations) return ( diff --git a/src/app/ui/StockBreadcrumbs.tsx b/src/app/ui/StockBreadcrumbs.tsx new file mode 100644 index 0000000..6ab2a1a --- /dev/null +++ b/src/app/ui/StockBreadcrumbs.tsx @@ -0,0 +1,20 @@ +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { useStock } from "@/entities/stock"; +import { BreadcrumbTrail } from "./BreadcrumbTrail"; + +type StockBreadcrumbsProps = { identifier: string }; +const StockBreadcrumbs: FC = (props) => { + const { data } = useStock(props.identifier); + const { t } = useTranslation(); + return ( + + ); +}; + +export { StockBreadcrumbs }; diff --git a/src/entities/broker-operation/api/useBrokerageOperations.ts b/src/entities/broker-operation/api/useBrokerageOperations.ts index b02bafa..7df4e63 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.ts +++ b/src/entities/broker-operation/api/useBrokerageOperations.ts @@ -25,6 +25,7 @@ const operationCategories = [ function brokerageOperationsQueryParams(query: BrokerageOperationsQuery) { return { + ...(query.instrumentUid ? { instrumentUid: query.instrumentUid } : {}), ...(query.from ? { from: query.from } : {}), ...(query.to ? { to: query.to } : {}), ...(query.categories?.length diff --git a/src/entities/broker-operation/model/display.ts b/src/entities/broker-operation/model/display.ts index 28f93ee..13381ac 100644 --- a/src/entities/broker-operation/model/display.ts +++ b/src/entities/broker-operation/model/display.ts @@ -50,4 +50,40 @@ function isZeroDecimal(value: string): boolean { return /^-?0(?:\.0+)?$/.test(value); } -export { paymentPresentation }; +function operationLabelKey(type: string): string { + if ( + [ + "OPERATION_TYPE_BUY", + "OPERATION_TYPE_BUY_CARD", + "OPERATION_TYPE_BUY_MARGIN", + ].includes(type) + ) + return "ledger.operationType.buy"; + if ( + [ + "OPERATION_TYPE_SELL", + "OPERATION_TYPE_SELL_CARD", + "OPERATION_TYPE_SELL_MARGIN", + ].includes(type) + ) + return "ledger.operationType.sell"; + if ( + [ + "OPERATION_TYPE_DIVIDEND", + "OPERATION_TYPE_DIV_EXT", + "OPERATION_TYPE_DIVIDEND_TRANSFER", + ].includes(type) + ) + return "ledger.operationType.dividend"; + if (type.includes("TAX")) return "ledger.operationType.tax"; + if (type.includes("FEE")) return "ledger.operationType.fee"; + if (type === "OPERATION_TYPE_INPUT_SECURITIES") + return "ledger.operationType.input"; + if (type === "OPERATION_TYPE_OUTPUT_SECURITIES") + return "ledger.operationType.output"; + if (type.startsWith("OPERATION_TYPE_TRANS_")) + return "ledger.operationType.transfer"; + return "ledger.operationType.other"; +} + +export { operationLabelKey, paymentPresentation }; diff --git a/src/entities/broker-operation/ui/LedgerEntryRow.tsx b/src/entities/broker-operation/ui/LedgerEntryRow.tsx index ad587f2..8944e50 100644 --- a/src/entities/broker-operation/ui/LedgerEntryRow.tsx +++ b/src/entities/broker-operation/ui/LedgerEntryRow.tsx @@ -1,12 +1,20 @@ +import { Anchor, Group, Text } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib"; +import { Link } from "react-router"; +import { + formatDecimal, + formatMoney, + formatSnapshotTime, + instrumentLogoUrl, +} from "@/shared/lib"; import { EntryCard } from "@/shared/ui"; import type { BrokerageOperation } from "../api/useBrokerageOperations"; -import { paymentPresentation } from "../model/display"; +import { operationLabelKey, paymentPresentation } from "../model/display"; type LedgerEntryRowProps = { operation: BrokerageOperation; + withDetails?: boolean; }; const LedgerEntryRow: FC = (props) => { @@ -16,17 +24,46 @@ const LedgerEntryRow: FC = (props) => { operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument"); const payment = paymentPresentation(operation); const metadata = [ - formatSnapshotTime(operation.occurredAt), + t("ledger.occurredAt", { date: formatSnapshotTime(operation.occurredAt) }), instrument, - operation.account?.name, + props.withDetails ? null : operation.account?.name, ] .filter(Boolean) .join(" · "); return ( + + {operation.quantity != null ? ( + + {t("ledger.quantity", { + value: formatDecimal(operation.quantity), + })} + + ) : null} + {operation.price ? ( + + {t("ledger.price", { value: formatMoney(operation.price) })} + + ) : null} + + {operation.account ? ( + + {operation.account.name} + + ) : null} + + ) : undefined + } initial={(instrument.trim()[0] ?? "?").toUpperCase()} imageSrc={instrumentLogoUrl(operation.logoName)} value={payment.amount} diff --git a/src/entities/brokerage-account/model/display.ts b/src/entities/brokerage-account/model/display.ts index c0b72e2..b92e039 100644 --- a/src/entities/brokerage-account/model/display.ts +++ b/src/entities/brokerage-account/model/display.ts @@ -6,6 +6,7 @@ type Money = NonNullable< type BrokerageAccountCard = { account: BrokerageAccount; + positionCounts: { stocks: number; bonds: number } | null; summary: | { state: "unavailable" } | { state: "empty" } @@ -21,11 +22,21 @@ function brokerageAccountCards( ): BrokerageAccountCard[] { return accounts.map((account) => { const summary = account.portfolioSummary; - if (!summary) return { account, summary: { state: "unavailable" } }; + if (!summary) + return { + account, + positionCounts: null, + summary: { state: "unavailable" }, + }; + const positionCounts = { + stocks: summary.stockPositionCount, + bonds: summary.bondPositionCount, + }; if (!summary.totalsByCurrency.length) - return { account, summary: { state: "empty" } }; + return { account, positionCounts, summary: { state: "empty" } }; return { account, + positionCounts, summary: { state: "available", totals: summary.totalsByCurrency.slice(0, 2), diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.module.css b/src/entities/brokerage-account/ui/BrokerageAccountList.module.css index b1b2578..80afbf0 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList.module.css +++ b/src/entities/brokerage-account/ui/BrokerageAccountList.module.css @@ -3,30 +3,59 @@ } .card { - background: linear-gradient( - 135deg, - var(--mantine-color-blue-0) 0%, - var(--mantine-color-body) 72% - ); - border-color: var(--mantine-color-blue-2); - box-shadow: 0 10px 28px rgb(15 23 42 / 6%); + min-width: 0; + gap: var(--mantine-spacing-xl); + background: var(--mantine-color-white); + border-color: var(--mantine-color-gray-3); + box-shadow: 0 4px 16px rgb(15 23 42 / 3%); color: inherit; text-decoration: none; transition: - transform 120ms ease, - box-shadow 120ms ease; + transform 160ms ease, + box-shadow 160ms ease, + border-color 160ms ease; } .card:hover, .card:focus-visible { - box-shadow: 0 16px 36px rgb(15 23 42 / 10%); - transform: translateY(-2px); + border-color: var(--mantine-color-blue-3); + box-shadow: 0 12px 28px rgb(15 23 42 / 8%); + transform: translateY(-3px); +} + +.card:focus-visible { + outline: 2px solid var(--mantine-color-blue-6); + outline-offset: 3px; +} + +.identity { + min-width: 0; } .kicker { - color: var(--mantine-color-blue-7); - letter-spacing: 0.04em; - text-transform: uppercase; + padding: 5px 10px; + border-radius: var(--mantine-radius-sm); + background: var(--mantine-color-blue-0); + color: var(--mantine-color-blue-8); + letter-spacing: 0.02em; +} + +.accountIcon { + width: 28px; + height: 28px; + flex-shrink: 0; + color: var(--mantine-color-blue-5); +} + +.name { + margin: 0; + font-size: var(--mantine-h3-font-size); + line-height: 1.3; + overflow-wrap: anywhere; +} + +.balance { + flex: 1; } .summary { @@ -34,20 +63,104 @@ } .total { - white-space: nowrap; + font-size: clamp(1.5rem, 2.8vw, 2.25rem); + letter-spacing: -0.035em; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.total + .total { + font-size: var(--mantine-font-size-lg); + font-weight: 500; + letter-spacing: 0; + color: var(--mantine-color-gray-7); +} + +.positions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--mantine-spacing-sm); + margin: 0; +} + +.positions > div { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 4px var(--mantine-spacing-sm); + padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); + border-radius: var(--mantine-radius-md); +} + +.stocks { + background: var(--mantine-color-blue-0); + color: var(--mantine-color-blue-8); +} + +.bonds { + background: var(--mantine-color-grape-0); + color: var(--mantine-color-grape-8); +} + +.positions dt { + font-size: var(--mantine-font-size-sm); + font-weight: 500; +} + +.positions dd { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.footer { + border-top: 1px solid var(--mantine-color-gray-2); + padding-top: var(--mantine-spacing-md); } .open { + display: inline-flex; + align-items: center; + gap: var(--mantine-spacing-xs); + margin-left: auto; color: var(--mantine-color-blue-7); } -@media (prefers-reduced-motion: reduce) { +.arrow { + font-size: var(--mantine-font-size-lg); + transition: transform 160ms ease; +} + +.card:hover .arrow { + transform: translateX(3px); +} + +@media (max-width: 36em) { .card { + padding: var(--mantine-spacing-md); + gap: var(--mantine-spacing-lg); + } + + .positions > div { + flex-direction: column; + align-items: flex-start; + padding: var(--mantine-spacing-sm); + } +} + +@media (prefers-reduced-motion: reduce) { + .card, + .arrow { transition: none; } .card:hover, - .card:focus-visible { + .card:focus-visible, + .card:hover .arrow { transform: none; } } diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.stories.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList.stories.tsx index 26a02ae..0a34306 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList.stories.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList.stories.tsx @@ -10,6 +10,8 @@ const syntheticAccount = { type: "BROKERAGE", openedOn: "2025-03-15", portfolioSummary: { + stockPositionCount: 5, + bondPositionCount: 12, totalsByCurrency: [ { currency: "RUB", value: "125000.50" }, { currency: "USD", value: "320" }, @@ -36,7 +38,7 @@ const meta = { docs: { description: { component: - "Карточки счетов WealthLedger со сводкой по валютам. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.", + "Карточки счетов WealthLedger со сводкой по валютам и числом наименований акций и облигаций. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.", }, }, }, @@ -80,7 +82,14 @@ export const Empty: Story = { name: "Нет текущих сумм", args: { accounts: brokerageAccountCards([ - { ...syntheticAccount, portfolioSummary: { totalsByCurrency: [] } }, + { + ...syntheticAccount, + portfolioSummary: { + totalsByCurrency: [], + stockPositionCount: 0, + bondPositionCount: 0, + }, + }, ]), }, parameters: { @@ -113,3 +122,66 @@ export const LongName: Story = { }, }, }; + +export const AccountGrid: Story = { + name: "Несколько счетов", + args: { + accounts: brokerageAccountCards([ + { + ...syntheticAccount, + name: "Основной", + portfolioSummary: { + totalsByCurrency: [{ currency: "RUB", value: "1256430.50" }], + stockPositionCount: 8, + bondPositionCount: 14, + }, + }, + { + ...syntheticAccount, + id: "storybook-iis", + name: "Долгосрочные инвестиции", + type: "INDIVIDUAL_INVESTMENT", + portfolioSummary: { + totalsByCurrency: [{ currency: "RUB", value: "480250" }], + stockPositionCount: 0, + bondPositionCount: 6, + }, + }, + ]), + }, +}; + +export const UnvaluedPositions: Story = { + name: "Бумаги без текущей оценки", + args: { + accounts: brokerageAccountCards([ + { + ...syntheticAccount, + portfolioSummary: { + totalsByCurrency: [], + stockPositionCount: 2, + bondPositionCount: 1, + }, + }, + ]), + }, +}; + +export const LargeValues: Story = { + name: "Длинные суммы", + args: { + accounts: brokerageAccountCards([ + { + ...syntheticAccount, + portfolioSummary: { + totalsByCurrency: [ + { currency: "RUB", value: "1234567890123456789.50" }, + { currency: "USD", value: "-9876543210987654321.50" }, + ], + stockPositionCount: 21, + bondPositionCount: 105, + }, + }, + ]), + }, +}; diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx index b8ce98c..326c9b6 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx @@ -1,5 +1,5 @@ import { MantineProvider } from "@mantine/core"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; import { brokerageAccountCards } from "../model/display"; @@ -18,6 +18,8 @@ describe("BrokerageAccountList", () => { type: "BROKERAGE", openedOn: "2024-08-13", portfolioSummary: { + stockPositionCount: 5, + bondPositionCount: 12, totalsByCurrency: [ { currency: "RUB", value: "1500" }, { currency: "USD", value: "20" }, @@ -37,7 +39,11 @@ describe("BrokerageAccountList", () => { name: "Пустой", type: "BROKERAGE", openedOn: null, - portfolioSummary: { totalsByCurrency: [] }, + portfolioSummary: { + totalsByCurrency: [], + stockPositionCount: 2, + bondPositionCount: 0, + }, }, ])} /> @@ -57,5 +63,24 @@ describe("BrokerageAccountList", () => { expect(screen.getByText("Сводка временно недоступна")).toBeInTheDocument(); expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument(); expect(screen.getAllByText("Открыть счет")).toHaveLength(3); + const available = within(screen.getByRole("link", { name: /Основной/ })); + expect(available.getByText("Акции").nextElementSibling).toHaveTextContent( + "5", + ); + expect( + available.getByText("Облигации").nextElementSibling, + ).toHaveTextContent("12"); + const unvalued = within(screen.getByRole("link", { name: /Пустой/ })); + expect(unvalued.getByText("Акции").nextElementSibling).toHaveTextContent( + "2", + ); + expect( + unvalued.getByText("Облигации").nextElementSibling, + ).toHaveTextContent("0"); + expect( + within(screen.getByRole("link", { name: /Недоступный/ })).queryByText( + "Акции", + ), + ).not.toBeInTheDocument(); }); }); diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList.tsx index 18ff8cc..c19b615 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList.tsx @@ -1,8 +1,8 @@ -import { Card, Group, Stack, Text } from "@mantine/core"; +import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { formatMoney } from "@/shared/lib"; +import { formatDate, formatMoney } from "@/shared/lib"; import type { BrokerageAccountCard } from "../model/display"; import { accountTypeKey } from "../model/display"; import classes from "./BrokerageAccountList.module.css"; @@ -30,7 +30,7 @@ const BrokerageAccountSummary: FC = (props) => { ); return ( - + {summary.totals.map((total) => ( {formatMoney(total)} @@ -43,7 +43,7 @@ const BrokerageAccountSummary: FC = (props) => { })} ) : null} - + ); }; @@ -52,42 +52,81 @@ const BrokerageAccountList: FC = (props) => { const { t } = useTranslation(); return ( - - {accounts.map(({ account, summary }) => ( + + {accounts.map(({ account, summary, positionCounts }) => ( - - - {t(accountTypeKey(account.type))} - - + + + + {t(accountTypeKey(account.type))} + + + + {account.name} + + + + {t("accounts.currentValue")} + + + {positionCounts ? ( +
+
+
{t("accounts.stocks")}
+
+ {new Intl.NumberFormat("ru-RU").format(positionCounts.stocks)} +
+
+
+
{t("accounts.bonds")}
+
+ {new Intl.NumberFormat("ru-RU").format(positionCounts.bonds)} +
+
+
+ ) : null} + {account.openedOn ? ( - + {t("accounts.openedOn", { - date: new Intl.DateTimeFormat("ru-RU", { - dateStyle: "long", - timeZone: "UTC", - }).format(new Date(`${account.openedOn}T00:00:00Z`)), + date: formatDate(account.openedOn), })} ) : null} - + {t("accounts.openAccount")} + -
+
))} -
+ ); }; diff --git a/src/entities/brokerage-event/ui/BrokerageEventCard.tsx b/src/entities/brokerage-event/ui/BrokerageEventCard.tsx index 2d5bcbb..feb11bb 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventCard.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventCard.tsx @@ -1,6 +1,6 @@ import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; +import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib"; import { EntryCard } from "@/shared/ui"; import type { BrokerageEvent } from "../api/useBrokerageEvents"; @@ -10,10 +10,7 @@ const BrokerageEventCard: FC = (props) => { const { event } = props; const { t } = useTranslation(); - const date = new Intl.DateTimeFormat("ru-RU", { - dateStyle: "medium", - timeZone: "UTC", - }).format(new Date(`${event.date}T00:00:00Z`)); + const date = formatDate(event.date); const metadata = [ date, diff --git a/src/entities/stock/api/queries.ts b/src/entities/stock/api/queries.ts new file mode 100644 index 0000000..47574b4 --- /dev/null +++ b/src/entities/stock/api/queries.ts @@ -0,0 +1,110 @@ +import { + apiQueryClient, + useAuthenticatedQueries, + useAuthenticatedQuery, +} from "@/shared/api"; + +type StockPeriod = "month" | "halfYear" | "year" | "all"; + +function useStockSearch(query: string) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/stocks", { + params: { query: { query } }, + }), + enabled: query.length >= 2, + staleTime: 30_000, + }); +} +function stockQueryOptions(uid: string) { + return { + ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}", { + params: { path: { uid } }, + }), + staleTime: 30_000, + }; +} + +function useStocks(uids: string[]) { + const uniqueUids = [...new Set(uids)]; + const queries = useAuthenticatedQueries(uniqueUids.map(stockQueryOptions)); + return new Map( + queries.map((query, index) => [uniqueUids[index], query.data]), + ); +} + +function useStock(identifier: string) { + const isUid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + identifier, + ); + + const byUid = useAuthenticatedQuery({ + ...stockQueryOptions(identifier), + enabled: Boolean(identifier) && isUid, + }); + const byTicker = useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", { + params: { path: { ticker: identifier } }, + }), + enabled: Boolean(identifier) && !isUid, + staleTime: 30_000, + }); + return isUid ? byUid : byTicker; +} +function useStockQuote(uid: string) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/quote", { + params: { path: { uid } }, + }), + enabled: Boolean(uid), + staleTime: 30_000, + }); +} +function useStockCandles(uid: string, period: StockPeriod) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/candles", { + params: { path: { uid }, query: { period } }, + }), + enabled: Boolean(uid), + staleTime: 30_000, + }); +} +function useStockPosition(uid: string) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/position", { + params: { path: { uid } }, + }), + enabled: Boolean(uid), + staleTime: 30_000, + }); +} +function useStockCompany(uid: string) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/company", { + params: { path: { uid } }, + }), + enabled: Boolean(uid), + staleTime: 30_000, + }); +} +function useStockDividends(uid: string) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/dividends", { + params: { path: { uid } }, + }), + enabled: Boolean(uid), + staleTime: 30_000, + }); +} + +export { + type StockPeriod, + useStock, + useStockCandles, + useStockCompany, + useStockDividends, + useStockPosition, + useStockQuote, + useStockSearch, + useStocks, +}; diff --git a/src/entities/stock/index.ts b/src/entities/stock/index.ts new file mode 100644 index 0000000..4c4483d --- /dev/null +++ b/src/entities/stock/index.ts @@ -0,0 +1,11 @@ +export { + type StockPeriod, + useStock, + useStockCandles, + useStockCompany, + useStockDividends, + useStockPosition, + useStockQuote, + useStockSearch, + useStocks, +} from "./api/queries"; diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts index a0401a8..e8cb281 100644 --- a/src/features/tbank-connection/model/connection-boundary.ts +++ b/src/features/tbank-connection/model/connection-boundary.ts @@ -22,7 +22,9 @@ function commitTbankConnectionBoundary( return ( method === "get" && typeof path === "string" && - (path === brokerageAccountsPath || + (path === "/api/v1/stocks" || + path.startsWith("/api/v1/stocks/") || + path === brokerageAccountsPath || path.startsWith(`${brokerageAccountsPath}/`)) ); }, diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx index 0234866..df4bec5 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx @@ -22,6 +22,11 @@ const operationsQueryKey = apiQueryClient.queryOptions( "/api/v1/brokerage-accounts/{accountId}/operations", { params: { path: { accountId: "account-1" } } }, ).queryKey; +const stockQueryKey = apiQueryClient.queryOptions( + "get", + "/api/v1/stocks/{uid}/position", + { params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } }, +).queryKey; const unrelatedQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/auth/me", @@ -53,6 +58,7 @@ function seededClient() { }, ]); client.setQueryData(portfolioQueryKey, { source: "previous-connection" }); + client.setQueryData(stockQueryKey, { source: "previous-connection" }); client.setQueryData(unrelatedQueryKey, { id: "user-1" }); return client; } @@ -75,6 +81,7 @@ describe("TbankConnectionSection", () => { ); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); + expect(client.getQueryData(stockQueryKey)).toBeUndefined(); expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" }); }); @@ -126,6 +133,7 @@ describe("TbankConnectionSection", () => { ); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); + expect(client.getQueryData(stockQueryKey)).toBeUndefined(); expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" }); }); diff --git a/src/main.tsx b/src/main.tsx index ba4b2f0..1c7e32f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "@mantine/core/styles.css"; +import "@mantine/charts/styles.css"; import "@mantine/dates/styles.css"; import "dayjs/locale/ru"; import "./shared/i18n"; diff --git a/src/pages/account-events/ui/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage.test.tsx index e2de2e0..11e25d7 100644 --- a/src/pages/account-events/ui/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage.test.tsx @@ -108,9 +108,9 @@ describe("AccountEventsPage", () => { expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument(); expect(screen.getByText("Купон · ОФЗ 26238")).toBeVisible(); expect(screen.getByText("Оферта · ОФЗ 26238")).toBeVisible(); - expect(screen.getByText("20 авг. 2026 г. · RU000A0JX0J2")).toBeVisible(); + expect(screen.getByText("20.08.2026 · RU000A0JX0J2")).toBeVisible(); expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1); - expect(screen.getByText("25 авг. 2026 г.")).toBeVisible(); + expect(screen.getByText("25.08.2026")).toBeVisible(); expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument(); expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2); const logo = container.querySelector("img"); diff --git a/src/pages/account-income/ui/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage.test.tsx index 317971e..d93466f 100644 --- a/src/pages/account-income/ui/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage.test.tsx @@ -392,10 +392,10 @@ describe("AccountIncomePage", () => { expect(eventsRequest).not.toHaveBeenCalled(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument(); const selected = screen.getByRole("region", { - name: /^1–27 авг\. 2026\sг\.$/, + name: "01.08.2026 – 27.08.2026", }); expect( - screen.getByRole("region", { name: /^1–27 авг\. 2025\sг\.$/ }), + screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }), ).toBeInTheDocument(); expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument(); expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument(); @@ -498,10 +498,10 @@ describe("income comparison display", () => { }, }); expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe( - "1 дек. 2025 г. – 9 янв. 2026 г.", + "01.12.2025 – 09.01.2026", ); expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe( - "1 дек. 2024 г. – 9 янв. 2025 г.", + "01.12.2024 – 09.01.2025", ); expect(display?.comparison?.values.map((value) => value.currency)).toEqual([ "RUB", diff --git a/src/pages/account-income/ui/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage.vm.ts index a485fe7..ba5648c 100644 --- a/src/pages/account-income/ui/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage.vm.ts @@ -5,6 +5,7 @@ import { useParams, useSearchParams } from "react-router"; import { z } from "zod"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { validationMessage } from "@/shared/i18n"; +import { formatDate } from "@/shared/lib"; type IncomePeriod = { from: string; to: string }; type IncomeForm = IncomePeriod; @@ -252,13 +253,7 @@ function toIncomeDisplayModel( } function formatComparisonPeriod(period: IncomePeriod): string { - return new Intl.DateTimeFormat("ru-RU", { - dateStyle: "medium", - timeZone: "UTC", - }).formatRange( - new Date(`${period.from}T00:00:00Z`), - new Date(`${period.to}T00:00:00Z`), - ); + return `${formatDate(period.from)} – ${formatDate(period.to)}`; } function formatIncomeChange( diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx index e831516..2480089 100644 --- a/src/pages/account/ui/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage.test.tsx @@ -121,6 +121,83 @@ function emptyProps() { } describe("AccountPage states", () => { + it.each(["account-1", "all"])( + "uses verified ticker hrefs for both name and logo in %s", + async (accountId) => { + const uid = "c190ff1f-1447-4227-b543-316332699ca5"; + const requests = vi.fn(); + const position = { + ...portfolio.positions[0], + instrumentUid: uid, + ticker: "SBERP", + displayName: "Сбербанк привилегированные", + }; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json({ + ...portfolio, + positions: + accountId === "all" + ? ["account-1", "account-2"].map((id) => ({ + ...position, + account: { id, name: id }, + })) + : [position], + }), + ), + http.get("*/api/v1/stocks/:uid", () => { + requests(); + return HttpResponse.json({ instrumentUid: uid, slug: "SBERP" }); + }), + ); + configureTestAuth(); + renderPage(); + await waitFor(() => { + const links = screen.getAllByRole("link", { + name: position.displayName, + }); + expect(links).toHaveLength(2); + for (const link of links) + expect(link).toHaveAttribute("href", "/stocks/SBERP"); + }); + expect(requests).toHaveBeenCalledOnce(); + }, + ); + + it.each(["unavailable", "noncanonical"])( + "keeps exact UID links and the portfolio visible when ticker is %s", + async (scenario) => { + const uid = "c190ff1f-1447-4227-b543-316332699ca5"; + const requests = vi.fn(); + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json({ + ...portfolio, + positions: [ + { + ...portfolio.positions[0], + instrumentUid: uid, + ticker: "SBERP", + }, + ], + }), + ), + http.get("*/api/v1/stocks/:uid", () => { + requests(); + return scenario === "unavailable" + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json({ instrumentUid: uid, slug: uid }); + }), + ); + renderAccountRoute(createTestQueryClient()); + await waitFor(() => expect(requests).toHaveBeenCalledOnce()); + const links = await screen.findAllByRole("link", { name: "Вторая" }); + expect(links).toHaveLength(2); + for (const link of links) + expect(link).toHaveAttribute("href", `/stocks/${uid}`); + }, + ); + it("keeps the account and positions visible for an older HTTP response without structure", async () => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => @@ -715,11 +792,11 @@ describe("AccountPage states", () => { expect(titles.map((title) => text.indexOf(title))).toEqual( titles.map((title) => text.indexOf(title)).sort((a, b) => a - b), ); - expect(content.getByText("10 сент. 2026 г. · RU000A0JX0J2")).toBeVisible(); + expect(content.getByText("10.09.2026 · RU000A0JX0J2")).toBeVisible(); expect(content.getAllByText(/RU000A0JX0J2$/)).toHaveLength(4); expect(content.queryByText(/Дата события/)).not.toBeInTheDocument(); expect( - content.getByText("11 сент. 2026 г. · Дата реестра · RU000A0JX0J2"), + content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"), ).toBeVisible(); for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"]) expect(content.getByText(amount)).toBeVisible(); diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts index 8b3823a..919de56 100644 --- a/src/pages/account/ui/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage.vm.ts @@ -13,6 +13,7 @@ import { import { type IncomeSummary, useIncomeSummary } from "@/entities/income"; import type { Portfolio } from "@/entities/portfolio"; import { formatShare, usePortfolio } from "@/entities/portfolio"; +import { useStocks } from "@/entities/stock"; import { apiErrorCode } from "@/shared/api"; import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; @@ -24,6 +25,7 @@ type AccountPagePosition = Portfolio["positions"][number] & { returnText: string | null; returnTone?: AccountPageReturnTone; logoSrc: string | null; + stockTo?: string; }; type AccountPagePositionGroup = { @@ -85,6 +87,13 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { const { accountId: routeAccountId } = useParams(); const accountId = scopeId ?? routeAccountId; const portfolio = usePortfolio(accountId); + const stockDetails = useStocks( + portfolio.data?.positions.flatMap((position) => + position.type === "STOCK" && position.instrumentUid + ? [position.instrumentUid] + : [], + ) ?? [], + ); const operations = useBrokerageOperations(accountId, { limit: 5, }); @@ -125,7 +134,10 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { account, accountId, portfolio: portfolio.data, - positionSections: toAccountPagePositionSections(portfolio.data), + positionSections: toAccountPagePositionSections( + portfolio.data, + stockDetails, + ), recentOperations, recentEvents, incomeSummary, @@ -170,6 +182,7 @@ function toRecentEvents( function toAccountPagePositionSections( portfolio: Portfolio, + stockDetails: ReturnType = new Map(), ): AccountPagePositionSection[] { const denominators = new Map(); for (const position of portfolio.positions) { @@ -180,9 +193,9 @@ function toAccountPagePositionSections( (denominators.get(currency) ?? 0) + Number(position.currentValue.value), ); } - const stocks = toSection("stocks", portfolio, denominators); - const bonds = toSection("bonds", portfolio, denominators); - const other = toSection("other", portfolio, denominators); + const stocks = toSection("stocks", portfolio, denominators, stockDetails); + const bonds = toSection("bonds", portfolio, denominators, stockDetails); + const other = toSection("other", portfolio, denominators, stockDetails); return [stocks, bonds, other].filter((section) => section.groups.length > 0); } @@ -190,6 +203,7 @@ function toSection( kind: PositionSectionKind, portfolio: Portfolio, denominators: Map, + stockDetails: ReturnType, ): AccountPagePositionSection { return { kind, @@ -204,7 +218,14 @@ function toSection( position.type !== "CURRENCY" ); }) - .map((position) => toPosition(position, denominators, kind)), + .map((position) => + toPosition( + position, + denominators, + kind, + stockDetails.get(position.instrumentUid ?? "")?.slug, + ), + ), ), }; } @@ -214,11 +235,13 @@ function groupPositions( ): AccountPagePositionGroup[] { const groups = new Map(); for (const [index, position] of positions.entries()) { - const identity = position.figi?.trim() - ? ["figi", position.figi.trim()] - : position.isin?.trim() - ? ["isin", position.isin.trim()] - : null; + const identity = position.instrumentUid?.trim() + ? ["uid", position.instrumentUid.trim()] + : position.figi?.trim() + ? ["figi", position.figi.trim()] + : position.isin?.trim() + ? ["isin", position.isin.trim()] + : null; const rowKey = JSON.stringify(["row", position.account?.id, index]); const key = position.account && identity @@ -242,6 +265,7 @@ function toPosition( position: Portfolio["positions"][number], denominators: Map, kind: PositionSectionKind, + stockSlug: string | undefined, ): AccountPagePosition { const expanded = kind !== "other"; const returnValue = position.absoluteReturn @@ -255,6 +279,10 @@ function toPosition( : "negative"; return { ...position, + stockTo: + kind === "stocks" && position.instrumentUid + ? `/stocks/${encodeURIComponent(stockSlug ?? position.instrumentUid)}` + : undefined, shareText: position.currentValue?.currency ? formatShare(position.currentValue, { currency: position.currentValue.currency, diff --git a/src/pages/account/ui/components/PortfolioStructure.view.tsx b/src/pages/account/ui/components/PortfolioStructure.view.tsx index c38fc02..fc0395f 100644 --- a/src/pages/account/ui/components/PortfolioStructure.view.tsx +++ b/src/pages/account/ui/components/PortfolioStructure.view.tsx @@ -1,4 +1,3 @@ -import "@mantine/charts/styles.css"; import { DonutChart } from "@mantine/charts"; import { Box, Group, Paper, Select, Stack, Text, Title } from "@mantine/core"; import { type FC, useId } from "react"; diff --git a/src/pages/account/ui/components/PositionCard.view.tsx b/src/pages/account/ui/components/PositionCard.view.tsx index 2f150fa..c547fe6 100644 --- a/src/pages/account/ui/components/PositionCard.view.tsx +++ b/src/pages/account/ui/components/PositionCard.view.tsx @@ -30,6 +30,7 @@ const PositionCard: FC = (props) => { value: position.returnText, }) : null; + const stockTo = position.stockTo; const identifier = kind === "stocks" ? (position.ticker ?? position.isin) : position.isin; const identifierText = [ @@ -68,11 +69,18 @@ const PositionCard: FC = (props) => { key={position.logoSrc ?? "no-logo"} src={position.logoSrc} label={position.displayName} + to={stockTo} /> - - {position.displayName} - + {stockTo ? ( + + {position.displayName} + + ) : ( + + {position.displayName} + + )} {accountLink} {identifierText} diff --git a/src/pages/account/ui/components/PositionGroupCard.view.tsx b/src/pages/account/ui/components/PositionGroupCard.view.tsx index 1c478d3..d08bb0f 100644 --- a/src/pages/account/ui/components/PositionGroupCard.view.tsx +++ b/src/pages/account/ui/components/PositionGroupCard.view.tsx @@ -19,6 +19,7 @@ const PositionGroupCard: FC = (props) => { const { group, kind } = props; const { t } = useTranslation(); const identity = group.positions[0]; + const stockTo = identity.stockTo; return ( @@ -27,10 +28,17 @@ const PositionGroupCard: FC = (props) => { key={identity.logoSrc ?? "no-logo"} src={identity.logoSrc} label={identity.displayName} + to={stockTo} /> ) : null} - {identity.displayName} + {stockTo ? ( + + {identity.displayName} + + ) : ( + {identity.displayName} + )} {identity.ticker ?? identity.isin ?? identity.figi} diff --git a/src/pages/account/ui/components/PositionLogo.view.tsx b/src/pages/account/ui/components/PositionLogo.view.tsx index 94cd9c7..5b5b584 100644 --- a/src/pages/account/ui/components/PositionLogo.view.tsx +++ b/src/pages/account/ui/components/PositionLogo.view.tsx @@ -1,18 +1,20 @@ -import { Avatar } from "@mantine/core"; +import { Anchor, Avatar } from "@mantine/core"; import type { FC } from "react"; import { useState } from "react"; +import { Link } from "react-router"; type PositionLogoProps = { src: string | null; label: string; + to?: string; }; const PositionLogo: FC = (props) => { const { src, label } = props; const [failed, setFailed] = useState(false); - if (src && !failed) - return ( + const logo = + src && !failed ? ( = (props) => { radius="md" onError={() => setFailed(true)} /> + ) : ( + + {(label.trim()[0] ?? "?").toUpperCase()} + ); - return ( - - {(label.trim()[0] ?? "?").toUpperCase()} - + return props.to ? ( + + {logo} + + ) : ( + logo ); }; diff --git a/src/pages/accounts/ui/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/components/AccountsPageLoaded.view.tsx index b9c5a95..dd9698e 100644 --- a/src/pages/accounts/ui/components/AccountsPageLoaded.view.tsx +++ b/src/pages/accounts/ui/components/AccountsPageLoaded.view.tsx @@ -1,4 +1,4 @@ -import { Button, Stack, Title } from "@mantine/core"; +import { Button, Stack, Text, Title } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; @@ -19,8 +19,13 @@ const AccountsPageLoaded: FC = (props) => { const { accounts, ...refresh } = props; const { t } = useTranslation(); return ( - - {t("accounts.title")} + + + {t("accounts.title")} + + {t("accounts.subtitle")} + + + + ); + return ; +}; + +export { StockPage }; diff --git a/src/pages/stock/ui/StockPage.view.tsx b/src/pages/stock/ui/StockPage.view.tsx new file mode 100644 index 0000000..1af221e --- /dev/null +++ b/src/pages/stock/ui/StockPage.view.tsx @@ -0,0 +1,224 @@ +import { + Alert, + Avatar, + Badge, + Button, + Group, + Paper, + Skeleton, + Stack, + Text, + Title, +} from "@mantine/core"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { + formatDate, + formatDecimal, + formatMoney, + formatSnapshotTime, +} from "@/shared/lib"; +import { LoadingSkeleton } from "@/shared/ui"; +import { StockChart } from "./components/StockChart.view"; +import { StockDividends, StockEvents } from "./components/StockEvents.view"; +import { StockPosition } from "./components/StockPosition.view"; +import { StockSection } from "./components/StockSection.view"; +import classes from "./StockPage.module.css"; +import type { StockPageVm } from "./StockPage.vm"; + +type StockPageViewProps = { vm: StockPageVm }; +const StockPageView: FC = (props) => { + const { vm } = props; + const { t } = useTranslation(); + const stock = vm.stock; + if (!stock) return null; + return ( + + + +
+ + {stock.name[0]} + +
+ + {stock.name} + + + {stock.ticker} ·{" "} + {t(`stock.shareTypes.${stock.shareType}`, { + defaultValue: t("stock.share"), + })} + +
+
+ +
+ {vm.company?.description ? ( + + {vm.company.description} + + ) : null} + {vm.stockState.error || vm.quoteState.error ? ( + + {t(vm.quote ? "accounts.refreshError" : "stock.sectionError")} + + ) : null} + {vm.quoteState.loading ? ( + + + + ) : ( +
+ + {formatMoney(vm.quote?.price ?? null)} + + {vm.quote?.change ? ( + + {vm.quote.change.value.startsWith("-") ? "" : "+"} + {formatMoney(vm.quote.change)} + {vm.quote.changePercent !== null + ? ` (${formatDecimal(vm.quote.changePercent)} %)` + : ""} + + ) : null} + {vm.quote?.previousCloseDate ? ( + + {t("stock.changeBase", { + date: formatDate(vm.quote.previousCloseDate), + })} + + ) : ( + + {t("stock.noComparison")} + + )} + + + {t(`stock.trading.${vm.quote?.tradingStatus}`, { + defaultValue: t("stock.trading.unknown"), + })} + + {vm.quote?.priceAt ? ( + + {t("stock.priceAt", { + date: formatSnapshotTime(vm.quote.priceAt), + })} + + ) : null} + + {vm.quote?.unavailable.length ? ( + + {t("stock.partialQuote")} + + ) : null} + {vm.quoteState.refreshing ? ( + + {t("accounts.refreshing")} + + ) : null} +
+ )} + + + {stock.sector ? ( + + {t("stock.sector")}:{" "} + {t(`stock.sectors.${stock.sector}`, { + defaultValue: stock.sector, + })} + + ) : null} + {stock.lot !== null ? ( + {t("stock.lot", { value: stock.lot })} + ) : null} + + {vm.company ? ( + + {vm.company.unavailable.length ? ( + + + {t("stock.partialCompany")} + + + + ) : null} + {vm.company.metrics.length ? ( +
+ {vm.company.metrics.map((metric) => ( +
+ + {t(`stock.metrics.${metric.key}`)} + {metric.period ? ` · ${metric.period}` : ""} + + + {new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 2, + notation: + metric.unit === "money" ? "compact" : "standard", + }).format(metric.value)}{" "} + {metric.unit === "money" + ? new Intl.NumberFormat("ru-RU", { + style: "currency", + currency: metric.currency ?? "RUB", + currencyDisplay: "narrowSymbol", + }) + .formatToParts(0) + .find((part) => part.type === "currency")?.value + : metric.unit === "percent" + ? "%" + : "×"} + +
+ ))} +
+ ) : ( + {t("stock.noFundamentals")} + )} + + {t("stock.periodNote")} + +
+ ) : null} +
+
+ + + + +
+ ); +}; + +export { StockPageView }; diff --git a/src/pages/stock/ui/StockPage.vm.ts b/src/pages/stock/ui/StockPage.vm.ts new file mode 100644 index 0000000..d64d47f --- /dev/null +++ b/src/pages/stock/ui/StockPage.vm.ts @@ -0,0 +1,175 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useParams, useSearchParams } from "react-router"; +import { + brokerageOperationsQueryOptions, + type OperationCategory, + useBrokerageOperations, +} from "@/entities/broker-operation"; +import { + type StockPeriod, + useStock, + useStockCandles, + useStockCompany, + useStockDividends, + useStockPosition, + useStockQuote, +} from "@/entities/stock"; +import { apiErrorCode } from "@/shared/api"; +import { formatDate, instrumentLogoUrl } from "@/shared/lib"; + +type SectionState = { + hasData: boolean; + loading: boolean; + error: boolean; + refreshing: boolean; + retry: () => void; +}; +function section(query: { + data: unknown; + isError: boolean; + isFetching: boolean; + refetch: () => unknown; +}): SectionState { + return { + hasData: Boolean(query.data), + loading: !query.data && !query.isError, + error: query.isError, + refreshing: Boolean(query.data) && query.isFetching, + retry: () => { + void query.refetch(); + }, + }; +} +const periods: StockPeriod[] = ["month", "halfYear", "year", "all"]; +const filters = ["all", "trades", "dividends", "other"] as const; +type StockEventFilter = (typeof filters)[number]; +const categories: Record = { + all: [], + trades: ["trade"], + dividends: ["income"], + other: ["tax", "fee", "transfer", "other"], +}; +function useStockPageVm() { + const { stockId = "" } = useParams(); + const stock = useStock(stockId); + const uid = stock.data?.instrumentUid ?? ""; + const [params, setParams] = useSearchParams(); + const period = + periods.find((value) => value === params.get("period")) ?? "month"; + const filter = + filters.find((value) => value === params.get("events")) ?? "all"; + const scope = `${uid}:${filter}`; + const [pagination, setPagination] = useState<{ + scope: string; + cursors: string[]; + }>({ scope, cursors: [] }); + const cursors = pagination.scope === scope ? pagination.cursors : []; + const client = useQueryClient(); + const quote = useStockQuote(uid); + const candles = useStockCandles(uid, period); + const position = useStockPosition(uid); + const company = useStockCompany(uid); + const dividends = useStockDividends(uid); + const firstPage = { + instrumentUid: uid, + categories: categories[filter], + limit: 20, + }; + const operations = useBrokerageOperations(uid ? "all" : undefined, { + ...firstPage, + cursor: cursors.at(-1), + }); + const setParam = (key: string, value: string) => { + const next = new URLSearchParams(params); + next.set(key, value); + setParams(next, { replace: true }); + }; + const retryOperations = () => { + if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") { + setPagination({ scope, cursors: [] }); + void client.invalidateQueries({ + queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, + exact: true, + }); + } else void operations.refetch(); + }; + const chartData = + candles.data?.candles.map((candle) => ({ + ...candle, + date: formatDate(candle.time), + price: Number(candle.close), + })) ?? []; + const average = position.data?.averagePurchasePrice; + const averageLine = + average && average.currency === candles.data?.currency + ? Number(average.value) + : null; + const companyData = company.data; + return { + canonicalTo: + stock.data?.slug && stock.data.slug !== stockId + ? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}` + : null, + stock: stock.data, + stockState: section(stock), + code: apiErrorCode(stock.error), + logoSrc: instrumentLogoUrl(stock.data?.logoName), + quote: quote.data, + quoteState: section(quote), + candles: candles.data, + chartData, + priceRows: chartData.toReversed(), + candlesState: section(candles), + period, + setPeriod: (value: string) => setParam("period", value), + averageLine: + averageLine !== null && Number.isFinite(averageLine) ? averageLine : null, + hasIncompleteCandle: + candles.data?.candles.some((candle) => !candle.isComplete) ?? false, + position: position.data, + positionState: section(position), + company: companyData, + companyState: section(company), + dividends: dividends.data, + dividendsState: section(dividends), + upcoming: + dividends.data?.dividends + .filter((event) => event.timing === "upcoming") + .sort((a, b) => + (a.recordDate ?? a.paymentDate ?? "").localeCompare( + b.recordDate ?? b.paymentDate ?? "", + ), + ) ?? [], + pastDividends: + dividends.data?.dividends + .filter((event) => event.timing !== "upcoming") + .sort((a, b) => + (b.recordDate ?? b.paymentDate ?? "").localeCompare( + a.recordDate ?? a.paymentDate ?? "", + ), + ) ?? [], + operations: operations.data, + operationsState: { ...section(operations), retry: retryOperations }, + eventRows: + operations.data?.items.map((item) => ({ + ...item, + ticker: item.ticker ?? stock.data?.ticker ?? null, + logoName: item.logoName ?? stock.data?.logoName ?? null, + })) ?? [], + filter, + setFilter: (value: string) => setParam("events", value), + hasPrevious: cursors.length > 0, + onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }), + onNext: () => { + if (operations.data?.nextCursor) + setPagination({ + scope, + cursors: [...cursors, operations.data.nextCursor], + }); + }, + }; +} +type StockPageVm = ReturnType; + +export { type SectionState, type StockPageVm, useStockPageVm }; diff --git a/src/pages/stock/ui/components/StockChart.view.tsx b/src/pages/stock/ui/components/StockChart.view.tsx new file mode 100644 index 0000000..d5ed298 --- /dev/null +++ b/src/pages/stock/ui/components/StockChart.view.tsx @@ -0,0 +1,173 @@ +import { AreaChart } from "@mantine/charts"; +import { + Accordion, + Badge, + ScrollArea, + SegmentedControl, + Table, + Text, +} from "@mantine/core"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { formatMoney } from "@/shared/lib"; +import classes from "../StockPage.module.css"; +import type { StockPageVm } from "../StockPage.vm"; +import { StockSection } from "./StockSection.view"; + +type StockChartProps = { vm: StockPageVm }; +const StockChart: FC = (props) => { + const { vm } = props; + const { t } = useTranslation(); + return ( + + ({ + value, + label: t(`stock.periods.${value}`), + }))} + /> + + } + > + {vm.candles ? ( + vm.chartData.length ? ( + <> + + new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 4, + }).format(value) + } + aria-label={t("stock.chartLabel", { + currency: vm.candles.currency ?? "", + })} + /> + + {t("stock.chartNote")} + + {vm.hasIncompleteCandle ? ( + + {t("stock.incomplete")} + + ) : null} + {vm.averageLine !== null ? ( + + {t("stock.averageLine")}:{" "} + {formatMoney(vm.position?.averagePurchasePrice ?? null)} + + ) : null} + + + + {t("stock.priceTable")} + + {t("stock.priceTableHint", { count: vm.chartData.length })} + + + + + + + + {t("stock.date")} + {t("stock.price")} + {t("stock.candle")} + + + + {vm.priceRows.map((candle) => ( + + {candle.date} + + {formatMoney( + vm.candles?.currency + ? { + value: candle.close, + currency: vm.candles.currency, + } + : null, + )} + + + + {t( + candle.isComplete + ? "stock.complete" + : "stock.forming", + )} + + + + ))} + +
+
+
+
+
+ + ) : ( + {t("stock.noCandles")} + ) + ) : null} +
+ ); +}; + +export { StockChart }; diff --git a/src/pages/stock/ui/components/StockEvents.view.tsx b/src/pages/stock/ui/components/StockEvents.view.tsx new file mode 100644 index 0000000..984618d --- /dev/null +++ b/src/pages/stock/ui/components/StockEvents.view.tsx @@ -0,0 +1,233 @@ +import { + Accordion, + Alert, + Badge, + Button, + Group, + Paper, + SegmentedControl, + Stack, + Text, +} from "@mantine/core"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { LedgerEntryRow } from "@/entities/broker-operation"; +import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; +import classes from "../StockPage.module.css"; +import type { StockPageVm } from "../StockPage.vm"; +import { StockSection } from "./StockSection.view"; + +type StockEventsProps = { vm: StockPageVm }; +const StockDividends: FC = (props) => { + const { vm } = props; + const { t } = useTranslation(); + return ( + + {vm.dividends ? ( + + {vm.dividends.estimateUnavailable || vm.dividends.positionIsStale ? ( + {t("stock.estimateUnavailable")} + ) : null} + {vm.upcoming.length ? ( + vm.upcoming.map((event) => ( +
+ +
+ + {t("stock.companyEvent")} + + + {formatMoney(event.perShare)}{" "} + + {t("stock.perShare")} + + +
+ {event.estimatedAmount ? ( +
+ + {t("stock.yourEstimate")} + + {formatMoney(event.estimatedAmount)} +
+ ) : null} +
+ + {event.lastBuyDate ? ( +
+ + {t("stock.lastBuy")} + + {formatDate(event.lastBuyDate)} +
+ ) : null} + {event.recordDate ? ( +
+ + {t("stock.record")} + + {formatDate(event.recordDate)} +
+ ) : null} + {event.paymentDate ? ( +
+ + {t("stock.payment")} + + {formatDate(event.paymentDate)} +
+ ) : null} +
+
+ )) + ) : ( + + {t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })} + + )} + + {t("stock.dividendNote")} + + {vm.dividends.positionAt ? ( + + {t("stock.snapshotAt", { + date: formatSnapshotTime(vm.dividends.positionAt), + })} + + ) : null} + + + + {t("stock.dividendHistory")} + + + + {t("stock.dividendWindow", { + from: formatDate(vm.dividends.from), + to: formatDate(vm.dividends.to), + })} + + {vm.pastDividends.length ? ( + + {vm.pastDividends.map((event) => ( + + +
+ + {t("stock.record")} + + + {event.recordDate + ? formatDate(event.recordDate) + : t("stock.dateUnknown")} + + {event.paymentDate ? ( + + {t("stock.payment")}:{" "} + {formatDate(event.paymentDate)} + + ) : null} +
+
+ + {formatMoney(event.perShare)} + + + {t("stock.perShare")} + +
+
+
+ ))} +
+ ) : ( + {t("stock.noDividends")} + )} +
+
+
+
+ ) : null} +
+ ); +}; +const StockEvents: FC = (props) => { + const { vm } = props; + const { t } = useTranslation(); + return ( + + ({ + value, + label: t(`stock.filters.${value}`), + }))} + /> + + } + > + {vm.operations ? ( + <> + + {t("stock.historyNote")} + + {vm.eventRows.length ? ( + + {vm.eventRows.map((event) => ( + + ))} + + ) : ( + + {t("stock.noOperations")} + + )} + + + + + + ) : null} + + ); +}; + +export { StockDividends, StockEvents }; diff --git a/src/pages/stock/ui/components/StockPosition.view.tsx b/src/pages/stock/ui/components/StockPosition.view.tsx new file mode 100644 index 0000000..ede7bae --- /dev/null +++ b/src/pages/stock/ui/components/StockPosition.view.tsx @@ -0,0 +1,135 @@ +import { Alert, Anchor, Stack, Text } from "@mantine/core"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib"; +import classes from "../StockPage.module.css"; +import type { StockPageVm } from "../StockPage.vm"; +import { StockSection } from "./StockSection.view"; + +type StockPositionProps = { vm: StockPageVm }; +const StockPosition: FC = (props) => { + const { vm } = props; + const { t } = useTranslation(); + if (vm.position && !vm.position.accounts.length) return null; + return ( + + {vm.position ? ( + + {vm.position.isStale ? ( + {t("portfolio.staleWarning")} + ) : null} +
+
+ + {t("stock.quantity")} + + + {formatDecimal(vm.position.quantity)} + +
+
+ + {t("stock.value")} + + + {formatMoney(vm.position.currentValue)} + +
+
+ + {t("stock.average")} + + + {formatMoney(vm.position.averagePurchasePrice)} + +
+
+ + {t("stock.openReturn")} + + + {formatMoney(vm.position.absoluteReturn)} + + {vm.position.returnPercent !== null ? ( + + {formatDecimal(vm.position.returnPercent)} % + + ) : null} +
+
+ + {t("stock.openReturnNote")} + +
+ {vm.position.accounts.map((account) => ( +
+ {account.account ? ( + + {account.account.name} + + ) : ( + {t("stock.portfolio")} + )} +
+ + {t("stock.quantity")} + + {formatDecimal(account.quantity)} +
+
+ + {t("stock.average")} + + {formatMoney(account.averagePurchasePrice)} +
+
+ + {t("stock.value")} + + {formatMoney(account.currentValue)} +
+
+ + {t("stock.openReturn")} + + + {formatMoney(account.absoluteReturn)} + + {account.returnPercent !== null ? ( + + {formatDecimal(account.returnPercent)} % + + ) : null} +
+
+ ))} +
+ + {t("stock.snapshotAt", { + date: formatSnapshotTime(vm.position.observedAt), + })} + +
+ ) : null} +
+ ); +}; + +export { StockPosition }; diff --git a/src/pages/stock/ui/components/StockSection.view.tsx b/src/pages/stock/ui/components/StockSection.view.tsx new file mode 100644 index 0000000..cb60ee1 --- /dev/null +++ b/src/pages/stock/ui/components/StockSection.view.tsx @@ -0,0 +1,73 @@ +import { + Alert, + Button, + Group, + Paper, + Skeleton, + Stack, + Text, + Title, +} from "@mantine/core"; +import type { FC, PropsWithChildren, ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { LoadingSkeleton } from "@/shared/ui"; +import classes from "../StockPage.module.css"; +import type { SectionState } from "../StockPage.vm"; + +type StockSectionProps = PropsWithChildren<{ + title: string; + state: SectionState; + controls?: ReactNode; + large?: boolean; + embedded?: boolean; +}>; +const StockSection: FC = (props) => { + const { t } = useTranslation(); + return ( + + + + {props.title} + + {props.controls} + + {props.state.refreshing ? ( + + {t("accounts.refreshing")} + + ) : null} + {props.state.error ? ( + + + + {props.state.hasData + ? t("accounts.refreshError") + : t("stock.sectionError")} + + + + + ) : null} + {props.state.loading ? ( + + + + + + + ) : ( + props.children + )} + + ); +}; + +export { StockSection }; diff --git a/src/pages/stocks/index.ts b/src/pages/stocks/index.ts new file mode 100644 index 0000000..1cbf53c --- /dev/null +++ b/src/pages/stocks/index.ts @@ -0,0 +1 @@ +export { StocksPage } from "./ui/StocksPage"; diff --git a/src/pages/stocks/ui/StocksPage.module.css b/src/pages/stocks/ui/StocksPage.module.css new file mode 100644 index 0000000..ab07ae0 --- /dev/null +++ b/src/pages/stocks/ui/StocksPage.module.css @@ -0,0 +1,36 @@ +.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.test.tsx b/src/pages/stocks/ui/StocksPage.test.tsx new file mode 100644 index 0000000..b5762ee --- /dev/null +++ b/src/pages/stocks/ui/StocksPage.test.tsx @@ -0,0 +1,63 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it, vi } from "vitest"; +import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; +import { server } from "@/test/server"; +import { StocksPage } from "./StocksPage"; + +describe("stock search", () => { + it("waits for a meaningful query and uses the server-verified ticker link", async () => { + const query = vi.fn(); + server.use( + http.get("*/api/v1/stocks", ({ request }) => { + query(new URL(request.url).searchParams.get("query")); + return HttpResponse.json([ + { + name: "Сбербанк", + ticker: "SBER", + instrumentUid: "unique-stock", + slug: "SBER", + logoName: "sber.png", + classCode: "TQBR", + isin: "RU0009029540", + realExchange: "REAL_EXCHANGE_MOEX", + shareType: "SHARE_TYPE_COMMON", + apiTradeAvailable: true, + price: { currency: "RUB", value: "306.12" }, + priceAt: "2026-09-10T10:00:00Z", + }, + ]); + }), + ); + configureAuthClient({ + accessToken: () => "synthetic", + refresh: async () => false, + clear: vi.fn(), + }); + render( + + + + + + + , + ); + expect(query).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("Поиск акций"), { + target: { value: "SBER" }, + }); + expect( + await screen.findByRole("link", { name: /Сбербанк/ }), + ).toHaveAttribute("href", "/stocks/SBER"); + expect(screen.getByText("306,12 ₽")).toBeVisible(); + expect(screen.getByText("Режим: TQBR")).toBeVisible(); + expect(screen.getByText("ISIN: RU0009029540")).toBeVisible(); + expect(screen.getByText("Мосбиржа")).toBeVisible(); + await waitFor(() => expect(query).toHaveBeenCalledWith("SBER")); + }); +}); diff --git a/src/pages/stocks/ui/StocksPage.tsx b/src/pages/stocks/ui/StocksPage.tsx new file mode 100644 index 0000000..26e62de --- /dev/null +++ b/src/pages/stocks/ui/StocksPage.tsx @@ -0,0 +1,7 @@ +import type { FC } from "react"; +import { StocksPageView } from "./StocksPage.view"; +import { useStocksPageVm } from "./StocksPage.vm"; + +const StocksPage: FC = () => ; + +export { StocksPage }; diff --git a/src/pages/stocks/ui/StocksPage.view.tsx b/src/pages/stocks/ui/StocksPage.view.tsx new file mode 100644 index 0000000..a94d6b3 --- /dev/null +++ b/src/pages/stocks/ui/StocksPage.view.tsx @@ -0,0 +1,152 @@ +import { + Alert, + Anchor, + Avatar, + Badge, + Button, + Group, + Paper, + Skeleton, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { formatMoney, formatSnapshotTime } from "@/shared/lib"; +import { LoadingSkeleton } from "@/shared/ui"; +import classes from "./StocksPage.module.css"; +import type { StocksPageVm } from "./StocksPage.vm"; + +type StocksPageViewProps = { vm: StocksPageVm }; +const StocksPageView: FC = (props) => { + const { vm } = props; + const { t } = useTranslation(); + return ( + +
+ {t("stock.searchTitle")} + + {t("stock.searchSubtitle")} + +
+ vm.setQuery(event.currentTarget.value)} + /> + {vm.query.trim().length < 2 ? ( + {t("stock.searchHint")} + ) : null} + {vm.error ? ( + + + + {t( + vm.code === "TBANK_NOT_CONNECTED" || + vm.code === "INVALID_TBANK_TOKEN" + ? "stock.connect" + : "stock.sectionError", + )} + + + {t("accounts.openSettings")} + + + + + ) : null} + {vm.loading ? ( + + + + + + + + ) : vm.ready ? ( + vm.items.length ? ( + + + {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 })} + + ) : null} + + {stock.isin ? ( + + {t("stock.isin", { value: stock.isin })} + + ) : null} + {stock.apiTradeAvailable === false ? ( + + {t("stock.apiUnavailable")} + + ) : null} +
+
+ + {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")} + + ) : null} +
+
+ ))} +
+ ) : ( + {t("stock.noSearchResults")} + ) + ) : null} +
+ ); +}; + +export { StocksPageView }; diff --git a/src/pages/stocks/ui/StocksPage.vm.ts b/src/pages/stocks/ui/StocksPage.vm.ts new file mode 100644 index 0000000..b1c3eeb --- /dev/null +++ b/src/pages/stocks/ui/StocksPage.vm.ts @@ -0,0 +1,33 @@ +import { useDebouncedValue } from "@mantine/hooks"; +import { useSearchParams } from "react-router"; +import { useStockSearch } from "@/entities/stock"; +import { apiErrorCode } from "@/shared/api"; +import { instrumentLogoUrl } from "@/shared/lib"; + +function useStocksPageVm() { + const [params, setParams] = useSearchParams(); + const query = params.get("q") ?? ""; + const [debounced] = useDebouncedValue(query.trim(), 300); + const result = useStockSearch(debounced); + const waiting = query.trim() !== debounced; + return { + query, + setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }), + loading: query.trim().length >= 2 && (waiting || result.isLoading), + error: result.isError, + code: apiErrorCode(result.error), + retry: () => { + void result.refetch(); + }, + ready: debounced.length >= 2 && !waiting && Boolean(result.data), + items: + result.data?.map((stock) => ({ + ...stock, + to: `/stocks/${encodeURIComponent(stock.slug ?? stock.instrumentUid)}`, + logoSrc: instrumentLogoUrl(stock.logoName), + })) ?? [], + }; +} +type StocksPageVm = ReturnType; + +export { type StocksPageVm, useStocksPageVm }; diff --git a/src/shared/api/authenticated-query.test.tsx b/src/shared/api/authenticated-query.test.tsx index cfe8f65..0fa58af 100644 --- a/src/shared/api/authenticated-query.test.tsx +++ b/src/shared/api/authenticated-query.test.tsx @@ -3,53 +3,64 @@ import { HttpResponse, http } from "msw"; import { describe, expect, it, vi } from "vitest"; import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; -import { useAuthenticatedQuery } from "./authenticated-query"; +import { + useAuthenticatedQueries, + useAuthenticatedQuery, +} from "./authenticated-query"; import { apiQueryClient, configureAuthClient } from "./client"; describe("useAuthenticatedQuery", () => { - it("keeps protected queries idle without a session and loads after session configuration", async () => { - const requests = vi.fn(); - server.use( - http.get("*/api/v1/brokerage-accounts", () => { - requests(); - return HttpResponse.json([ - { - id: "account-1", - name: "Основной", - type: "BROKERAGE", - openedOn: "2024-08-13", - }, - ]); - }), - ); + it.each(["single", "multiple"])( + "keeps %s protected queries idle without a session and loads after session configuration", + async (mode) => { + const requests = vi.fn(); + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requests(); + return HttpResponse.json([ + { + id: "account-1", + name: "Основной", + type: "BROKERAGE", + openedOn: "2024-08-13", + }, + ]); + }), + ); - const { result } = renderHook( - () => + const useSingleQuery = () => useAuthenticatedQuery( apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"), - ), - { wrapper: createQueryWrapper().wrapper }, - ); + ); + const useMultipleQueries = () => + useAuthenticatedQueries([ + apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"), + ])[0]; + const { result } = renderHook( + mode === "single" ? useSingleQuery : useMultipleQueries, + { wrapper: createQueryWrapper().wrapper }, + ); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(requests).not.toHaveBeenCalled(); - expect(result.current.fetchStatus).toBe("idle"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(requests).not.toHaveBeenCalled(); + expect(result.current.fetchStatus).toBe("idle"); - configureAuthClient({ - accessToken: () => "token", - refresh: async () => true, - clear: vi.fn(), - }); + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(requests).toHaveBeenCalledOnce(); - expect(result.current.data).toEqual([ - { - id: "account-1", - name: "Основной", - type: "BROKERAGE", - openedOn: "2024-08-13", - }, - ]); - }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(requests).toHaveBeenCalledOnce(); + expect(result.current.data).toEqual([ + { + id: "account-1", + name: "Основной", + type: "BROKERAGE", + openedOn: "2024-08-13", + }, + ]); + }, + ); }); diff --git a/src/shared/api/authenticated-query.ts b/src/shared/api/authenticated-query.ts index 612d7a6..d6b4486 100644 --- a/src/shared/api/authenticated-query.ts +++ b/src/shared/api/authenticated-query.ts @@ -2,6 +2,7 @@ import { type QueryKey, type UseQueryOptions, type UseQueryResult, + useQueries, useQuery, } from "@tanstack/react-query"; import { useSyncExternalStore } from "react"; @@ -18,11 +19,7 @@ function useAuthenticatedQuery< >( options: UseQueryOptions, ): UseQueryResult { - const hasSession = useSyncExternalStore( - subscribeAuthenticatedSession, - getAuthenticatedSessionSnapshot, - getAuthenticatedSessionSnapshot, - ); + const hasSession = useHasAuthenticatedSession(); const enabled = options.enabled; return useQuery({ @@ -33,4 +30,30 @@ function useAuthenticatedQuery< }); } -export { useAuthenticatedQuery }; +function useHasAuthenticatedSession() { + return useSyncExternalStore( + subscribeAuthenticatedSession, + getAuthenticatedSessionSnapshot, + getAuthenticatedSessionSnapshot, + ); +} + +function useAuthenticatedQueries( + queries: UseQueryOptions[], +) { + const hasSession = useHasAuthenticatedSession(); + return useQueries({ + queries: queries.map( + (options): UseQueryOptions => ({ + ...options, + enabled: (query) => + hasSession && + (typeof options.enabled === "function" + ? options.enabled(query) + : (options.enabled ?? true)), + }), + ), + }); +} + +export { useAuthenticatedQueries, useAuthenticatedQuery }; diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index a90695a..8eee697 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -84,6 +84,134 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/stocks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StockController_search"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stocks/by-ticker/{ticker}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StockController_getByTicker"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stocks/{uid}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StockController_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stocks/{uid}/quote": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StockController_quote"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stocks/{uid}/candles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StockController_candles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stocks/{uid}/position": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StockController_position"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stocks/{uid}/company": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StockController_company"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/stocks/{uid}/dividends": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StockController_dividends"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/tbank-connection": { parameters: { query?: never; @@ -309,6 +437,151 @@ export interface components { accessToken: string; user: components["schemas"]["UserDto"]; }; + MoneyDto: { + currency: string; + value: string; + }; + StockSearchResultDto: { + /** Format: uuid */ + instrumentUid: string; + /** @description Unambiguous ticker URL segment, or the exact instrument UID. */ + slug: string; + name: string; + isin: string | null; + classCode: string | null; + realExchange: string | null; + apiTradeAvailable: boolean | null; + ticker: string | null; + logoName: string | null; + currency: string | null; + shareType: string | null; + sector: string | null; + lot: number | null; + /** Format: date-time */ + historyFrom: string | null; + price: components["schemas"]["MoneyDto"] | null; + /** Format: date-time */ + priceAt: string | null; + }; + StockDto: { + /** Format: uuid */ + instrumentUid: string; + /** @description Unambiguous ticker URL segment, or the exact instrument UID. */ + slug: string; + name: string; + isin: string | null; + classCode: string | null; + realExchange: string | null; + apiTradeAvailable: boolean | null; + ticker: string | null; + logoName: string | null; + currency: string | null; + shareType: string | null; + sector: string | null; + lot: number | null; + /** Format: date-time */ + historyFrom: string | null; + }; + StockQuoteDto: { + price: components["schemas"]["MoneyDto"] | null; + /** Format: date-time */ + priceAt: string | null; + tradingStatus: string | null; + change: components["schemas"]["MoneyDto"] | null; + changePercent: string | null; + /** Format: date */ + previousCloseDate: string | null; + previousClose: components["schemas"]["MoneyDto"] | null; + unavailable: string[]; + /** Format: date-time */ + observedAt: string; + }; + StockCandleDto: { + /** Format: date-time */ + time: string; + close: string; + isComplete: boolean; + }; + StockCandlesDto: { + candles: components["schemas"]["StockCandleDto"][]; + currency: string | null; + /** Format: date-time */ + historyFrom: string | null; + /** Format: date-time */ + observedAt: string; + }; + AccountReferenceDto: { + id: string; + name: string; + }; + StockPositionRowDto: { + instrumentUid?: string | null; + account?: components["schemas"]["AccountReferenceDto"]; + figi: string | null; + displayName: string; + /** @enum {string} */ + type: "STOCK" | "BOND" | "FUND" | "CURRENCY" | "FUTURE" | "OPTION" | "STRUCTURED_NOTE" | "DIGITAL_FINANCIAL_ASSET" | "OTHER"; + quantity: string; + currentValue: components["schemas"]["MoneyDto"] | null; + averagePurchasePrice: components["schemas"]["MoneyDto"] | null; + absoluteReturn: components["schemas"]["MoneyDto"] | null; + isin: string | null; + ticker: string | null; + logoName: string | null; + returnPercent: string | null; + }; + StockPositionDto: { + quantity: string; + currentValue: components["schemas"]["MoneyDto"] | null; + averagePurchasePrice: components["schemas"]["MoneyDto"] | null; + absoluteReturn: components["schemas"]["MoneyDto"] | null; + returnPercent: string | null; + accounts: components["schemas"]["StockPositionRowDto"][]; + isStale: boolean; + /** Format: date-time */ + observedAt: string; + }; + StockMetricDto: { + key: string; + value: number; + /** @enum {string} */ + unit: "money" | "ratio" | "percent"; + currency: string | null; + /** @enum {string|null} */ + period: "TTM" | "MRQ" | null; + }; + StockCompanyDto: { + description: string | null; + metrics: components["schemas"]["StockMetricDto"][]; + unavailable: string[]; + /** Format: date-time */ + observedAt: string; + }; + StockDividendDto: { + perShare: components["schemas"]["MoneyDto"] | null; + /** Format: date */ + recordDate: string | null; + /** Format: date */ + lastBuyDate: string | null; + /** Format: date */ + paymentDate: string | null; + /** @enum {string} */ + timing: "upcoming" | "past" | "undated"; + estimatedAmount: components["schemas"]["MoneyDto"] | null; + }; + StockDividendsDto: { + dividends: components["schemas"]["StockDividendDto"][]; + /** Format: date */ + from: string; + /** Format: date */ + to: string; + /** Format: date-time */ + observedAt: string; + /** Format: date-time */ + positionAt: string | null; + estimateUnavailable: boolean; + positionIsStale: boolean; + }; TbankConnectionStatusDto: { connected: boolean; /** Format: date-time */ @@ -317,12 +590,12 @@ export interface components { PutTbankConnectionDto: { token: string; }; - MoneyDto: { - currency: string; - value: string; - }; PortfolioSummaryDto: { totalsByCurrency: components["schemas"]["MoneyDto"][]; + /** @description Number of non-zero stock positions, not the number of units. */ + stockPositionCount: number; + /** @description Number of non-zero bond positions, not the number of units. */ + bondPositionCount: number; }; BrokerageAccountDto: { id: string; @@ -333,10 +606,6 @@ export interface components { openedOn: string | null; portfolioSummary: components["schemas"]["PortfolioSummaryDto"] | null; }; - AccountReferenceDto: { - id: string; - name: string; - }; BrokerageOperationDto: { account?: components["schemas"]["AccountReferenceDto"]; id: string; @@ -366,6 +635,7 @@ export interface components { observedAt: string; }; PortfolioPositionDto: { + instrumentUid?: string | null; account?: components["schemas"]["AccountReferenceDto"]; figi: string | null; displayName: string; @@ -851,6 +1121,536 @@ export interface operations { }; }; }; + StockController_search: { + parameters: { + query: { + query: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StockSearchResultDto"][]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + StockController_getByTicker: { + parameters: { + query?: never; + header?: never; + path: { + ticker: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StockDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + StockController_get: { + parameters: { + query?: never; + header?: never; + path: { + uid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StockDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + StockController_quote: { + parameters: { + query?: never; + header?: never; + path: { + uid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StockQuoteDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + StockController_candles: { + parameters: { + query?: { + period?: "month" | "halfYear" | "year" | "all"; + }; + header?: never; + path: { + uid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StockCandlesDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + StockController_position: { + parameters: { + query?: never; + header?: never; + path: { + uid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StockPositionDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + StockController_company: { + parameters: { + query?: never; + header?: never; + path: { + uid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StockCompanyDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + StockController_dividends: { + parameters: { + query?: never; + header?: never; + path: { + uid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StockDividendsDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; TbankConnectionController_status: { parameters: { query?: never; @@ -1112,6 +1912,8 @@ export interface operations { BrokerageOperationsController_all: { parameters: { query?: { + /** @description Instrument UID. Without from, includes history since the earliest available account opening. */ + instrumentUid?: string; from?: string; to?: string; categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[]; @@ -1182,6 +1984,8 @@ export interface operations { BrokerageOperationsController_get: { parameters: { query?: { + /** @description Instrument UID. Without from, includes history since the earliest available account opening. */ + instrumentUid?: string; from?: string; to?: string; categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[]; diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 9f8de66..d8dc2e8 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -1,4 +1,7 @@ -export { useAuthenticatedQuery } from "./authenticated-query"; +export { + useAuthenticatedQueries, + useAuthenticatedQuery, +} from "./authenticated-query"; export { apiClient, apiQueryClient, configureAuthClient } from "./client"; export type { paths } from "./generated/schema"; export { apiErrorCode } from "./problem-details"; diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index bc44bef..e559d6f 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -374,6 +374,709 @@ ] } }, + "/api/v1/stocks": { + "get": { + "operationId": "StockController_search", + "parameters": [ + { + "name": "query", + "required": true, + "in": "query", + "schema": { + "minLength": 2, + "maxLength": 80, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockSearchResultDto" + } + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "stocks" + ] + } + }, + "/api/v1/stocks/by-ticker/{ticker}": { + "get": { + "operationId": "StockController_getByTicker", + "parameters": [ + { + "name": "ticker", + "required": true, + "in": "path", + "schema": { + "minLength": 1, + "maxLength": 80, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]*$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "stocks" + ] + } + }, + "/api/v1/stocks/{uid}": { + "get": { + "operationId": "StockController_get", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "stocks" + ] + } + }, + "/api/v1/stocks/{uid}/quote": { + "get": { + "operationId": "StockController_quote", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockQuoteDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "stocks" + ] + } + }, + "/api/v1/stocks/{uid}/candles": { + "get": { + "operationId": "StockController_candles", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "period", + "required": false, + "in": "query", + "schema": { + "default": "month", + "type": "string", + "enum": [ + "month", + "halfYear", + "year", + "all" + ] + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockCandlesDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "stocks" + ] + } + }, + "/api/v1/stocks/{uid}/position": { + "get": { + "operationId": "StockController_position", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockPositionDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "stocks" + ] + } + }, + "/api/v1/stocks/{uid}/company": { + "get": { + "operationId": "StockController_company", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockCompanyDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "stocks" + ] + } + }, + "/api/v1/stocks/{uid}/dividends": { + "get": { + "operationId": "StockController_dividends", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockDividendsDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "stocks" + ] + } + }, "/api/v1/tbank-connection": { "get": { "operationId": "TbankConnectionController_status", @@ -684,6 +1387,16 @@ "get": { "operationId": "BrokerageOperationsController_all", "parameters": [ + { + "name": "instrumentUid", + "required": false, + "in": "query", + "description": "Instrument UID. Without from, includes history since the earliest available account opening.", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "from", "required": false, @@ -825,6 +1538,16 @@ "type": "string" } }, + { + "name": "instrumentUid", + "required": false, + "in": "query", + "description": "Instrument UID. Without from, includes history since the earliest available account opening.", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "from", "required": false, @@ -1724,6 +2447,647 @@ "user" ] }, + "MoneyDto": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "currency", + "value" + ] + }, + "StockSearchResultDto": { + "type": "object", + "properties": { + "instrumentUid": { + "type": "string", + "format": "uuid" + }, + "slug": { + "type": "string", + "description": "Unambiguous ticker URL segment, or the exact instrument UID." + }, + "name": { + "type": "string" + }, + "isin": { + "type": "string", + "nullable": true + }, + "classCode": { + "type": "string", + "nullable": true + }, + "realExchange": { + "type": "string", + "nullable": true + }, + "apiTradeAvailable": { + "type": "boolean", + "nullable": true + }, + "ticker": { + "type": "string", + "nullable": true + }, + "logoName": { + "type": "string", + "nullable": true + }, + "currency": { + "type": "string", + "nullable": true + }, + "shareType": { + "type": "string", + "nullable": true + }, + "sector": { + "type": "string", + "nullable": true + }, + "lot": { + "type": "number", + "nullable": true + }, + "historyFrom": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "price": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "priceAt": { + "type": "string", + "nullable": true, + "format": "date-time" + } + }, + "required": [ + "instrumentUid", + "slug", + "name", + "isin", + "classCode", + "realExchange", + "apiTradeAvailable", + "ticker", + "logoName", + "currency", + "shareType", + "sector", + "lot", + "historyFrom", + "price", + "priceAt" + ] + }, + "StockDto": { + "type": "object", + "properties": { + "instrumentUid": { + "type": "string", + "format": "uuid" + }, + "slug": { + "type": "string", + "description": "Unambiguous ticker URL segment, or the exact instrument UID." + }, + "name": { + "type": "string" + }, + "isin": { + "type": "string", + "nullable": true + }, + "classCode": { + "type": "string", + "nullable": true + }, + "realExchange": { + "type": "string", + "nullable": true + }, + "apiTradeAvailable": { + "type": "boolean", + "nullable": true + }, + "ticker": { + "type": "string", + "nullable": true + }, + "logoName": { + "type": "string", + "nullable": true + }, + "currency": { + "type": "string", + "nullable": true + }, + "shareType": { + "type": "string", + "nullable": true + }, + "sector": { + "type": "string", + "nullable": true + }, + "lot": { + "type": "number", + "nullable": true + }, + "historyFrom": { + "type": "string", + "nullable": true, + "format": "date-time" + } + }, + "required": [ + "instrumentUid", + "slug", + "name", + "isin", + "classCode", + "realExchange", + "apiTradeAvailable", + "ticker", + "logoName", + "currency", + "shareType", + "sector", + "lot", + "historyFrom" + ] + }, + "StockQuoteDto": { + "type": "object", + "properties": { + "price": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "priceAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "tradingStatus": { + "type": "string", + "nullable": true + }, + "change": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "changePercent": { + "type": "string", + "nullable": true + }, + "previousCloseDate": { + "type": "string", + "nullable": true, + "format": "date" + }, + "previousClose": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "unavailable": { + "type": "array", + "items": { + "type": "string" + } + }, + "observedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "price", + "priceAt", + "tradingStatus", + "change", + "changePercent", + "previousCloseDate", + "previousClose", + "unavailable", + "observedAt" + ] + }, + "StockCandleDto": { + "type": "object", + "properties": { + "time": { + "type": "string", + "format": "date-time" + }, + "close": { + "type": "string" + }, + "isComplete": { + "type": "boolean" + } + }, + "required": [ + "time", + "close", + "isComplete" + ] + }, + "StockCandlesDto": { + "type": "object", + "properties": { + "candles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockCandleDto" + } + }, + "currency": { + "type": "string", + "nullable": true + }, + "historyFrom": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "observedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "candles", + "currency", + "historyFrom", + "observedAt" + ] + }, + "AccountReferenceDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "StockPositionRowDto": { + "type": "object", + "properties": { + "instrumentUid": { + "type": "string", + "nullable": true + }, + "account": { + "$ref": "#/components/schemas/AccountReferenceDto" + }, + "figi": { + "type": "string", + "nullable": true + }, + "displayName": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "STOCK", + "BOND", + "FUND", + "CURRENCY", + "FUTURE", + "OPTION", + "STRUCTURED_NOTE", + "DIGITAL_FINANCIAL_ASSET", + "OTHER" + ] + }, + "quantity": { + "type": "string" + }, + "currentValue": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "averagePurchasePrice": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "absoluteReturn": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "isin": { + "type": "string", + "nullable": true + }, + "ticker": { + "type": "string", + "nullable": true + }, + "logoName": { + "type": "string", + "nullable": true + }, + "returnPercent": { + "type": "string", + "nullable": true + } + }, + "required": [ + "figi", + "displayName", + "type", + "quantity", + "currentValue", + "averagePurchasePrice", + "absoluteReturn", + "isin", + "ticker", + "logoName", + "returnPercent" + ] + }, + "StockPositionDto": { + "type": "object", + "properties": { + "quantity": { + "type": "string" + }, + "currentValue": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "averagePurchasePrice": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "absoluteReturn": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "returnPercent": { + "type": "string", + "nullable": true + }, + "accounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockPositionRowDto" + } + }, + "isStale": { + "type": "boolean" + }, + "observedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "quantity", + "currentValue", + "averagePurchasePrice", + "absoluteReturn", + "returnPercent", + "accounts", + "isStale", + "observedAt" + ] + }, + "StockMetricDto": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "number" + }, + "unit": { + "type": "string", + "enum": [ + "money", + "ratio", + "percent" + ] + }, + "currency": { + "type": "string", + "nullable": true + }, + "period": { + "type": "string", + "nullable": true, + "enum": [ + "TTM", + "MRQ" + ] + } + }, + "required": [ + "key", + "value", + "unit", + "currency", + "period" + ] + }, + "StockCompanyDto": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "metrics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockMetricDto" + } + }, + "unavailable": { + "type": "array", + "items": { + "type": "string" + } + }, + "observedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "description", + "metrics", + "unavailable", + "observedAt" + ] + }, + "StockDividendDto": { + "type": "object", + "properties": { + "perShare": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "recordDate": { + "type": "string", + "nullable": true, + "format": "date" + }, + "lastBuyDate": { + "type": "string", + "nullable": true, + "format": "date" + }, + "paymentDate": { + "type": "string", + "nullable": true, + "format": "date" + }, + "timing": { + "type": "string", + "enum": [ + "upcoming", + "past", + "undated" + ] + }, + "estimatedAmount": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + } + }, + "required": [ + "perShare", + "recordDate", + "lastBuyDate", + "paymentDate", + "timing", + "estimatedAmount" + ] + }, + "StockDividendsDto": { + "type": "object", + "properties": { + "dividends": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockDividendDto" + } + }, + "from": { + "type": "string", + "format": "date" + }, + "to": { + "type": "string", + "format": "date" + }, + "observedAt": { + "type": "string", + "format": "date-time" + }, + "positionAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "estimateUnavailable": { + "type": "boolean" + }, + "positionIsStale": { + "type": "boolean" + } + }, + "required": [ + "dividends", + "from", + "to", + "observedAt", + "positionAt", + "estimateUnavailable", + "positionIsStale" + ] + }, "TbankConnectionStatusDto": { "type": "object", "properties": { @@ -1753,21 +3117,6 @@ "token" ] }, - "MoneyDto": { - "type": "object", - "properties": { - "currency": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "currency", - "value" - ] - }, "PortfolioSummaryDto": { "type": "object", "properties": { @@ -1776,10 +3125,22 @@ "items": { "$ref": "#/components/schemas/MoneyDto" } + }, + "stockPositionCount": { + "type": "integer", + "minimum": 0, + "description": "Number of non-zero stock positions, not the number of units." + }, + "bondPositionCount": { + "type": "integer", + "minimum": 0, + "description": "Number of non-zero bond positions, not the number of units." } }, "required": [ - "totalsByCurrency" + "totalsByCurrency", + "stockPositionCount", + "bondPositionCount" ] }, "BrokerageAccountDto": { @@ -1823,21 +3184,6 @@ "portfolioSummary" ] }, - "AccountReferenceDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ] - }, "BrokerageOperationDto": { "type": "object", "properties": { @@ -1982,6 +3328,10 @@ "PortfolioPositionDto": { "type": "object", "properties": { + "instrumentUid": { + "type": "string", + "nullable": true + }, "account": { "$ref": "#/components/schemas/AccountReferenceDto" }, diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index c1f9e83..b7284a5 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -1,7 +1,7 @@ const ru = { translation: { appName: "WealthLedger", - navigation: { accounts: "Счета", settings: "Настройки" }, + navigation: { accounts: "Счета", stocks: "Акции", settings: "Настройки" }, breadcrumbs: { label: "Хлебные крошки", accounts: "Брокерские счета", @@ -11,6 +11,163 @@ const ru = { events: "Будущие события", settings: "Настройки", }, + stock: { + searchTitle: "Акции", + searchSubtitle: + "Найдите компанию, изучите цену и дивиденды, посмотрите свою историю владения.", + searchLabel: "Поиск акций", + searchPlaceholder: "Название или тикер, например Сбер или SBER", + searchHint: "Введите хотя бы два символа.", + searching: "Ищем акции", + noSearchResults: + "Акции не найдены. Попробуйте другое название или тикер.", + backToSearch: "Все акции", + searchVariants: + "Одна карточка на выпуск (ISIN). Обыкновенные и привилегированные акции показаны отдельно.", + apiUnavailable: "Торги через API недоступны", + apiAvailable: "Доступна через API", + exchangeUnknown: "Площадка не указана", + classCode: "Режим: {{code}}", + isin: "ISIN: {{value}}", + priceUnavailable: "Цена недоступна", + exchanges: { + REAL_EXCHANGE_MOEX: "Мосбиржа", + REAL_EXCHANGE_RTS: "СПБ Биржа", + REAL_EXCHANGE_OTC: "Внебиржевой рынок", + }, + aboutCompany: "О компании", + priceTableHint: "Дневные закрытия · записей: {{count}} · сначала новые", + share: "Акция", + loading: "Загружаем страницу акции", + loadingSection: "Загружаем: {{section}}", + loadingPrice: "Загружаем текущую цену", + unavailable: "Акция недоступна", + ambiguousTicker: + "Этот тикер относится к нескольким выпускам. Выберите нужную акцию в поиске.", + unavailableText: + "Проверьте ссылку или попробуйте загрузить данные ещё раз.", + connect: + "Для просмотра акций подключите T-Банк в настройках или обновите подключение.", + sectionError: "Не удалось загрузить этот раздел. Попробуйте ещё раз.", + refreshPrice: "Обновить цену", + price: "Цена", + priceAt: "Цена на {{date}} UTC", + changeBase: "К закрытию предыдущего торгового дня, {{date}}", + noComparison: + "Изменение за день недоступно: нет подтверждённой цены сравнения.", + partialQuote: "Часть данных о цене или торгах временно недоступна.", + chart: "График цены", + period: "Период графика", + periods: { + month: "Месяц", + halfYear: "Полгода", + year: "Год", + all: "Вся история", + }, + chartLabel: "Дневные цены акции, {{currency}}", + chartNote: + "Дневные цены по данным Т-Банка. Исторические цены могут учитывать корпоративные изменения.", + incomplete: "Последняя свеча ещё формируется: её цена может измениться.", + averageLine: "Текущая средняя покупка", + priceTable: "Цены в таблице", + date: "Дата", + candle: "Свеча", + complete: "Завершена", + forming: "Формируется", + noCandles: + "За этот период нет доступных цен. Возможно, торги ещё не начались.", + position: "Моя позиция", + quantity: "Количество акций", + value: "Текущая стоимость", + average: "Средняя покупка", + openReturn: "Результат позиции", + openReturnNote: + "Результат только открытой позиции: без проданных акций, дивидендов и полной доходности владения. «—» означает, что расчёт недоступен.", + portfolio: "Портфель", + snapshotAt: "Позиция на {{date}} UTC", + upcoming: "Предстоящие дивиденды", + companyEvent: "Событие компании", + perShare: "на акцию", + yourEstimate: "Оценка по вашей позиции", + record: "Дата реестра", + lastBuy: "Последний день покупки", + payment: "Выплата по данным источника", + noUpcoming: "Нет известных предстоящих выплат до {{to}}.", + dividendNote: + "Личная сумма — оценка по текущему количеству, не гарантия выплаты. Налоги не сопоставлены; дата выплаты компании может отличаться от дня зачисления на ваш счёт.", + estimateUnavailable: + "Данные позиции не удалось обновить. Личная оценка недоступна или рассчитана по сохранённой позиции.", + dividendHistory: "История дивидендов компании", + dividendWindow: + "События компании с {{from}} по {{to}}. Личные зачисления показаны отдельно в истории событий.", + noDividends: "Нет известных выплат за этот период.", + dateUnknown: "Дата не указана", + history: "Моя история событий", + historyNote: + "Операции по этой акции на доступных открытых счетах, включая периоды до текущего года. Дата сделки — дата операции брокера; количество — исполненная часть. История закрытых счетов не включена.", + eventFilter: "Фильтр событий", + filters: { + all: "Все", + trades: "Сделки", + dividends: "Дивиденды", + other: "Другие", + }, + noOperations: "Нет доступных операций по выбранному фильтру.", + previous: "Назад", + next: "Далее", + eventQuantity: "{{value}} шт.", + eventPrice: "Цена: {{value}}", + + company: "Компания и показатели", + sector: "Сектор", + lot: "Лот: {{value}} шт.", + partialCompany: "Часть сведений о компании временно недоступна.", + noFundamentals: "Нет доступных финансовых показателей.", + periodNote: + "TTM — последние 12 месяцев, MRQ — последний отчётный квартал. Дата отчётности не предоставлена источником. Отсутствующие значения не показаны.", + metrics: { + marketCapitalization: "Капитализация", + peRatioTtm: "P/E", + priceToBookTtm: "P/B", + revenueTtm: "Выручка", + ebitdaTtm: "EBITDA", + netIncomeTtm: "Чистая прибыль", + roe: "Рентабельность капитала (ROE)", + totalDebtMrq: "Общий долг", + netDebtToEbitda: "Чистый долг / EBITDA", + }, + shareTypes: { + SHARE_TYPE_COMMON: "Обыкновенная акция", + SHARE_TYPE_PREFERRED: "Привилегированная акция", + SHARE_TYPE_ADR: "Депозитарная расписка", + SHARE_TYPE_GDR: "Депозитарная расписка", + }, + sectors: { + financial: "Финансы", + energy: "Энергетика", + materials: "Материалы", + industrials: "Промышленность", + utilities: "Коммунальные услуги", + telecom: "Телекоммуникации", + it: "Информационные технологии", + consumer: "Потребительский сектор", + health_care: "Здравоохранение", + real_estate: "Недвижимость", + }, + trading: { + unknown: "Статус торгов неизвестен", + SECURITY_TRADING_STATUS_NORMAL_TRADING: "Идут торги", + SECURITY_TRADING_STATUS_NOT_AVAILABLE_FOR_TRADING: "Торги недоступны", + SECURITY_TRADING_STATUS_OPENING_PERIOD: "Период открытия", + SECURITY_TRADING_STATUS_CLOSING_PERIOD: "Период закрытия", + SECURITY_TRADING_STATUS_BREAK_IN_TRADING: "Перерыв в торгах", + SECURITY_TRADING_STATUS_CLOSING_AUCTION: "Аукцион закрытия", + SECURITY_TRADING_STATUS_OPENING_AUCTION: "Аукцион открытия", + SECURITY_TRADING_STATUS_SESSION_OPEN: "Сессия открыта", + SECURITY_TRADING_STATUS_SESSION_CLOSE: "Сессия закрыта", + SECURITY_TRADING_STATUS_DEALER_NORMAL_TRADING: "Дилерские торги", + }, + }, auth: { login: "Вход", register: "Регистрация", @@ -33,6 +190,10 @@ const ru = { scope: "Счёт", allDescription: "Портфель по всем доступным инвестиционным счетам", title: "Счета", + subtitle: "Ваши инвестиции по счетам", + currentValue: "Текущая стоимость", + stocks: "Акции", + bonds: "Облигации", emptyTitle: "Нет инвестиционных счетов", emptyText: "Откройте инвестиционный счет в T-Bank, чтобы он появился здесь.", @@ -120,6 +281,20 @@ const ru = { share: "Доля: {{value}}", }, ledger: { + occurredAt: "{{date}} UTC", + quantity: "{{value}} шт.", + price: "Цена: {{value}}", + operationType: { + buy: "Покупка", + sell: "Продажа", + dividend: "Зачисление дивидендов", + tax: "Налог", + fee: "Комиссия", + input: "Зачисление ценных бумаг", + output: "Вывод ценных бумаг", + transfer: "Перевод между счетами", + other: "Другая операция", + }, title: "Журнал операций", back: "К счету", open: "Открыть журнал", diff --git a/src/shared/lib/display.test.ts b/src/shared/lib/display.test.ts index 01ae6b2..284f47f 100644 --- a/src/shared/lib/display.test.ts +++ b/src/shared/lib/display.test.ts @@ -1,7 +1,20 @@ import { describe, expect, it } from "vitest"; -import { formatMoney, instrumentLogoUrl } from "./display"; +import { + formatDate, + formatMoney, + formatSnapshotTime, + instrumentLogoUrl, +} from "./display"; describe("shared display helpers", () => { + it("uses padded Russian dates and preserves the UTC date of timestamps", () => { + expect(formatDate("2027-09-10")).toBe("10.09.2027"); + expect(formatDate("2024-02-29")).toBe("29.02.2024"); + expect(formatDate("2027-09-10T00:30:00+03:00")).toBe("09.09.2027"); + expect(formatSnapshotTime("2027-09-10T00:30:00+03:00")).toBe( + "09.09.2027, 21:30", + ); + }); it("formats decimal strings without financial arithmetic", () => { expect(formatMoney({ value: "-1234567.89", currency: "RUB" })).toBe( "-1 234 567,89 ₽", diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts index d5518ca..993b2d1 100644 --- a/src/shared/lib/display.ts +++ b/src/shared/lib/display.ts @@ -15,9 +15,18 @@ function formatMoney(money: Money | null): string { return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`; } +function formatDate(value: string): string { + return new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + timeZone: "UTC", + }).format(new Date(value)); +} + function formatSnapshotTime(value: string): string { return new Intl.DateTimeFormat("ru-RU", { - dateStyle: "medium", + dateStyle: "short", timeStyle: "short", timeZone: "UTC", }).format(new Date(value)); @@ -32,4 +41,10 @@ function instrumentLogoUrl(logoName: string | null | undefined): string | null { : null; } -export { formatDecimal, formatMoney, formatSnapshotTime, instrumentLogoUrl }; +export { + formatDate, + formatDecimal, + formatMoney, + formatSnapshotTime, + instrumentLogoUrl, +}; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 796a25d..a05bfe4 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,4 +1,5 @@ export { + formatDate, formatDecimal, formatMoney, formatSnapshotTime, diff --git a/src/shared/ui/AppShell.tsx b/src/shared/ui/AppShell.tsx index bfef47a..072b251 100644 --- a/src/shared/ui/AppShell.tsx +++ b/src/shared/ui/AppShell.tsx @@ -14,6 +14,7 @@ const AppShell: FC = (props) => { const { pathname } = useLocation(); const items = [ { to: "/accounts", label: t("navigation.accounts") }, + { to: "/stocks", label: t("navigation.stocks") }, { to: "/settings", label: t("navigation.settings") }, ]; diff --git a/src/shared/ui/EntryCard.tsx b/src/shared/ui/EntryCard.tsx index e65433d..c7e38b0 100644 --- a/src/shared/ui/EntryCard.tsx +++ b/src/shared/ui/EntryCard.tsx @@ -1,10 +1,11 @@ import { Avatar, Paper, Stack, Text } from "@mantine/core"; -import { type FC, useState } from "react"; +import { type FC, type ReactNode, useState } from "react"; import classes from "./EntryCard.module.css"; type EntryCardProps = { title: string; metadata: string; + details?: ReactNode; initial: string; imageSrc?: string | null; value: string; @@ -33,6 +34,7 @@ const EntryCard: FC = (props) => { {props.metadata} + {props.details}