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}