From ca0ad4f824a47fa47e32d4717e27b15c8a985f7d Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 18:47:24 +0300 Subject: [PATCH 1/2] refactor: enforce frontend view model boundaries --- .ai/architecture.md | 9 +- .ai/conventions/storybook.md | 4 +- .ai/conventions/typescript-react.md | 24 + .ai/conventions/ui.md | 40 +- .storybook/dependency-cruiser.cjs | 2 +- .storybook/preview.tsx | 6 +- README.md | 22 +- eslint-rules.mjs | 178 +++++++ eslint.config.mjs | 206 ++++++-- packages/icons/package.json | 7 +- packages/icons/src/Icons.stories.tsx | 5 + packages/icons/src/Icons.test.tsx | 1 + .../OperationIcon/OperationIcon.module.css | 0 .../icons/src/OperationIcon/OperationIcon.tsx | 86 ++++ {src => packages/icons/src}/assets/react.svg | 0 {src => packages/icons/src}/assets/vite.svg | 0 .../icons/src}/bind-icon-animation.test.tsx | 6 + .../icons/src}/bind-icon-animation.ts | 7 + packages/icons/src/index.ts | 13 + packages/icons/src/navigation.module.css | 179 +++++++ packages/icons/src/svg/AddIcon.tsx | 1 + packages/icons/src/svg/BondsIcon.tsx | 39 ++ packages/icons/src/svg/CardsIcon.tsx | 39 ++ packages/icons/src/svg/CategoriesIcon.tsx | 27 ++ packages/icons/src/svg/ContactlessIcon.tsx | 32 ++ packages/icons/src/svg/DeleteIcon.tsx | 1 + packages/icons/src/svg/EditIcon.tsx | 1 + packages/icons/src/svg/PortfolioIcon.tsx | 43 ++ packages/icons/src/svg/RestoreIcon.tsx | 27 ++ packages/icons/src/svg/SettingsIcon.tsx | 35 ++ packages/icons/src/svg/StocksIcon.tsx | 39 ++ packages/icons/src/svg/WalletIcon.tsx | 29 ++ packages/icons/tsconfig.json | 14 +- .../ui/src/EntryCard/EntryCard.stories.tsx | 2 + packages/ui/src/EntryCard/EntryCard.test.tsx | 2 + packages/ui/src/EntryCard/EntryCard.tsx | 38 +- .../LoadingSkeleton.stories.tsx | 4 + .../ui/src/StateView/StateView.stories.tsx | 8 +- packages/ui/src/StateView/StateView.tsx | 2 + packages/ui/src/theme.ts | 4 +- scripts/check-image-publication.cjs | 11 +- scripts/check-openapi.mjs | 2 + scripts/check-release.cjs | 7 +- scripts/test-eslint.mjs | 125 ++++- scripts/test-pre-commit.mjs | 19 +- src/app/routes/AppRoutes/AllAccounts.test.tsx | 118 +++-- src/app/routes/AppRoutes/AppRoutes.test.tsx | 9 +- src/app/routes/AppRoutes/AppRoutes.tsx | 50 ++ .../DocumentTitle/DocumentTitle.test.tsx | 9 +- .../routes/DocumentTitle/DocumentTitle.tsx | 19 +- src/app/routes/ProtectedLayout.tsx | 1 + .../RouteErrorBoundary.test.tsx | 13 +- .../RouteErrorBoundary/RouteErrorBoundary.tsx | 11 +- src/app/routes/RouteFallback.tsx | 14 +- src/app/routes/RouteGuards.tsx | 7 +- src/app/routes/RouteTransition.tsx | 10 +- .../ui/AppBreadcrumbs/AccountBreadcrumbs.tsx | 7 +- .../AccountScopeSwitch/AccountScopeSwitch.tsx | 25 + .../AccountScopeSwitch.vm.ts} | 44 +- .../ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx | 2 + src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx | 15 +- src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx | 2 + src/app/ui/AppBreadcrumbs/BreadcrumbTrail.tsx | 2 + src/app/ui/AppBreadcrumbs/CardBreadcrumbs.tsx | 2 + .../ui/AppBreadcrumbs/StockBreadcrumbs.tsx | 2 + src/app/ui/AppShell/AppShell.module.css | 178 ------- src/app/ui/AppShell/AppShell.tsx | 113 ++--- src/entities/bank-card/api/banking.test.tsx | 52 +- src/entities/bank-card/api/mutations.ts | 25 +- src/entities/bank-card/api/queries.ts | 29 +- src/entities/bank-card/api/summary.test.ts | 1 + src/entities/bank-card/index.ts | 15 +- .../bank-card/model/category.schema.ts | 30 ++ .../bank-card/model/form-validation.test.ts | 16 +- .../bank-card/model/form-validation.ts | 92 +--- src/entities/bank-card/model/form.schema.ts | 81 ++++ .../bank-card/model/operation-appearance.ts | 74 +-- src/entities/bank-card/model/period.test.ts | 1 + src/entities/bank-card/model/period.ts | 5 +- .../ui/BankCardFace/BankCardFace.stories.tsx | 91 ++-- .../ui/BankCardFace/BankCardFace.tsx | 31 +- .../ui/BankCardForm/BankCardFields.vm.ts | 76 +++ .../ui/BankCardForm/BankCardForm.stories.tsx | 6 +- .../ui/BankCardForm/BankCardForm.test.tsx | 26 +- .../ui/BankCardForm/BankCardForm.tsx | 132 ++---- .../ui/BankCardForm/BankCardForm.view.tsx | 119 +++-- .../ui/BankCardForm/BankCardForm.vm.ts | 127 +++++ src/entities/bank-card/ui/BankFeedback.tsx | 3 + .../BankOperationIcon.stories.tsx | 14 +- .../BankOperationIcon.test.tsx | 24 +- .../BankOperationIcon/BankOperationIcon.tsx | 39 -- .../BankPeriodPicker.tsx | 22 +- .../BankPeriodPicker/BankPeriodPicker.vm.ts | 30 ++ .../ui/BankSummary/BankSummary.stories.tsx | 144 +++--- .../ui/BankSummary/BankSummary.view.tsx | 115 +++-- .../CategoryManager/CategoryManager.test.tsx | 19 +- .../ui/CategoryManager/CategoryManager.tsx | 221 +++------ .../ui/CategoryManager/CategoryManager.vm.ts | 128 +++++ src/entities/bond/api/queries.ts | 11 +- src/entities/bond/index.ts | 3 +- .../api/useBrokerageOperations.test.tsx | 1 + .../api/useBrokerageOperations.ts | 8 +- .../broker-operation/model/display.ts | 27 +- .../broker-operation/ui/LedgerEntryRow.tsx | 73 +-- .../api/useBrokerageAccounts.test.tsx | 2 + .../brokerage-account/model/display.ts | 11 +- .../BrokerageAccountList.stories.tsx | 22 +- .../BrokerageAccountList.test.tsx | 87 ++-- .../BrokerageAccountList.tsx | 60 +-- .../api/useBrokerageEvents.test.tsx | 36 +- .../brokerage-event/api/useBrokerageEvents.ts | 1 + .../brokerage-event/ui/BrokerageEventCard.tsx | 14 +- .../BrokerageEventsPreview.stories.tsx | 53 ++- .../BrokerageEventsPreview.tsx | 71 +-- src/entities/income/api/useIncome.ts | 3 +- .../portfolio/api/resource-lifecycle.test.tsx | 32 +- .../portfolio/api/usePortfolio.test.tsx | 3 + src/entities/portfolio/api/usePortfolio.ts | 4 +- src/entities/portfolio/model/display.ts | 14 +- src/entities/stock/api/queries.ts | 20 +- src/entities/stock/index.ts | 3 +- src/features/auth/index.ts | 4 +- src/features/auth/model/auth-context.ts | 3 + .../model/{validation.ts => auth.schema.ts} | 3 + src/features/auth/model/auth.test.tsx | 39 +- src/features/auth/model/auth.tsx | 153 +----- src/features/auth/model/auth.vm.ts | 173 +++++++ src/features/auth/ui/AuthForm/AuthForm.tsx | 42 +- .../auth/ui/AuthPageLayout/AuthPageLayout.tsx | 2 + src/features/auth/ui/LoginForm.tsx | 25 - src/features/auth/ui/LoginForm/LoginForm.tsx | 12 + .../auth/ui/LoginForm/LoginForm.vm.ts | 28 ++ src/features/auth/ui/RegisterForm.tsx | 26 - .../auth/ui/RegisterForm/RegisterForm.tsx | 12 + .../auth/ui/RegisterForm/RegisterForm.vm.ts | 28 ++ src/features/instrument-details/index.ts | 2 +- .../instrument-details/model/section.test.ts | 26 +- .../instrument-details/model/section.ts | 3 + .../instrument-details/model/types.ts | 5 + .../model/useInstrumentOperations.ts | 12 +- .../ui/InstrumentChart.view.tsx | 197 -------- .../InstrumentChart/InstrumentChart.view.tsx | 195 ++++++++ .../ui/InstrumentChart/InstrumentChart.vm.ts | 64 +++ .../ui/InstrumentEvents.view.tsx | 84 ++-- .../ui/InstrumentPosition.view.tsx | 154 +++--- .../ui/InstrumentSection.view.tsx | 91 ++-- .../model/connection-boundary.ts | 1 + .../model/screen-sync.test.tsx | 159 +++++-- .../tbank-connection/model/screen-sync.ts | 78 ++- .../tbank-connection/model/sync.test.tsx | 40 +- src/features/tbank-connection/model/sync.ts | 2 + .../TbankConnectionSection.test.tsx | 54 ++- .../TbankConnectionSection.tsx | 9 +- .../TbankConnectionSection.view.test.tsx | 2 + .../TbankConnectionSection.view.tsx | 1 + .../TbankConnectionSection.vm.ts | 6 +- .../TbankConnectionSectionLoaded.tsx | 103 ++-- .../TbankConnectionSectionLoaded.vm.ts | 116 +++++ src/main.tsx | 9 +- .../AccountEventsPage.filters.test.tsx | 31 +- .../AccountEventsPage.test.tsx | 28 +- .../AccountEventsPage/AccountEventsPage.tsx | 9 +- .../AccountEventsPage/AccountEventsPage.vm.ts | 31 +- .../AccountEventsPageEmpty.view.tsx | 1 + .../AccountEventsPageError.view.tsx | 2 + .../AccountEventsPageLoaded.view.tsx | 96 ++-- .../components/AccountEventsPageLoaded.vm.ts | 24 + .../AccountEventsPageLoading.view.tsx | 7 + .../account-income/model/income.schema.ts | 31 ++ src/pages/account-income/model/period.ts | 16 +- .../AccountIncomePage.test.tsx | 328 ++++++++----- .../AccountIncomePage/AccountIncomePage.tsx | 9 +- .../AccountIncomePage/AccountIncomePage.vm.ts | 92 ++-- .../AccountIncomePageError.view.tsx | 1 + .../AccountIncomePageLoaded.view.tsx | 72 +-- .../AccountIncomePageLoading.view.tsx | 10 + .../components/IncomeComparisons.view.tsx | 63 ++- .../components/IncomePayments.view.tsx | 3 + .../IncomePeriodForm.stories.tsx | 11 +- .../IncomePeriodForm.view.tsx | 58 +-- .../IncomePeriodForm/IncomePeriodForm.vm.ts | 69 +++ .../components/IncomeSummary.view.tsx | 16 +- .../AccountOperationsPage.test.tsx | 88 ++-- .../AccountOperationsPage.tsx | 8 +- .../AccountOperationsPage.vm.ts | 60 ++- .../AccountOperationsPageError.view.tsx | 1 + .../AccountOperationsPageLoaded.view.tsx | 127 +++-- .../AccountOperationsPageLoaded.vm.ts | 78 +++ .../AccountOperationsPageLoading.view.tsx | 6 + .../ui/AccountPage/AccountPage.test.tsx | 154 ++++-- .../account/ui/AccountPage/AccountPage.tsx | 1 + .../ui/AccountPage/AccountPage.vm.test.tsx | 161 ++++--- .../account/ui/AccountPage/AccountPage.vm.ts | 80 +++- .../components/AccountHeading.view.tsx | 7 +- .../components/AccountPageEmpty.view.tsx | 24 +- .../components/AccountPageError.view.tsx | 6 +- .../components/AccountPageLoaded.view.tsx | 45 +- .../AccountPageLoading.stories.tsx | 1 + .../AccountPageLoading.view.tsx | 5 + .../CurrencyCards/CurrencyCards.test.tsx | 2 + .../CurrencyCards/CurrencyCards.view.tsx | 10 +- .../components/IncomeSummary.view.tsx | 30 +- .../PortfolioRefreshState.stories.tsx | 2 + .../PortfolioRefreshState.view.tsx | 11 +- .../PortfolioStructure.stories.tsx | 35 +- .../PortfolioStructure.test.tsx | 40 +- .../PortfolioStructure/PortfolioStructure.tsx | 81 +--- .../PortfolioStructure.view.tsx | 94 ++-- .../PortfolioStructure.vm.ts | 108 +++++ .../PositionCard/PositionCard.stories.tsx | 76 +-- .../PositionCard/PositionCard.view.tsx | 63 ++- .../components/PositionGroupCard.view.tsx | 122 ++--- .../components/PositionLogo.view.tsx | 48 +- .../components/PositionSection.view.tsx | 37 +- .../components/RecentOperations.view.tsx | 63 +-- .../ui/AccountPage/position-groups.test.tsx | 53 ++- .../position-layout.browser.test.tsx | 46 +- .../ui/AccountsPage/AccountsPage.test.tsx | 26 +- .../accounts/ui/AccountsPage/AccountsPage.tsx | 1 + .../ui/AccountsPage/AccountsPage.vm.ts | 3 + .../components/AccountsPageEmpty.view.tsx | 2 + .../components/AccountsPageError.view.tsx | 4 + .../components/AccountsPageLoaded.view.tsx | 5 + .../components/AccountsPageLoading.view.tsx | 9 + .../components/AccountsRefreshState.view.tsx | 16 +- src/pages/bond/ui/BondPage/BondPage.test.tsx | 74 ++- src/pages/bond/ui/BondPage/BondPage.tsx | 16 +- src/pages/bond/ui/BondPage/BondPage.view.tsx | 445 ++++++++++-------- src/pages/bond/ui/BondPage/BondPage.vm.ts | 43 +- .../bonds/ui/BondsPage/BondsPage.test.tsx | 1 + .../bonds/ui/BondsPage/BondsPage.view.tsx | 278 ++++++----- src/pages/bonds/ui/BondsPage/BondsPage.vm.ts | 7 +- .../bonds/ui/BondsPage/BondsPageView.vm.ts | 20 + src/pages/card/ui/CardPage/CardPage.test.tsx | 51 +- src/pages/card/ui/CardPage/CardPage.tsx | 18 +- src/pages/card/ui/CardPage/CardPage.view.tsx | 285 ++++++----- src/pages/card/ui/CardPage/CardPage.vm.ts | 34 +- .../ui/CardPage/CardPagePresentation.vm.ts | 98 ++++ .../DeleteOperation/DeleteOperation.test.tsx | 12 +- .../ui/DeleteOperation/DeleteOperation.tsx | 88 ++-- .../ui/DeleteOperation/DeleteOperation.vm.ts | 75 +++ .../ui/OperationForm/OperationFields.vm.ts | 99 ++++ .../ui/OperationForm/OperationForm.schema.ts | 131 ++++++ .../OperationForm/OperationForm.stories.tsx | 17 +- .../ui/OperationForm/OperationForm.test.tsx | 61 ++- .../card/ui/OperationForm/OperationForm.tsx | 8 +- .../ui/OperationForm/OperationForm.view.tsx | 227 +++++---- .../card/ui/OperationForm/OperationForm.vm.ts | 155 ++---- .../card/ui/OperationForm/RefundFields.vm.ts | 38 ++ .../cards/ui/CardsPage/CardsPage.test.tsx | 60 ++- src/pages/cards/ui/CardsPage/CardsPage.tsx | 7 +- .../cards/ui/CardsPage/CardsPage.view.tsx | 139 +++--- src/pages/cards/ui/CardsPage/CardsPage.vm.ts | 4 + .../cards/ui/CardsPage/CardsPageView.vm.ts | 35 ++ src/pages/not-found/ui/NotFoundPage.tsx | 1 + src/pages/settings/index.ts | 2 +- src/pages/settings/ui/SettingsPage.tsx | 31 -- .../settings/ui/SettingsPage/SettingsPage.tsx | 23 + .../ui/SettingsPage/SettingsPage.vm.ts | 19 + .../stock/ui/StockPage/StockPage.test.tsx | 85 ++-- src/pages/stock/ui/StockPage/StockPage.tsx | 16 +- .../stock/ui/StockPage/StockPage.view.tsx | 266 ++++++----- src/pages/stock/ui/StockPage/StockPage.vm.ts | 47 +- .../StockPage/components/StockEvents.view.tsx | 283 ++++++----- .../stocks/ui/StocksPage/StocksPage.test.tsx | 1 + .../stocks/ui/StocksPage/StocksPage.view.tsx | 229 +++++---- .../stocks/ui/StocksPage/StocksPage.vm.ts | 7 +- .../stocks/ui/StocksPage/StocksPageView.vm.ts | 20 + src/shared/api/authenticated-query.test.tsx | 2 + src/shared/api/authenticated-query.ts | 1 + src/shared/api/client.test.ts | 17 +- src/shared/api/client.ts | 38 +- src/shared/api/problem-details.test.ts | 7 +- src/shared/api/resource-query.ts | 29 +- src/shared/lib/calendar.schema.ts | 3 + src/shared/lib/calendar.ts | 12 + src/shared/lib/display.test.ts | 15 +- src/shared/lib/display.ts | 38 +- src/shared/lib/index.ts | 5 +- src/shared/lib/useImageFallback.ts | 10 + src/shared/theme/Foundations.stories.tsx | 162 ++++--- src/shared/theme/contrast.test.ts | 10 +- src/shared/ui/Button.stories.tsx | 3 + src/test/handlers.ts | 16 +- src/test/resource.ts | 7 +- src/test/setup.ts | 4 +- src/test/storybook.test.tsx | 5 + 287 files changed, 8558 insertions(+), 4538 deletions(-) create mode 100644 eslint-rules.mjs rename src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.module.css => packages/icons/src/OperationIcon/OperationIcon.module.css (100%) create mode 100644 packages/icons/src/OperationIcon/OperationIcon.tsx rename {src => packages/icons/src}/assets/react.svg (100%) rename {src => packages/icons/src}/assets/vite.svg (100%) rename {src/shared/lib => packages/icons/src}/bind-icon-animation.test.tsx (99%) rename {src/shared/lib => packages/icons/src}/bind-icon-animation.ts (99%) create mode 100644 packages/icons/src/navigation.module.css create mode 100644 packages/icons/src/svg/BondsIcon.tsx create mode 100644 packages/icons/src/svg/CardsIcon.tsx create mode 100644 packages/icons/src/svg/CategoriesIcon.tsx create mode 100644 packages/icons/src/svg/ContactlessIcon.tsx create mode 100644 packages/icons/src/svg/PortfolioIcon.tsx create mode 100644 packages/icons/src/svg/RestoreIcon.tsx create mode 100644 packages/icons/src/svg/SettingsIcon.tsx create mode 100644 packages/icons/src/svg/StocksIcon.tsx create mode 100644 packages/icons/src/svg/WalletIcon.tsx create mode 100644 src/app/ui/AppBreadcrumbs/AccountScopeSwitch/AccountScopeSwitch.tsx rename src/app/ui/AppBreadcrumbs/{AccountScopeSwitch.tsx => AccountScopeSwitch/AccountScopeSwitch.vm.ts} (53%) create mode 100644 src/entities/bank-card/model/category.schema.ts create mode 100644 src/entities/bank-card/model/form.schema.ts create mode 100644 src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts create mode 100644 src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts delete mode 100644 src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx rename src/entities/bank-card/ui/{ => BankPeriodPicker}/BankPeriodPicker.tsx (50%) create mode 100644 src/entities/bank-card/ui/BankPeriodPicker/BankPeriodPicker.vm.ts create mode 100644 src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts rename src/features/auth/model/{validation.ts => auth.schema.ts} (99%) create mode 100644 src/features/auth/model/auth.vm.ts delete mode 100644 src/features/auth/ui/LoginForm.tsx create mode 100644 src/features/auth/ui/LoginForm/LoginForm.tsx create mode 100644 src/features/auth/ui/LoginForm/LoginForm.vm.ts delete mode 100644 src/features/auth/ui/RegisterForm.tsx create mode 100644 src/features/auth/ui/RegisterForm/RegisterForm.tsx create mode 100644 src/features/auth/ui/RegisterForm/RegisterForm.vm.ts delete mode 100644 src/features/instrument-details/ui/InstrumentChart.view.tsx create mode 100644 src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx create mode 100644 src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts create mode 100644 src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts create mode 100644 src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts create mode 100644 src/pages/account-income/model/income.schema.ts create mode 100644 src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.vm.ts create mode 100644 src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts create mode 100644 src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.vm.ts create mode 100644 src/pages/bonds/ui/BondsPage/BondsPageView.vm.ts create mode 100644 src/pages/card/ui/CardPage/CardPagePresentation.vm.ts create mode 100644 src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts create mode 100644 src/pages/card/ui/OperationForm/OperationFields.vm.ts create mode 100644 src/pages/card/ui/OperationForm/OperationForm.schema.ts create mode 100644 src/pages/card/ui/OperationForm/RefundFields.vm.ts create mode 100644 src/pages/cards/ui/CardsPage/CardsPageView.vm.ts delete mode 100644 src/pages/settings/ui/SettingsPage.tsx create mode 100644 src/pages/settings/ui/SettingsPage/SettingsPage.tsx create mode 100644 src/pages/settings/ui/SettingsPage/SettingsPage.vm.ts create mode 100644 src/pages/stocks/ui/StocksPage/StocksPageView.vm.ts create mode 100644 src/shared/lib/calendar.schema.ts create mode 100644 src/shared/lib/calendar.ts create mode 100644 src/shared/lib/useImageFallback.ts diff --git a/.ai/architecture.md b/.ai/architecture.md index 592142c..a5b6fbb 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -69,10 +69,11 @@ Route-aware AppShell и его navigation styles принадлежат `app/ui` `ProtectedLayout` использует public API этого владельца. При изменении foundation прочитай [UI](conventions/ui.md#визуальная-основа) и [Storybook](conventions/storybook.md). -Статичные Add/Edit/Delete и публичный `IconProps` принадлежат private -`packages/icons`; package не зависит от UI, domain или motion. -`BankOperationIcon` и mappings остаются у entity. Card/Cards используют package -Add/Edit/Delete; restore/category SVG остаются локальными. +Все SVG, их геометрия и анимации принадлежат private `packages/icons`. +Приложение импортирует компоненты из `@wealthledger/icons` и подключает +`@wealthledger/icons/styles.css`. В `src` нет SVG-файлов и компонентов, +единственная задача которых — рисовать иконку. Domain mapping категории в +параметры `OperationIcon` остаётся в `entities/bank-card/model`. ## Примеры diff --git a/.ai/conventions/storybook.md b/.ai/conventions/storybook.md index 519437f..f9cb91b 100644 --- a/.ai/conventions/storybook.md +++ b/.ai/conventions/storybook.md @@ -19,7 +19,9 @@ StateView/LoadingSkeleton и их stories принадлежат `packages/ui`. Используй CSF, `Meta`/`StoryObj`, `satisfies Meta`, локальные fixtures и actions. Общие Mantine/theme/i18n/Dates providers уже есть в [preview](../../.storybook/preview.tsx); не создавай второй theme source. -Stories не достигают application API, auth, VM и query providers даже транзитивно; +Stories не достигают application API, auth и query providers даже транзитивно. +Presentation VM с form-context/UI-only state допустима; data VM остаётся за +этой границей, которую проверяет полный reachable graph; границу проверяет `deps:storybook`. Не добавляй MSW worker ради каталога. `play` через `storybook/test` нужен, когда interaction иллюстрирует собственный diff --git a/.ai/conventions/typescript-react.md b/.ai/conventions/typescript-react.md index c9420f0..489c3df 100644 --- a/.ai/conventions/typescript-react.md +++ b/.ai/conventions/typescript-react.md @@ -17,6 +17,30 @@ CSF сохраняет default meta и прямые story exports; configuration exceptions заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code. +## Читаемость: ESLint и review + +ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между +JSX siblings и вокруг многострочных declarations/control-flow/return, вынос +функций и условных выражений из JSX props, отдельные Zod schema files, +отсутствие SVG в приложении, optional trailing parameters и многострочные +сложные union types. Generated artifacts не форматируй вручную. +URL, строковые/template/regex literals и комментарии исключены из `max-len`; +это исключение для содержимого, а не повод собирать длинные expressions. +CSS и Markdown также держи в 80 колонках там, где это не ломает данные/ссылки. + +Review проверяет то, чего синтаксис не доказывает: + +- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат. +- Имена derived values описывают значение, например `projectedAmount`. +- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток + и многострочное преобразование — отдельная функция с guard и `if/else`. +- Union из object shapes оформляй несколькими строками; nullable scalar + `null | string` остаётся компактным. Для повторяемого shape используй named type. +- Последний необязательный аргумент — `accountId?: string`. Required аргумент + перед следующим required параметром может сохранять `T | undefined`. +- Helpers и действия размещай по [UI ownership](ui.md#container-vm-и-view); + перенос не меняет null/zero, validation, recovery, closures и hook ordering. + ## Hooks и derived state Hooks вызываются без условий и ранних выходов перед ними; custom hook нужен diff --git a/.ai/conventions/ui.md b/.ai/conventions/ui.md index 163ff83..e7c691a 100644 --- a/.ai/conventions/ui.md +++ b/.ai/conventions/ui.md @@ -11,13 +11,11 @@ Mantine component и общую тему. Для выпадающего выбо ограничения JSDOM решай в test setup, сохраняя production UI. Семантический HTML для структуры страницы остаётся уместным. -Icons ищи у существующего owner (например, -[BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx)); -статичные Add/Edit/Delete доступны в `@wealthledger/icons`. Их SVG декоративны -по умолчанию; имя action принадлежит кнопке. Standalone meaningful icon получает -`aria-hidden={false}`, `role="img"` и доступное имя. Category/kind mappings -и анимации остаются в entity. Не добавляй пакет ради одного символа. Business/domain -components живут у feature/entity/page, даже при повторном использовании. +Icons импортируй из `@wealthledger/icons`; геометрия, SVG assets и animation +lifecycle принадлежат этому пакету. Domain/category lookup возвращает параметры +иконки из model сущности. SVG декоративны по умолчанию; доступное имя action +принадлежит кнопке. Meaningful icon получает `aria-hidden={false}`, `role="img"` +и доступное имя. Приложение и Storybook подключают CSS пакета. Wrapper нужен для проектной ответственности, а не ради нового имени для Button. Локальный CSS module принадлежит component; общий token вводи для @@ -51,10 +49,26 @@ retry callbacks, domain grouping и derived presentation data. View получа | `Component.types.ts` | Переиспользуемые props/local types, когда отдельный owner полезен | | `Component.module.css` | Локальные стили | -Во view нет data/auth/router/navigation/VM hooks и вычислений по domain collection. -Допустимы `useTranslation`, form-context hooks, UI-only state (например failed -image fallback) и formatter одного переданного значения. Группировка/доли -коллекции готовятся в VM. Перенос fetch во view ради меньшего container нарушает границу. +View получает готовые данные и именованные действия. Container вызывает VM; +VM владеет запросами, формой, состоянием, обработчиками и derived values. +Во view нет data/auth/router/navigation hooks и domain grouping. Data VM +вызывает container; presentation hook не должен скрывать fetching во view. +Presentation VM для form-context/UI-only state может использоваться автономно +в каталоге: её граф зависимостей не достигает application data/API. +`useTranslation` и декларативный вывод коллекции допустимы во view. + +JSX props получают ссылки на функции, включая `onClick`, render props, +`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай +`useController` в VM вместо inline `Controller.render`. Значение prop с условием +сначала получает предметное имя: `projectedAmount`, `archiveActionLabel`. +Проверяй narrowing при переносе: выражение не должно читать отсутствующие данные +до ветки, которая раньше защищала доступ; обработчик сохраняет актуальный snapshot. + +Внутри компонента нет именованных helpers. Чистые функции форматирования и +адаптации UI inputs выноси в `shared/lib`, передавая явные аргументы. Domain +преобразование остаётся в model своего owner; shared не импортирует entities/pages. +Closure над состоянием, mutations и refs является действием VM, а не shared helper. +Render-helper замени самостоятельным presentation component у того же owner. Отдельный state/section с самостоятельной responsibility выноси в `components/` своего component. Небольшой tightly-coupled renderer оставляй @@ -87,6 +101,10 @@ image fallback) и formatter одного переданного значени ## Формы +Схемы Zod объявляй в `*.schema.ts`, включая `.refine`/`.superRefine`. +Зависимая от текущих данных схема — фабрика с явными inputs в том же файле; +VM вызывает фабрику. Сохраняй актуальность данных и deferred validation messages. + Используй `react-hook-form`, `zodResolver`, `FormProvider`. Container/VM создаёт form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`. Во время submit блокируй все действия формы. Zod errors показывай у поля; diff --git a/.storybook/dependency-cruiser.cjs b/.storybook/dependency-cruiser.cjs index 6576ed2..347f1a1 100644 --- a/.storybook/dependency-cruiser.cjs +++ b/.storybook/dependency-cruiser.cjs @@ -9,7 +9,7 @@ module.exports = { name: "stories-must-not-reach-application-data", severity: "error", to: { - path: String.raw`^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|\.vm\.ts$|^src/pages/[^/]+/ui/.*Page\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/`, + path: String.raw`^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|^src/pages/[^/]+/ui/.*Page\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/`, reachable: true, }, }, diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 267c854..e5ef2ca 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,12 +1,14 @@ -import "@mantine/core/styles.css"; import "@mantine/charts/styles.css"; +import "@mantine/core/styles.css"; import "@mantine/dates/styles.css"; +import "@wealthledger/icons/styles.css"; import "@wealthledger/ui/styles.css"; import "dayjs/locale/ru"; -import "../src/shared/i18n"; import type { Preview } from "@storybook/react-vite"; +import "../src/shared/i18n"; + import { MantineProvider } from "@mantine/core"; import { DatesProvider } from "@mantine/dates"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; diff --git a/README.md b/README.md index e57f40d..0ebf4da 100644 --- a/README.md +++ b/README.md @@ -262,14 +262,14 @@ Library build использует отдельный Vite config без app plu Начальная версия manifest взята из приложения; отдельный release-процесс и публикация не настроены. -Private `@wealthledger/icons` экспортирует `AddIcon`, `EditIcon`, `DeleteIcon` -и type `IconProps`. Единственный source рисунка — TSX в `packages/icons/src/svg`; -поставка содержит ESM/declarations, без raw SVG, CSS, Mantine и motion. -React — peer/external. Размер `size` по умолчанию 24, `strokeWidth` — 1.5; -цвет наследуется через `currentColor`, SVG props можно передать явно. -В action используйте декоративный ``, -доступное имя задавайте кнопке. Standalone пример: -``. -Поддерживается также стандартный `aria-labelledby`; подпись выбирает caller. -Card/Cards используют пакетные Add/Edit/Delete с прежними размерами/strokeWidth. -Restore/category SVG и предметный BankOperationIcon остаются в приложении. +Private `@wealthledger/icons` содержит все SVG приложения: действия, +навигацию, contactless/wallet и геометрию `OperationIcon`. Пакет поставляет +ESM, declarations и `@wealthledger/icons/styles.css`; приложение и Storybook +подключают CSS явно. В `src` нет raw SVG и компонентов только для рисования +иконок. Category/kind mapping остаётся в model `entities/bank-card`. + +Статичные иконки принимают `IconProps`: `size` по умолчанию 24, +`strokeWidth` 1.5, цвет через `currentColor`. В кнопке иконка декоративная, +имя принадлежит действию. Для самостоятельного изображения передайте +`aria-hidden={false}`, `role="img"` и `aria-label` либо `aria-labelledby`. +Анимации сохраняют hover/focus lifecycle и `prefers-reduced-motion`. diff --git a/eslint-rules.mjs b/eslint-rules.mjs new file mode 100644 index 0000000..bbb543c --- /dev/null +++ b/eslint-rules.mjs @@ -0,0 +1,178 @@ +const readability = { + create(context) { + const filename = context.filename; + + function checkParameters(node) { + const last = node.params.at(-1); + if ( + last?.type === "Identifier" + && !last.optional + && last.typeAnnotation?.typeAnnotation.type === "TSUnionType" + && last.typeAnnotation.typeAnnotation.types.some( + type => type.type === "TSUndefinedKeyword", + ) + ) + context.report({ messageId: "optional", node: last }); + } + + return { + ArrowFunctionExpression: checkParameters, + CallExpression(node) { + if ( + node.callee.type === "MemberExpression" + && ["refine", "superRefine"].includes(node.callee.property.name) + && !filename.endsWith(".schema.ts") + ) + context.report({ messageId: "schema", node }); + }, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + ImportDeclaration(node) { + if ( + node.source.value === "zod" + && node.importKind !== "type" + && !filename.endsWith(".schema.ts") + ) + context.report({ messageId: "schema", node }); + }, + JSXOpeningElement(node) { + if ( + !filename.includes("/packages/icons/") + && node.name.type === "JSXIdentifier" + && /^(?:svg|path|circle|ellipse|rect|polygon|polyline|line|g|defs|use|symbol)$/.test( + node.name.name, + ) + ) + context.report({ messageId: "svg", node }); + }, + TSUnionType(node) { + const complex + = node.types.length > 2 + || node.types.some(type => type.type === "TSTypeLiteral"); + + if ( + complex + && node.types.some( + (type, index) => + index > 0 + && type.loc.start.line === node.types[index - 1].loc.end.line, + ) + ) + context.report({ messageId: "union", node }); + }, + VariableDeclarator(node) { + if ( + !/^[A-Z]/.test(node.id.name ?? "") + || node.init?.type !== "ArrowFunctionExpression" + || node.init.body.type !== "BlockStatement" + ) + return; + + for (const statement of node.init.body.body) { + if (statement.type === "FunctionDeclaration") + context.report({ messageId: "helper", node: statement }); + + else if (statement.type === "VariableDeclaration") { + for (const declaration of statement.declarations) { + if ( + ["ArrowFunctionExpression", "FunctionExpression"].includes( + declaration.init?.type, + ) + ) + context.report({ messageId: "helper", node: declaration }); + } + } + } + }, + }; + }, + meta: { + messages: { + helper: + "Move helpers outside components; actions belong to the ViewModel.", + optional: + "Use an optional trailing parameter instead of a union with undefined.", + schema: "Declare Zod schemas in a dedicated *.schema.ts file.", + svg: "Own SVG markup in packages/icons, and import its public component.", + union: "Put complex union members on separate lines.", + }, + schema: [], + type: "suggestion", + }, +}; + +function isComponent(node) { + const pending = [node]; + + while (pending.length > 0) { + const child = pending.pop(); + if (!child) continue; + + switch (child.type) { + case "JSXElement": { + return true; + } + + case "JSXFragment": { + return true; + } + + case "JSXExpressionContainer": { + pending.push(child.expression); + continue; + } + + case "LogicalExpression": { + pending.push(child.right); + continue; + } + + case "ConditionalExpression": { + pending.push(child.consequent, child.alternate); + continue; + } + } + } + + return false; +} + +const jsxSpacing = { + create(context) { + function check(node) { + let previous; + + for (const child of node.children) { + if (child.type === "JSXText" && !child.value.trim()) continue; + if (isComponent(child)) { + if (previous) { + const gap = context.sourceCode.text.slice( + previous.range[1], + child.range[0], + ); + + if (!/\n\s*\n/.test(gap)) + context.report({ + fix: fixer => fixer.insertTextAfter(previous, "\n"), + messageId: "space", + node: child, + }); + } + + previous = child; + } + else previous = undefined; + } + } + + return { JSXElement: check, JSXFragment: check }; + }, + meta: { + fixable: "whitespace", + messages: { space: "Separate sibling JSX components with a blank line." }, + schema: [], + type: "layout", + }, +}; + +export default { rules: { "jsx-spacing": jsxSpacing, readability } }; diff --git a/eslint.config.mjs b/eslint.config.mjs index 75cc19b..edbfa82 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,10 +20,24 @@ import { defineConfig, globalIgnores } from "eslint/config"; import globals from "globals"; import tseslint from "typescript-eslint"; +import frontend from "./eslint-rules.mjs"; + export default defineConfig( + { + files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"], + ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"], + plugins: { frontend }, + rules: { "frontend/jsx-spacing": "error", "frontend/readability": "error" }, + }, globalIgnores([ - "**/dist/**", "dist-ssr/**", "storybook-static/**", "coverage/**", - ".vitest/**", ".scannerwork/**", "src/shared/api/generated/**", "src/shared/api/spec/**", + "**/dist/**", + "dist-ssr/**", + "storybook-static/**", + "coverage/**", + ".vitest/**", + ".scannerwork/**", + "src/shared/api/generated/**", + "src/shared/api/spec/**", ]), { extends: [ @@ -35,32 +49,91 @@ export default defineConfig( security.configs.recommended, noUnsanitized.configs.recommended, perfectionist.configs["recommended-natural"], - stylistic.configs.customize({ indent: "tab", jsx: true, quotes: "double", semi: true }), + stylistic.configs.customize({ + indent: "tab", + jsx: true, + quotes: "double", + semi: true, + }), ], files: ["**/*.{js,mjs,cjs,ts,tsx}"], linterOptions: { reportUnusedDisableDirectives: "error" }, rules: { // A comma disambiguates generic arrow functions from JSX in TSX. - "@stylistic/comma-dangle": ["error", { - arrays: "always-multiline", enums: "always-multiline", exports: "always-multiline", - functions: "always-multiline", generics: "ignore", imports: "always-multiline", - objects: "always-multiline", tuples: "always-multiline", - }], + "@stylistic/comma-dangle": [ + "error", + { + arrays: "always-multiline", + enums: "always-multiline", + exports: "always-multiline", + functions: "always-multiline", + generics: "ignore", + imports: "always-multiline", + objects: "always-multiline", + tuples: "always-multiline", + }, + ], + // Keep text/interpolation together: splitting can change rendered whitespace. "@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }], + "@stylistic/max-len": [ + "error", + { + code: 80, + ignoreComments: true, + ignoreRegExpLiterals: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + ignoreTrailingComments: true, + ignoreUrls: true, + tabWidth: 2, + }, + ], + "@stylistic/multiline-ternary": ["error", "always-multiline"], "@stylistic/no-tabs": ["error", { allowIndentationTabs: true }], "@stylistic/operator-linebreak": ["error", "before"], - "perfectionist/sort-classes": ["error", { groups: ["static-method", "property", "method", "unknown"], type: "natural" }], + "@stylistic/padding-line-between-statements": [ + "error", + { blankLine: "always", next: "return", prev: "*" }, + { + blankLine: "always", + next: "*", + prev: ["multiline-const", "multiline-let", "block-like", "type"], + }, + { + blankLine: "always", + next: ["multiline-const", "multiline-let", "block-like", "type"], + prev: "*", + }, + { blankLine: "any", next: "cjs-import", prev: "cjs-import" }, + ], + "no-nested-ternary": "error", + "perfectionist/sort-classes": [ + "error", + { + groups: ["static-method", "property", "method", "unknown"], + type: "natural", + }, + ], // Preserve the repository's component, hook and config file naming conventions. - "unicorn/filename-case": ["error", { cases: { camelCase: true, kebabCase: true, pascalCase: true } }], + "unicorn/filename-case": [ + "error", + { cases: { camelCase: true, kebabCase: true, pascalCase: true } }, + ], // React uses Props/Ref names, including the recommended React ref-name rule. - "unicorn/name-replacements": ["error", { replacements: { props: false, ref: false } }], + "unicorn/name-replacements": [ + "error", + { replacements: { props: false, ref: false } }, + ], // React, Mantine and Fetch accept null at their documented call boundaries. "unicorn/no-null": ["error", { checkArguments: false }], + "unicorn/prefer-ternary": ["error", "only-single-line"], }, settings: { - "import-x/resolver-next": [createTypeScriptImportResolver({ project: "tsconfig.json" })], + "import-x/resolver-next": [ + createTypeScriptImportResolver({ project: "tsconfig.json" }), + ], }, }, { @@ -68,18 +141,37 @@ export default defineConfig( languageOptions: { globals: globals.node }, }, { - extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript], + extends: [ + tseslint.configs.recommendedTypeChecked, + importX.flatConfigs.typescript, + ], files: ["**/*.{ts,tsx}"], - languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } }, + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, rules: { // React ignores event handler return values; floating promises are still checked in bodies. - "@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: false } }], + "@typescript-eslint/no-misused-promises": [ + "error", + { checksVoidReturn: { attributes: false } }, + ], "@typescript-eslint/switch-exhaustiveness-check": "error", }, }, { - extends: [react.configs["recommended-type-checked"], hooks.configs.flat.recommended, query.configs["flat/recommended"]], - files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}", ".storybook/preview.tsx"], + extends: [ + react.configs["recommended-type-checked"], + hooks.configs.flat.recommended, + query.configs["flat/recommended"], + ], + files: [ + "src/**/*.{ts,tsx}", + "packages/*/src/**/*.{ts,tsx}", + ".storybook/preview.tsx", + ], languageOptions: { globals: globals.browser }, rules: { "import-x/exports-last": "error", @@ -95,10 +187,16 @@ export default defineConfig( files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"], ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"], rules: { - "@typescript-eslint/no-magic-numbers": ["error", { - ignore: [-1, 0, 1, "-1n", "0n", "1n"], ignoreArrayIndexes: true, ignoreDefaultValues: true, - ignoreNumericLiteralTypes: true, ignoreTypeIndexes: true, - }], + "@typescript-eslint/no-magic-numbers": [ + "error", + { + ignore: [-1, 0, 1, "-1n", "0n", "1n"], + ignoreArrayIndexes: true, + ignoreDefaultValues: true, + ignoreNumericLiteralTypes: true, + ignoreTypeIndexes: true, + }, + ], }, }, { @@ -110,51 +208,73 @@ export default defineConfig( }, }, { - extends: [testingLibrary.configs["flat/react"], jestDom.configs["flat/recommended"]], + extends: [ + testingLibrary.configs["flat/react"], + jestDom.configs["flat/recommended"], + ], files: ["**/*.test.tsx"], }, storybook.configs["flat/recommended"], { files: ["**/*.stories.tsx", ".storybook/preview.tsx"], - rules: { "import-x/exports-last": "off", "import-x/no-default-export": "off" }, + rules: { + "import-x/exports-last": "off", + "import-x/no-default-export": "off", + }, }, { - files: [ - "src/**/*.{ts,tsx}", - "packages/*/src/**/*.{ts,tsx}", - ], - ignores: [ - "**/*.test.{ts,tsx}", - "**/*.stories.tsx", - "src/test/**", - ], + files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"], + ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"], rules: { "@typescript-eslint/no-unsafe-enum-comparison": "error", "no-restricted-syntax": [ "error", { - message: "Use the owning enum member for machine codes and protocol values.", - selector: "Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)", + message: + "Pass a named handler from the ViewModel; prepare prop data before JSX.", + selector: + "JSXAttribute > JSXExpressionContainer :matches(ArrowFunctionExpression, FunctionExpression)", }, { - message: "Use the owning enum member instead of a template machine code.", - selector: "TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)", + message: "Name the derived prop value before JSX.", + selector: + "JSXAttribute > JSXExpressionContainer ConditionalExpression", + }, + + { + message: + "Use the owning enum member for machine codes and protocol values.", + selector: + "Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)", }, { - message: "Use an enum member instead of an inline code or protocol value.", - selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]", + message: + "Use the owning enum member instead of a template machine code.", + selector: + "TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)", }, { - message: "Use an enum member instead of constructing a code or protocol value.", - selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value", + message: + "Use an enum member instead of an inline code or protocol value.", + selector: + "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]", + }, + { + message: + "Use an enum member instead of constructing a code or protocol value.", + selector: + "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value", }, { message: "Use a computed enum member as the protocol mapping key.", - selector: "Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]", + selector: + "Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]", }, { - message: "Compare codes with their enum members, not string literals.", - selector: "BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]", + message: + "Compare codes with their enum members, not string literals.", + selector: + "BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]", }, ], }, diff --git a/packages/icons/package.json b/packages/icons/package.json index e3f8c13..17c4c3c 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -10,9 +10,12 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" - } + }, + "./styles.css": "./dist/index.css" }, - "sideEffects": false, + "sideEffects": [ + "**/*.css" + ], "scripts": { "build": "vite build && tsc -p tsconfig.json" }, diff --git a/packages/icons/src/Icons.stories.tsx b/packages/icons/src/Icons.stories.tsx index 40bfe20..47a7a0e 100644 --- a/packages/icons/src/Icons.stories.tsx +++ b/packages/icons/src/Icons.stories.tsx @@ -17,6 +17,7 @@ const meta = { } satisfies Meta; export default meta; + type Story = StoryObj; export const Decorative: Story = { @@ -25,9 +26,11 @@ export const Decorative: Story = { + + @@ -39,7 +42,9 @@ export const Meaningful: Story = { render: () => (
+ +
), diff --git a/packages/icons/src/Icons.test.tsx b/packages/icons/src/Icons.test.tsx index 7d52460..c416b5b 100644 --- a/packages/icons/src/Icons.test.tsx +++ b/packages/icons/src/Icons.test.tsx @@ -12,6 +12,7 @@ describe("icon accessibility", () => { , ); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); expect( // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. diff --git a/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.module.css b/packages/icons/src/OperationIcon/OperationIcon.module.css similarity index 100% rename from src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.module.css rename to packages/icons/src/OperationIcon/OperationIcon.module.css diff --git a/packages/icons/src/OperationIcon/OperationIcon.tsx b/packages/icons/src/OperationIcon/OperationIcon.tsx new file mode 100644 index 0000000..198b036 --- /dev/null +++ b/packages/icons/src/OperationIcon/OperationIcon.tsx @@ -0,0 +1,86 @@ +import type { FC } from "react"; + +import { bindIconAnimation } from "../bind-icon-animation"; +import classes from "./OperationIcon.module.css"; + +const paths = { + basket: + "M3 9h18l-2 11H5L3 9Z M8 9l4-6 4 6 M9 13v3 M15 13v3 M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0", + car: + "M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2 M8 16h8 M6 11h12m-6-4v4 M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4 M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4", + cashWithdrawal: "M4 10V3h16v7 M7 7h10v14H7V7Z M7 11h10 M10 16h4", + coffee: + "M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z M16 9h2a3 3 0 0 1 0 6h-2 M8 6c-2-1 2-2 0-4 M12 6c-2-1 2-2 0-4 M3 22h15", + deposit: + "M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0 M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3 M4 16c0 4 16 4 16 0 M4 19c0 4 16 4 16 0", + dice: + "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z M7 7h.01 M17 7h.01 M12 12h.01 M7 17h.01 M17 17h.01", + download: "M3 3h18v13H3V3Z M8 21h8 M12 16v5 M12 6v7 M9 10l3 3 3-3", + education: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9", + grid: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z", + growth: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5", + home: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8", + medical: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z", + music: "M9 5v14a3 3 0 1 1-3-3h3 M9 5l11-2v14a3 3 0 1 1-3-3h3 M9 9l11-2", + refund: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14", + repayment: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4", + server: + "M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3", + shield: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6", + shopping: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2", + smoking: + "M3 14h18v5H3v-5Z M16 14v5 M8 10V8c0-2 3-2 3-4V2 M15 10V8c0-2 3-2 3-4", + subscriptions: + "M4 10a8 8 0 0 1 13-6l3 3 M20 3v4h-4 M20 14a8 8 0 0 1-13 6l-3-3 M4 21v-4h4", + taxi: + "M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2", + topup: + "M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5m-2.5-2.5h5", + transfer: "M3 7h18l-4-4 M21 17H3l4 4", + travel: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z", + wifi: + "M3 7a14 14 0 0 1 18 0 M6 11a9 9 0 0 1 12 0 M9 15a4 4 0 0 1 6 0 M12 19h.01", + wine: + "M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10v1a5 5 0 0 1-10 0V7Z M12 13v8 M8 21h8", +} as const; + +type OperationIconProps = { + animation?: string; + color: string; + variant: keyof typeof paths; +}; + +const OperationIcon: FC = (props) => { + const { animation, color, variant } = props; + // eslint-disable-next-line security/detect-object-injection -- Animation keys select CSS module classes only. + const animationClass = animation ? classes[animation] : ""; + + // eslint-disable-next-line security/detect-object-injection -- Variant is a closed keyof the package geometry map. + const parts = paths[variant] + .split(" M") + .map((part, index) => ({ d: index ? `M${part}` : part, key: part })); + + return ( + + ); +}; + +export { OperationIcon, type OperationIconProps }; diff --git a/src/assets/react.svg b/packages/icons/src/assets/react.svg similarity index 100% rename from src/assets/react.svg rename to packages/icons/src/assets/react.svg diff --git a/src/assets/vite.svg b/packages/icons/src/assets/vite.svg similarity index 100% rename from src/assets/vite.svg rename to packages/icons/src/assets/vite.svg diff --git a/src/shared/lib/bind-icon-animation.test.tsx b/packages/icons/src/bind-icon-animation.test.tsx similarity index 99% rename from src/shared/lib/bind-icon-animation.test.tsx rename to packages/icons/src/bind-icon-animation.test.tsx index 89a9caa..fde5ddd 100644 --- a/src/shared/lib/bind-icon-animation.test.tsx +++ b/packages/icons/src/bind-icon-animation.test.tsx @@ -9,25 +9,30 @@ it("finishes every delayed part after leaving and does not restart on quick re-e vi.spyOn(globalThis, "matchMedia").mockReturnValue({ matches: true, } as MediaQueryList); + const { container } = render( , ); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role. const icon = container.querySelector("svg"); if (!icon) throw new Error("Missing icon"); const target = screen.getByRole("button"); let finishFirst!: () => void; let finishLast!: () => void; + const getAnimations = vi.fn(() => { const first = Promise.withResolvers(); const last = Promise.withResolvers(); finishFirst = first.resolve; finishLast = last.resolve; + return [{ finished: first.promise }, { finished: last.promise }]; }); + Object.defineProperty(icon, "getAnimations", { value: getAnimations }); fireEvent.mouseEnter(target); fireEvent.mouseLeave(target); @@ -53,6 +58,7 @@ it("keeps playing after keyboard blur and releases cancelled animations", async Category , ); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role. const icon = container.querySelector("svg"); if (!icon) throw new Error("Missing icon"); diff --git a/src/shared/lib/bind-icon-animation.ts b/packages/icons/src/bind-icon-animation.ts similarity index 99% rename from src/shared/lib/bind-icon-animation.ts rename to packages/icons/src/bind-icon-animation.ts index eb520a5..0f0433d 100644 --- a/src/shared/lib/bind-icon-animation.ts +++ b/packages/icons/src/bind-icon-animation.ts @@ -13,6 +13,7 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => { delete icon.dataset.iconAnimating; } }; + const start = () => { if (Object.hasOwn(icon.dataset, "iconAnimating")) return; icon.dataset.iconAnimating = ""; @@ -27,21 +28,25 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => { release(); }); }; + const enter = () => { if (!matchMedia("(hover: hover)").matches) return; isHovered = true; start(); }; + const leave = () => { isHovered = false; release(); }; + const focus = () => { isFocused = target.matches(":focus-visible") || !!target.querySelector(":focus-visible"); if (isFocused) start(); }; + const blur = (event: FocusEvent) => { if ( event.relatedTarget instanceof Node @@ -51,10 +56,12 @@ const bindIconAnimation = (icon: null | SVGSVGElement) => { isFocused = false; release(); }; + target.addEventListener("mouseenter", enter); target.addEventListener("mouseleave", leave); target.addEventListener("focusin", focus); target.addEventListener("focusout", blur); + return () => { isDisposed = true; target.removeEventListener("mouseenter", enter); diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 296d458..c1adc6a 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -1,4 +1,17 @@ +export { + OperationIcon, + type OperationIconProps, +} from "./OperationIcon/OperationIcon"; export { AddIcon } from "./svg/AddIcon"; +export { BondsIcon } from "./svg/BondsIcon"; +export { CardsIcon } from "./svg/CardsIcon"; +export { CategoriesIcon } from "./svg/CategoriesIcon"; +export { ContactlessIcon } from "./svg/ContactlessIcon"; export { DeleteIcon } from "./svg/DeleteIcon"; export { EditIcon } from "./svg/EditIcon"; +export { PortfolioIcon } from "./svg/PortfolioIcon"; +export { RestoreIcon } from "./svg/RestoreIcon"; +export { SettingsIcon } from "./svg/SettingsIcon"; +export { StocksIcon } from "./svg/StocksIcon"; +export { WalletIcon } from "./svg/WalletIcon"; export type { IconProps } from "./types"; diff --git a/packages/icons/src/navigation.module.css b/packages/icons/src/navigation.module.css new file mode 100644 index 0000000..75f58fa --- /dev/null +++ b/packages/icons/src/navigation.module.css @@ -0,0 +1,179 @@ +.rubles { + pointer-events: none; + color: var(--mantine-color-green-8); + stroke: currentColor; + + & path { + opacity: 0; + --flight-x: -7px; + --flight-rotation: -20deg; + --flight-delay: 0ms; + } + + & path:nth-child(2) { + --flight-x: 0px; + --flight-rotation: 8deg; + --flight-delay: 120ms; + } + + & path:nth-child(3) { + --flight-x: 7px; + --flight-rotation: 24deg; + --flight-delay: 240ms; + } +} + +.portfolioRuble { + stroke: var(--mantine-color-green-8); + opacity: 0; +} + +.stockArrow { + transform-origin: 21px 5px; +} + +.topSlider, +.bottomSlider { + fill: var(--mantine-color-body); +} + +@media (prefers-reduced-motion: no-preference) { + .drawStroke, + .couponLines path { + stroke-dasharray: 1; + } + + .topSlider { + --slider-offset: 3px; + --slider-delay: 0ms; + } + + .bottomSlider { + --slider-offset: -3px; + --slider-delay: 160ms; + } + + .couponLines path:nth-child(2) { + --line-delay: 140ms; + } + + .couponLines path:nth-child(3) { + --line-delay: 280ms; + } + + svg[data-icon-animating] { + .rubles path { + animation: ruble-fly 800ms ease-out var(--flight-delay) both; + } + .portfolioHandle { + animation: handle-lift 1200ms ease-in-out; + } + .portfolioLid { + animation: lid-open 1200ms ease-in-out; + } + .portfolioRuble { + animation: portfolio-ruble-reveal 1200ms ease-in-out; + } + .stockArrow { + animation: arrow-pop 300ms ease-out 400ms both; + } + .couponLines path { + animation: stroke-draw 300ms ease-out var(--line-delay, 0ms) both; + } + .drawStroke { + animation: stroke-draw 450ms ease-out; + } + :is(.topSlider, .bottomSlider) { + animation: slider-slide 650ms ease-in-out var(--slider-delay) both; + } + } +} + +@keyframes ruble-fly { + 0% { + opacity: 0; + transform: translate(0, 4px); + } + 20%, + 50% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translate(var(--flight-x), -10px) rotate(var(--flight-rotation)); + } +} + +@keyframes handle-lift { + 0%, + 100% { + transform: translateY(0); + } + 20% { + transform: translateY(-3px); + } + 40%, + 75% { + transform: translateY(-3px); + } +} + +@keyframes lid-open { + 0%, + 15%, + 100% { + transform: translateY(0); + } + 40%, + 75% { + transform: translateY(-3px); + } +} + +@keyframes portfolio-ruble-reveal { + 0%, + 25%, + 100% { + opacity: 0; + transform: translateY(2px); + } + 40%, + 75% { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes arrow-pop { + 0% { + opacity: 0; + transform: scale(0.7); + } + 60% { + opacity: 1; + transform: scale(1.2); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes stroke-draw { + 0% { + stroke-dashoffset: 1; + } + 100% { + stroke-dashoffset: 0; + } +} + +@keyframes slider-slide { + 0%, + 100% { + transform: translateX(0); + } + 50% { + transform: translateX(var(--slider-offset)); + } +} diff --git a/packages/icons/src/svg/AddIcon.tsx b/packages/icons/src/svg/AddIcon.tsx index fa04cbe..975a562 100644 --- a/packages/icons/src/svg/AddIcon.tsx +++ b/packages/icons/src/svg/AddIcon.tsx @@ -4,6 +4,7 @@ import type { IconProps } from "../types"; const AddIcon: FC = (props) => { const { size = "24", ...svgProps } = props; + return (
- + +
+ {props.valueLabel} + {Boolean(props.actions) && (
{props.actions}
)} diff --git a/packages/ui/src/LoadingSkeleton/LoadingSkeleton.stories.tsx b/packages/ui/src/LoadingSkeleton/LoadingSkeleton.stories.tsx index 561c9e1..e344bd6 100644 --- a/packages/ui/src/LoadingSkeleton/LoadingSkeleton.stories.tsx +++ b/packages/ui/src/LoadingSkeleton/LoadingSkeleton.stories.tsx @@ -23,7 +23,9 @@ const meta = { + + @@ -33,5 +35,7 @@ const meta = { } satisfies Meta; export default meta; + type Story = StoryObj; + export const Initial: Story = { name: "Загрузка области" }; diff --git a/packages/ui/src/StateView/StateView.stories.tsx b/packages/ui/src/StateView/StateView.stories.tsx index 5ab38c3..fc17ced 100644 --- a/packages/ui/src/StateView/StateView.stories.tsx +++ b/packages/ui/src/StateView/StateView.stories.tsx @@ -41,16 +41,17 @@ const meta = { }, render: (arguments_) => { const { onAction, withAction, ...stateProps } = arguments_; + return ( {arguments_.error ? "Повторить локально" : "Выполнить пример"} - )) + ) : undefined } /> @@ -63,6 +64,7 @@ const meta = { export default meta; type Story = StoryObj; + export const Empty: Story = { name: "Пустое состояние" }; export const EmptyWithAction: Story = { args: { withAction: true }, diff --git a/packages/ui/src/StateView/StateView.tsx b/packages/ui/src/StateView/StateView.tsx index a41d0f6..cd059e7 100644 --- a/packages/ui/src/StateView/StateView.tsx +++ b/packages/ui/src/StateView/StateView.tsx @@ -17,7 +17,9 @@ const StateView: FC = (props) => { {title} + {text} + {action} diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts index 7bdac58..d450d21 100644 --- a/packages/ui/src/theme.ts +++ b/packages/ui/src/theme.ts @@ -1,4 +1,3 @@ -import "./styles.css"; import { colorsTuple, createTheme, @@ -11,8 +10,11 @@ import { } from "@mantine/core"; import designTokens from "@wealthledger/tokens/tokens.json"; +import "./styles.css"; + const { breakpoints, color, radius, shadows, spacing, typography } = designTokens; + const mapValues = (values: Record, convert: (value: T) => U) => Object.fromEntries( Object.entries(values).map(([name, value]) => [name, convert(value)]), diff --git a/scripts/check-image-publication.cjs b/scripts/check-image-publication.cjs index 25788d5..74efa34 100644 --- a/scripts/check-image-publication.cjs +++ b/scripts/check-image-publication.cjs @@ -14,14 +14,20 @@ const { spawnSync } = require("node:child_process"); test("immutable publication resumes exact digest checks after post-push failure", () => { const directory = mkdtempSync(nodePath.join(tmpdir(), "wl-image-retry-")); + const workflow = readFileSync( nodePath.resolve(__dirname, "../.gitea/workflows/image.yml"), "utf8", ); + const block = workflow - .split(" - name: Publish immutable release tag\n run: |\n", 2)[1] + .split( + " - name: Publish immutable release tag\n run: |\n", + 2, + )[1] .split(" - name:", 1)[0] .replaceAll(/^ {10}/gm, ""); + const double = String.raw`#!${process.execPath} const fs = require('node:fs'); const args = process.argv.slice(2).join(' '); @@ -39,6 +45,7 @@ if (args.startsWith('manifest inspect')) { console.log(args.includes('image.version') ? '1.2.3' : process.env.BAD_REVISION ? 'other' : 'commit'); } else if (args.startsWith('run') && process.env.BAD_SMOKE) process.exit(1); `; + try { for (const name of ["docker", "npm"]) { // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. @@ -46,6 +53,7 @@ if (args.startsWith('manifest inspect')) { // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. chmodSync(nodePath.join(directory, name), EXECUTABLE_MODE); } + const run = (extra = {}) => spawnSync("/bin/sh", ["-ec", block], { cwd: directory, @@ -59,6 +67,7 @@ if (args.startsWith('manifest inspect')) { ...extra, }, }); + assert.notEqual(run().status, 0); const retry = run(); assert.equal(retry.status, 0, retry.stdout + retry.stderr); diff --git a/scripts/check-openapi.mjs b/scripts/check-openapi.mjs index a440dfa..d9a9ac5 100644 --- a/scripts/check-openapi.mjs +++ b/scripts/check-openapi.mjs @@ -6,6 +6,7 @@ import nodePath from "node:path"; const temporaryDirectory = await mkdtemp( nodePath.join(tmpdir(), "wealth-ledger-openapi-"), ); + const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts"); const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts"); const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js"); @@ -24,6 +25,7 @@ try { // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. readFile(generatedPath, "utf8"), ]); + if (committed !== current) { console.error( "Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.", diff --git a/scripts/check-release.cjs b/scripts/check-release.cjs index f5ed10e..f8814a0 100644 --- a/scripts/check-release.cjs +++ b/scripts/check-release.cjs @@ -2,14 +2,16 @@ const assert = require("node:assert/strict"); const { execFileSync } = require("node:child_process"); const { version } = require("../package.json"); -// eslint-disable-next-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell. -const git = (...arguments_) => execFileSync("git", arguments_, { encoding: "utf8" }).trim(); + +const git = (...arguments_) => + execFileSync("git", arguments_, { encoding: "utf8" }).trim(); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell. assert.equal( git("status", "--porcelain"), "", "Commit all changes before preparing a release", ); + const tag_ = git( "tag", "--merged", @@ -20,6 +22,7 @@ const tag_ = git( ) .split("\n") .find(tag => /^v\d+\.\d+\.\d+$/.test(tag)); + assert.equal( version, tag_?.slice(1) ?? "0.0.0", diff --git a/scripts/test-eslint.mjs b/scripts/test-eslint.mjs index 36ca2e3..65fc8f5 100644 --- a/scripts/test-eslint.mjs +++ b/scripts/test-eslint.mjs @@ -16,11 +16,13 @@ import storybook from "eslint-plugin-storybook"; import testingLibrary from "eslint-plugin-testing-library"; import unicorn from "eslint-plugin-unicorn"; import assert from "node:assert/strict"; +import { globSync } from "node:fs"; import { test } from "node:test"; import tseslint from "typescript-eslint"; test("ESLint enforces the frontend profile and excludes generated artifacts", async () => { const eslint = new ESLint(); + for (const [filePath, code, rule] of [ [ "src/shared/api/problem-details.ts", @@ -110,6 +112,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as rule, ); } + for (const file of [ "src/shared/api/generated/schema.d.ts", "packages/ui/dist/index.js", @@ -117,9 +120,11 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as "coverage/index.js", ]) assert.equal(await eslint.isPathIgnored(file), true, file); + const story = await eslint.calculateConfigForFile( "src/shared/ui/Button.stories.tsx", ); + assert.equal(story.rules["import-x/no-default-export"][0], 0); assert.ok(story.rules["storybook/default-exports"][0] > 0); assert.equal(story.rules["react-refresh/only-export-components"], undefined); @@ -130,6 +135,7 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy const source = "src/shared/api/problem-details.ts"; const component = "packages/ui/src/EntryCard/EntryCard.tsx"; const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx"; + for (const [preset, filePath] of [ [tseslint.configs.recommendedTypeChecked, source], [[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source], @@ -147,12 +153,25 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy [vitest.configs.recommended, unit], [testingLibrary.configs["flat/react"], unit], [jestDom.configs["flat/recommended"], unit], - ...storybook.configs["flat/recommended"].filter(config => config.rules).map(config => [config, config.name.endsWith("main-rules") ? ".storybook/main.ts" : "src/shared/ui/Button.stories.tsx"]), + ...storybook.configs["flat/recommended"] + .filter(config => config.rules) + .map(config => [ + config, + config.name.endsWith("main-rules") + ? ".storybook/main.ts" + : "src/shared/ui/Button.stories.tsx", + ]), ]) { const configs = [preset].flat(); - const expected = Object.assign({}, ...configs.map(config => config.rules)); + + const expected = Object.assign( + {}, + ...configs.map(config => config.rules), + ); + const config = await eslint.calculateConfigForFile(filePath); const actual = new Map(Object.entries(config.rules)); + for (const [rule, setting] of Object.entries(expected)) { const severity = Array.isArray(setting) ? setting[0] : setting; if (severity === "off" || severity === 0 || !rule.includes("/")) continue; @@ -164,10 +183,12 @@ test("all recommended plugin rules remain enabled in their intended scopes", asy test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => { const eslint = new ESLint({ fix: true }); const filePath = "packages/ui/src/EntryCard/EntryCard.tsx"; + const [fixed] = await eslint.lintText( "//identity\nconst identity = (value: T): T => value;\nexport {identity};", { filePath }, ); + assert.equal(fixed.fatalErrorCount, 0); assert.equal(fixed.errorCount, 0); assert.ok(fixed.output); @@ -178,10 +199,12 @@ test("ESLint formatting is idempotent and preserves TSX generic syntax", async ( test("ESLint formatting preserves spaces around JSX text and interpolations", async () => { const eslint = new ESLint({ fix: true }); + const [fixed] = await eslint.lintText( "const Example = () => {\nconst value = 'value';\nreturn Amount: {value} / {value};\n};\nexport { Example };", { filePath: "packages/ui/src/EntryCard/EntryCard.tsx" }, ); + assert.equal(fixed.errorCount, 0); assert.ok(fixed.output?.includes("Amount: {value} / {value}")); }); @@ -190,11 +213,25 @@ test("ESLint formatting preserves spaces around JSX text and interpolations", as test("machine codes require enum members without banning ordinary text or Prisma selects", async () => { const eslint = new ESLint(); const filePath = "src/shared/api/problem-details.ts"; - for (const code of ["export function check(code: string) { return code === \"one-off-code\"; }", "export const result = `MOEX_UNAVAILABLE`;", "export const result = { code: \"one-off-code\" };", "export const result = { \"errorCode\": \"one-off-code\" };", "export const result = { code: `dynamic-${Date.now()}` };", "export const result = \"MOEX_UNAVAILABLE\";", "export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];", "export const result = { OPERATION_TYPE_COUPON: \"income\" };"]) { + + for (const code of [ + "export function check(code: string) { return code === \"one-off-code\"; }", + "export const result = `MOEX_UNAVAILABLE`;", + "export const result = { code: \"one-off-code\" };", + "export const result = { \"errorCode\": \"one-off-code\" };", + "export const result = { code: `dynamic-${Date.now()}` };", + "export const result = \"MOEX_UNAVAILABLE\";", + "export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];", + "export const result = { OPERATION_TYPE_COUPON: \"income\" };", + ]) { const [result] = await eslint.lintText(code, { filePath }); assert.equal(result.fatalErrorCount, 0, code); - assert.ok(result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"), code); + assert.ok( + result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"), + code, + ); } + for (const code of [ "enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };", "export const select = { code: true, errorCode: true };", @@ -202,10 +239,80 @@ test("machine codes require enum members without banning ordinary text or Prisma ]) { const [result] = await eslint.lintText(code, { filePath }); assert.equal(result.fatalErrorCount, 0, code); - assert.ok(result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"), code); + assert.ok( + result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"), + code, + ); } - const [fixture] = await eslint.lintText("export const result = { code: \"MOEX_UNAVAILABLE\" };", { filePath: "src/shared/api/client.test.ts" }); - assert.ok(fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax")); - const [comparison] = await eslint.lintText("enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }", { filePath }); - assert.ok(comparison.messages.some(({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison")); + + const [fixture] = await eslint.lintText( + "export const result = { code: \"MOEX_UNAVAILABLE\" };", + { filePath: "src/shared/api/client.test.ts" }, + ); + + assert.ok( + fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"), + ); + + const [comparison] = await eslint.lintText( + "enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }", + { filePath }, + ); + + assert.ok( + comparison.messages.some( + ({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison", + ), + ); +}); + +test("readability enforces syntax without banning VM and schema patterns", async () => { + const eslint = new ESLint(); + const component = "src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx"; + const model = "src/entities/income/api/useIncome.ts"; + + const invalid = [ + [component, "const Example = () => ;", "frontend/readability"], + [component, "const Example = () => + @@ -39,6 +41,7 @@ const renderTitle = (path: string) => + @@ -59,10 +62,13 @@ beforeEach(() => { describe("document titles", () => { it("updates a loading account name and follows account scope and history", async () => { - const { promise: accountsReady, resolve: resolveAccounts } = Promise.withResolvers(); + const { promise: accountsReady, resolve: resolveAccounts } + = Promise.withResolvers(); + server.use( http.get("*/api/v1/brokerage-accounts", async () => { await accountsReady; + return HttpResponse.json(resource([{ id: "a", name: "ИИС" }])); }), ); @@ -102,6 +108,7 @@ describe("document titles", () => { server.use( http.get("*/api/v1/stocks/by-ticker/:ticker", () => { isRequested = true; + return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }); }), ); diff --git a/src/app/routes/DocumentTitle/DocumentTitle.tsx b/src/app/routes/DocumentTitle/DocumentTitle.tsx index cb616e5..ee243f8 100644 --- a/src/app/routes/DocumentTitle/DocumentTitle.tsx +++ b/src/app/routes/DocumentTitle/DocumentTitle.tsx @@ -24,29 +24,36 @@ type AccountDocumentTitleProps = { const AccountDocumentTitle: FC = (props) => { const { t } = useTranslation(); const { data } = useBrokerageAccounts(); + const accountName = props.accountId === "all" ? t("accounts.all") : (data?.find(account => account.id === props.accountId)?.name ?? t("account.title")); + let section = t("portfolio.title"); + switch (props.section ?? "") { case "income": { section = t("income.title"); break; } + case "operations": { section = t("ledger.title"); break; } + case "events": { section = t("events.title"); break; } + default: { break; } } + return ; }; @@ -55,18 +62,21 @@ type InstrumentDocumentTitleProps = { identifier: string }; const BondDocumentTitle: FC = (props) => { const { t } = useTranslation(); const { data } = useBond(props.identifier); + return ; }; const StockDocumentTitle: FC = (props) => { const { t } = useTranslation(); const { data } = useStock(props.identifier); + return ; }; const CardDocumentTitle: FC = (props) => { const { t } = useTranslation(); const { data } = useBankCard(props.identifier); + return ; }; @@ -94,7 +104,14 @@ const ProtectedDocumentTitle: FC = () => { if (cards) return ; if (bonds) return ; if (stocks) return ; - return settings ? : ; + + return settings + ? ( + + ) + : ( + + ); }; export { ProtectedDocumentTitle }; diff --git a/src/app/routes/ProtectedLayout.tsx b/src/app/routes/ProtectedLayout.tsx index 821b496..f03ff3b 100644 --- a/src/app/routes/ProtectedLayout.tsx +++ b/src/app/routes/ProtectedLayout.tsx @@ -8,6 +8,7 @@ const ProtectedLayout: FC = () => { return ( <> + } /> ); diff --git a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx index 134afb0..8218ddf 100644 --- a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx +++ b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx @@ -8,9 +8,10 @@ import { RouteErrorBoundary } from "./RouteErrorBoundary"; const session = { isAuthenticated: false }; vi.mock("@/features/auth", () => ({ - useAuth: () => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { user: session.isAuthenticated ? {} : null } - /* eslint-enable unicorn/no-null */)), + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + useAuth: () => + ({ user: session.isAuthenticated ? {} : null }), + /* eslint-enable unicorn/no-null */ })); const ThrowingRoute: FC = () => { @@ -39,6 +40,7 @@ const RouteHarness: FC = () => { + @@ -62,10 +64,13 @@ describe("RouteErrorBoundary", () => { async (path) => { session.isAuthenticated = path === "/accounts"; let isFailing = true; + const RecoverableRoute: FC = () => { if (isFailing) throw new Error("temporary failure"); + return
recovered route
; }; + render( @@ -116,10 +121,12 @@ describe("RouteErrorBoundary", () => { expect(reload).toHaveAttribute("href", "/login"); // Cancel browser navigation in JSDOM only after proving React Router did not intercept it. let isIntercepted = true; + const preventNavigation = (event: MouseEvent) => { isIntercepted = event.defaultPrevented; event.preventDefault(); }; + document.addEventListener("click", preventNavigation, { once: true }); fireEvent.click(reload); expect(isIntercepted).toBe(false); diff --git a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx index 809c239..6fe81ae 100644 --- a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx +++ b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx @@ -33,7 +33,13 @@ class Boundary extends Component { } render() { - return this.state.hasError ? : this.props.children; + return this.state.hasError + ? ( + + ) + : ( + this.props.children + ); } } @@ -46,10 +52,13 @@ const RouteErrorFallback: FC = (props) => { return ( {t("routeError.title")} + {t("routeError.text")} + + diff --git a/src/app/routes/RouteFallback.tsx b/src/app/routes/RouteFallback.tsx index d6f4e1d..77f5bf9 100644 --- a/src/app/routes/RouteFallback.tsx +++ b/src/app/routes/RouteFallback.tsx @@ -7,7 +7,10 @@ import { useTranslation } from "react-i18next"; import { RouteTransition } from "./RouteTransition"; type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>; -type RouteSkeletonVariant = "auth" | "content" | "shell"; + +type RouteSkeletonVariant = "auth" + | "content" + | "shell"; const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { const { variant } = props; @@ -22,8 +25,11 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { role="status" > + + + @@ -39,12 +45,16 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { > + + + + @@ -54,6 +64,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { return ( + ); @@ -61,6 +72,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { const LazyPage: FC = (props) => { const { children, variant } = props; + return ( }> {variant === "shell" diff --git a/src/app/routes/RouteGuards.tsx b/src/app/routes/RouteGuards.tsx index 3a3cf4f..b740d94 100644 --- a/src/app/routes/RouteGuards.tsx +++ b/src/app/routes/RouteGuards.tsx @@ -11,6 +11,7 @@ const Protected: FC = (props) => { const { ready, user } = useAuth(); const location = useLocation(); if (!ready) return ; + return user ? ( <>{children} @@ -30,13 +31,17 @@ const Public: FC = (props) => { const location = useLocation(); if (!ready) return ; - const requested = (location.state as null | { returnTo?: string })?.returnTo; + + const requested = (location.state as null + | { returnTo?: string })?.returnTo; + const returnTo = typeof requested === "string" && requested.startsWith("/") && !requested.startsWith("//") ? requested : "/accounts"; + return user ? : <>{children}; }; diff --git a/src/app/routes/RouteTransition.tsx b/src/app/routes/RouteTransition.tsx index 84895a9..a7cfd8a 100644 --- a/src/app/routes/RouteTransition.tsx +++ b/src/app/routes/RouteTransition.tsx @@ -11,16 +11,18 @@ const RouteTransition: FC = (props) => { const { pathname } = useLocation(); const reduceMotion = useReducedMotion(); + const transition = { + duration: reduceMotion ? 0 : TRANSITION_DURATION_SECONDS, + ease: "easeOut" as const, + }; + return ( {children} diff --git a/src/app/ui/AppBreadcrumbs/AccountBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/AccountBreadcrumbs.tsx index 8a7952d..5879342 100644 --- a/src/app/ui/AppBreadcrumbs/AccountBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs/AccountBreadcrumbs.tsx @@ -8,19 +8,24 @@ import { BreadcrumbTrail } from "./BreadcrumbTrail"; type AccountBreadcrumbsProps = { accountId: string; - section?: "events" | "income" | "operations"; + section?: "events" + | "income" + | "operations"; }; const AccountBreadcrumbs: FC = (props) => { const { accountId, section } = props; const { data: accounts } = useBrokerageAccounts(); const { t } = useTranslation(); + const accountName = accountId === "all" ? t("accounts.all") : (accounts?.find(account => account.id === accountId)?.name ?? t("breadcrumbs.accountFallback")); + const accountPath = `/accounts/${accountId}`; + const items = [ { label: t("breadcrumbs.accounts"), to: "/accounts" }, section ? { label: accountName, to: accountPath } : { label: accountName }, diff --git a/src/app/ui/AppBreadcrumbs/AccountScopeSwitch/AccountScopeSwitch.tsx b/src/app/ui/AppBreadcrumbs/AccountScopeSwitch/AccountScopeSwitch.tsx new file mode 100644 index 0000000..1ac4153 --- /dev/null +++ b/src/app/ui/AppBreadcrumbs/AccountScopeSwitch/AccountScopeSwitch.tsx @@ -0,0 +1,25 @@ +import type { FC } from "react"; + +import { Select } from "@mantine/core"; + +import { useAccountScopeSwitchVm } from "./AccountScopeSwitch.vm"; + +type AccountScopeSwitchProps = { accountId: string; section?: string }; + +const AccountScopeSwitch: FC = (props) => { + const { accountId, data, onChange, t } = useAccountScopeSwitchVm(props); + + return ( + { - if (!value || value === accountId) return; - const filters = new URLSearchParams(search); - filters.delete("cursor"); - void navigate({ - pathname: ["/accounts", encodeURIComponent(value), section].filter(Boolean).join("/"), - search: filters.toString(), - }); - }} - value={accountId} - w="100%" - /> - ); -}; -export { AccountScopeSwitch }; + const onChange: (value: null | string) => void = (value) => { + if (!value || value === accountId) return; + const filters = new URLSearchParams(search); + filters.delete("cursor"); + void navigate({ + pathname: ["/accounts", encodeURIComponent(value), section] + .filter(Boolean) + .join("/"), + search: filters.toString(), + }); + }; + + return { accountId, data, onChange, t }; +} + +export { useAccountScopeSwitchVm }; diff --git a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx index 0ad4d08..cb82e76 100644 --- a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx +++ b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx @@ -16,6 +16,7 @@ function renderBreadcrumbs( accounts?: { id: string; name: string }[], ) { useBrokerageAccounts.mockReturnValue({ data: accounts }); + return render( @@ -70,6 +71,7 @@ describe("AppBreadcrumbs", () => { "page", ); expect(screen.getByRole("navigation")).toHaveAttribute("data-depth", "3"); + for (const separator of screen.getAllByText("→")) { expect(separator).toHaveAttribute("aria-hidden", "true"); } diff --git a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx index 9f7b40b..ceaf254 100644 --- a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { useMatch } from "react-router"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; -import { AccountScopeSwitch } from "./AccountScopeSwitch"; +import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch"; import { BondBreadcrumbs } from "./BondBreadcrumbs"; import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { CardBreadcrumbs } from "./CardBreadcrumbs"; @@ -13,10 +13,12 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs"; const AppBreadcrumbs: FC = () => { const { t } = useTranslation(); + const operations = useMatch({ end: true, path: "/accounts/:accountId/operations", }); + const events = useMatch({ end: true, path: "/accounts/:accountId/events" }); const income = useMatch({ end: true, path: "/accounts/:accountId/income" }); const account = useMatch({ end: true, path: "/accounts/:accountId" }); @@ -37,6 +39,7 @@ const AppBreadcrumbs: FC = () => { accountId={operations.params.accountId ?? ""} section="operations" /> + { accountId={events.params.accountId ?? ""} section="events" /> + { accountId={income.params.accountId ?? ""} section="income" /> + { return ( + ); @@ -80,7 +86,12 @@ const AppBreadcrumbs: FC = () => { if (cards) return ; if (accounts) return ; - return settings ? : undefined; + + return settings + ? ( + + ) + : undefined; }; export { AppBreadcrumbs }; diff --git a/src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx index 6fe89f6..08cb1d2 100644 --- a/src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx @@ -7,9 +7,11 @@ import { useBond } from "@/entities/bond"; import { BreadcrumbTrail } from "./BreadcrumbTrail"; type BondBreadcrumbsProps = { identifier: string }; + const BondBreadcrumbs: FC = (props) => { const { data } = useBond(props.identifier); const { t } = useTranslation(); + return ( = (props) => { const { items } = props; const { t } = useTranslation(); + return ( = (props) => { const { data } = useBankCard(props.identifier); const { t } = useTranslation(); + return ( = (props) => { const { data } = useStock(props.identifier); const { t } = useTranslation(); + return ( = (props) => { const { contentAboveOutlet } = props; const { t } = useTranslation(); const { pathname } = useLocation(); + const items = [ { - icon: ( - <> - - - - - - ), + icon: , iconClassName: classes.portfolioIcon, label: t("navigation.accounts"), to: "/accounts", }, { - icon: ( - <> - - - - - - - - ), + icon: , iconClassName: classes.cardsIcon, label: t("navigation.cards"), to: "/cards", }, { - icon: ( - <> - - - - - ), + icon: , iconClassName: classes.stocksIcon, label: t("navigation.stocks"), to: "/stocks", }, { - icon: ( - <> - - - - - - - - ), + icon: , iconClassName: classes.bondsIcon, label: t("navigation.bonds"), to: "/bonds", }, { - icon: ( - <> - - - - - ), + icon: , iconClassName: classes.settingsIcon, label: t("navigation.settings"), to: "/settings", @@ -105,6 +64,7 @@ const AppShell: FC = (props) => { {t("appName")} + {items.map(item => ( = (props) => { - + {item.icon} )} to={item.to} /> ))} +
{contentAboveOutlet} +
+ ); diff --git a/src/entities/bank-card/api/banking.test.tsx b/src/entities/bank-card/api/banking.test.tsx index f13e8a4..436c37a 100644 --- a/src/entities/bank-card/api/banking.test.tsx +++ b/src/entities/bank-card/api/banking.test.tsx @@ -19,8 +19,10 @@ import { useBankSummary, } from "./queries"; -const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +const card: BankCard + = { archivedAt: null, balance: { currency: "RUB", value: "100" }, bankName: null, @@ -31,8 +33,9 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t openingDate: "2026-09-01", paymentSystem: null, version: 1, - } -/* eslint-enable unicorn/no-null */); + }; + +/* eslint-enable unicorn/no-null */ const transfer: CreateBankOperation = { amount: { currency: "RUB", value: "90071992547409.93" }, cardId: "a", @@ -55,6 +58,7 @@ function readHandlers(counts: Map, categoryName = "Food") { http.get("*/api/v1/bank-cards", ({ request }) => { const key = new URL(request.url).pathname + new URL(request.url).search; counts.set(key, (counts.get(key) ?? 0) + 1); + return HttpResponse.json([card]); }), http.get("*/api/v1/bank-cards/*", ({ request }) => { @@ -84,7 +88,10 @@ function readHandlers(counts: Map, categoryName = "Food") { page: 1, total: 0, }); - return url.pathname.includes("/operations/") ? HttpResponse.json({ ...transfer, version: 1 }) : HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) }); + + return url.pathname.includes("/operations/") + ? HttpResponse.json({ ...transfer, version: 1 }) + : HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) }); }), ]; } @@ -94,15 +101,18 @@ function useReadModels() { const a = useBankCard("a"); const b = useBankCard("b"); const categories = useBankCategories(); + const history = useBankOperations("a", { limit: 20, month: "2026-09", page: 2, }); + const expenses = useBankExpenses("a", { page: 1 }); const september = useBankSummary("2026-09"); const august = useBankSummary("2026-08", "b"); const original = useBankOperation("original"); + return { mutations: useBankMutations(), reads: [ @@ -134,6 +144,7 @@ describe("banking API", () => { expect(url.searchParams.get("month")).toBe("2026-09"); if (isFail && page === 2) return HttpResponse.json({ code: "FAILED" }, { status: 500 }); + return HttpResponse.json({ hasNext: page === 1, items: [{ ...transfer, id: String(page) }], @@ -142,10 +153,12 @@ describe("banking API", () => { }); }), ); + const { result } = renderHook( () => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), { wrapper: createQueryWrapper().wrapper }, ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(pages).toEqual([1, 2]); expect(result.current.data?.items.map(item => item.id)).toEqual([ @@ -153,10 +166,12 @@ describe("banking API", () => { "2", ]); isFail = true; + const { result: failedResult } = renderHook( () => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), { wrapper: createQueryWrapper().wrapper }, ); + await waitFor(() => expect(failedResult.current.isError).toBe(true)); expect(failedResult.current.data).toBeUndefined(); }); @@ -164,15 +179,15 @@ describe("banking API", () => { it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => { const counts = new Map(); server.use(...readHandlers(counts)); + const { result } = renderHook(useReadModels, { wrapper: createQueryWrapper().wrapper, }); + await waitFor(() => expect(result.current.reads.every(read => read.isSuccess)).toBe(true), ); - expect(counts.keys()).toContain( - "/api/v1/bank-cards?includeArchived=true", - ); + expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true"); expect(counts.keys()).toContain( "/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2", ); @@ -184,10 +199,13 @@ describe("banking API", () => { it("replays the exact UUID and large Money string once across shared auth refresh", async () => { let token = "expired"; + const refresh = vi.fn(() => { token = "renewed"; + return Promise.resolve(true); }); + configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh }); const bodies: unknown[] = []; const headers: (null | string)[] = []; @@ -195,14 +213,17 @@ describe("banking API", () => { http.post("*/api/v1/bank-cards/operations", async ({ request }) => { bodies.push(await request.json()); headers.push(request.headers.get("Authorization")); + return bodies.length === 1 ? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }) : HttpResponse.json({ ...transfer, version: 1 }); }), ); + const { result } = renderHook(useBankMutations, { wrapper: createQueryWrapper().wrapper, }); + await act(() => result.current.createOperation.mutateAsync(transfer)); expect(bodies).toEqual([transfer, transfer]); expect(headers).toEqual(["Bearer expired", "Bearer renewed"]); @@ -214,14 +235,17 @@ describe("banking API", () => { server.use( http.post("*/api/v1/bank-cards/operations", async ({ request }) => { bodies.push(await request.json()); + return bodies.length === 1 ? HttpResponse.error() : HttpResponse.json({ ...transfer, version: 1 }); }), ); + const { result } = renderHook(useBankMutations, { wrapper: createQueryWrapper().wrapper, }); + await act(async () => { await expect( result.current.createOperation.mutateAsync(transfer), @@ -239,13 +263,16 @@ describe("banking API", () => { server.use( http.post("*/api/v1/bank-cards/operations", async () => { await pending; + return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 }); }), ); + const { result } = renderHook( () => ({ mutations: useBankMutations(), query: useBankCard("a") }), { wrapper: createQueryWrapper().wrapper }, ); + await waitFor(() => expect(result.current.query.isSuccess).toBe(true)); let saving: Promise; act(() => { @@ -310,10 +337,12 @@ describe("banking API", () => { HttpResponse.json({ code }, { status: 409 }), ), ); + const { result } = renderHook( () => ({ card: useBankCard("a"), mutations: useBankMutations() }), { wrapper: createQueryWrapper().wrapper }, ); + await waitFor(() => expect(result.current.card.data?.version).toBe(1)); await act(async () => { await expect( @@ -337,6 +366,7 @@ describe("banking API", () => { const body = await request.json(); requests.push(body); name = "Groceries"; + return HttpResponse.json({ id: "food", isDefault: true, @@ -348,14 +378,17 @@ describe("banking API", () => { "*/api/v1/bank-cards/operations/original", async ({ request }) => { requests.push(await request.json()); + return HttpResponse.json({ ...transfer, version: 2 }); }, ), http.delete("*/api/v1/bank-cards/operations/original", ({ request }) => { requests.push(new URL(request.url).searchParams.get("version")); + return new HttpResponse(null, { status: 204 }); }), ); + const { result } = renderHook( () => ({ categories: useBankCategories(), @@ -363,6 +396,7 @@ describe("banking API", () => { }), { wrapper: createQueryWrapper().wrapper }, ); + await waitFor(() => expect(result.current.categories.data?.[0].name).toBe("Food"), ); @@ -406,9 +440,11 @@ describe("banking API", () => { ), ); const { queryClient, wrapper } = createQueryWrapper(); + const { rerender, result } = renderHook(() => useBankCard("a"), { wrapper, }); + await waitFor(() => expect(result.current.data?.balance.value).toBe("100")); await apiClient.PUT("/api/v1/tbank-connection", { body: { token: "synthetic" }, diff --git a/src/entities/bank-card/api/mutations.ts b/src/entities/bank-card/api/mutations.ts index cfe0b0d..ea486b1 100644 --- a/src/entities/bank-card/api/mutations.ts +++ b/src/entities/bank-card/api/mutations.ts @@ -1,6 +1,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { apiClient, ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; +import { + apiClient, + ApiErrorCode, + apiErrorCode, + isApiErrorCode, +} from "@/shared/api"; import type { CreateBankCard, @@ -17,21 +22,27 @@ async function mutationData( const { data, error } = await request; // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. if (error) throw error; + return data; } function useBankMutations() { const queryClient = useQueryClient(); + const invalidate = () => queryClient.invalidateQueries({ predicate: ({ queryKey }) => { const [method, path] = queryKey; - return method === "get" + + return ( + method === "get" && typeof path === "string" && (path === "/api/v1/bank-cards" - || path.startsWith("/api/v1/bank-cards/")); + || path.startsWith("/api/v1/bank-cards/")) + ); }, }); + const options = { onError: (error: unknown) => { const errorCode = apiErrorCode(error); @@ -49,11 +60,13 @@ function useBankMutations() { onSuccess: invalidate, retry: false, } as const; + const cardCreation = useMutation({ ...options, mutationFn: (body: CreateBankCard) => mutationData(apiClient.POST("/api/v1/bank-cards", { body })), }); + const updateCard = useMutation({ ...options, mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) => @@ -64,11 +77,13 @@ function useBankMutations() { }), ), }); + const operationCreation = useMutation({ ...options, mutationFn: (body: CreateBankOperation) => mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })), }); + const updateOperation = useMutation({ ...options, mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) => @@ -79,6 +94,7 @@ function useBankMutations() { }), ), }); + const operationDeletion = useMutation({ ...options, mutationFn: ({ id, version }: { id: string; version: number }) => @@ -88,11 +104,13 @@ function useBankMutations() { }), ), }); + const categoryCreation = useMutation({ ...options, mutationFn: (body: CreateExpenseCategory) => mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })), }); + const updateCategory = useMutation({ ...options, mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) => @@ -103,6 +121,7 @@ function useBankMutations() { }), ), }); + return { createCard: cardCreation, createCategory: categoryCreation, diff --git a/src/entities/bank-card/api/queries.ts b/src/entities/bank-card/api/queries.ts index 2a6a427..e97e28a 100644 --- a/src/entities/bank-card/api/queries.ts +++ b/src/entities/bank-card/api/queries.ts @@ -9,24 +9,34 @@ import { moneyCents } from "../model/form-validation"; type BankCard = paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"]; + type BankOperation = paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"]; + type BankOperations = paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"]; + type BankSummary = paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"]; + type CreateBankCard = paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"]; + type CreateBankOperation = paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"]; + type CreateExpenseCategory = paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"]; + type ExpenseCategory = paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number]; + type UpdateBankCard = paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"]; + type UpdateBankOperation = paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"]; + type UpdateExpenseCategory = paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"]; @@ -41,12 +51,13 @@ function sortBankSummary(data: BankSummary): BankSummary { if (left === null || right === null) throw new Error("Invalid bank summary amount"); if (left > right) return -1; + return left < right ? 1 : 0; }), }; } -function useBankCard(id: string | undefined) { +function useBankCard(id?: string) { return useAuthenticatedQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}", { params: { path: { id: id ?? "" } }, @@ -83,7 +94,7 @@ function useBankExpenses( }); } -function useBankOperation(id: string | undefined) { +function useBankOperation(id?: string) { return useAuthenticatedQuery({ ...apiQueryClient.queryOptions( "get", @@ -105,6 +116,7 @@ function useBankOperations( }, ) { const isAll = query.limit === "all"; + const parameters = { path: { id: id ?? "" }, query: { @@ -113,11 +125,13 @@ function useBankOperations( page: isAll ? 1 : query.page, }, }; + const options = apiQueryClient.queryOptions( "get", "/api/v1/bank-cards/{id}/operations", { params: parameters }, ); + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity. return useAuthenticatedQuery({ enabled: Boolean(id && (query.month || (query.from && query.to))), @@ -126,6 +140,7 @@ function useBankOperations( previousQuery, ) => { const previous = previousQuery?.queryKey[2]?.params; + return !isAll && previousQuery?.queryKey.length === options.queryKey.length && previous?.path?.id === id @@ -138,6 +153,7 @@ function useBankOperations( }, queryFn: async ({ signal }) => { const items: BankOperation[] = []; + for (let page = parameters.query.page; ; page++) { const { data, error } = await apiClient.GET( "/api/v1/bank-cards/{id}/operations", @@ -146,6 +162,7 @@ function useBankOperations( signal, }, ); + // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. if (error) throw error; if (!data) throw new Error("Missing bank operations response"); @@ -154,18 +171,22 @@ function useBankOperations( if (!data.hasNext) return { ...data, hasNext: false, items, page: 1 }; } }, - queryKey: isAll ? ([...options.queryKey, "all"] as const) : options.queryKey, + queryKey: isAll + ? ([...options.queryKey, "all"] as const) + : options.queryKey, }); } function useBankSummary( - period: string | { from: string; to: string }, + period: string + | { from: string; to: string }, cardId?: string, ) { const query = typeof period === "string" ? { cardId, month: period } : { ...period, cardId }; + return useAuthenticatedQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", { params: { query }, diff --git a/src/entities/bank-card/api/summary.test.ts b/src/entities/bank-card/api/summary.test.ts index 76fbe67..2175a8c 100644 --- a/src/entities/bank-card/api/summary.test.ts +++ b/src/entities/bank-card/api/summary.test.ts @@ -28,6 +28,7 @@ it("orders displayed expenses descending, preserving cents, ties and source data net: money("0"), refunds: money("0"), }; + expect(sortBankSummary(data).categories.map(row => row.categoryId)).toEqual( ["3", "1", "2", "5", "4", "0"], ); diff --git a/src/entities/bank-card/index.ts b/src/entities/bank-card/index.ts index a2c908a..ee2aca1 100644 --- a/src/entities/bank-card/index.ts +++ b/src/entities/bank-card/index.ts @@ -10,21 +10,16 @@ export { useBankOperations, useBankSummary, } from "./api/queries"; -export { - bankDateInputValue, - bankDateSchema, - bankMoneyValue, - bankToday, - moneyCents, - positiveMoneySchema, -} from "./model/form-validation"; +export { bankDateInputValue, bankMoneyValue, bankToday, moneyCents } from "./model/form-validation"; +export { bankDateSchema, positiveMoneySchema } from "./model/form.schema"; + +export { bankOperationAppearance } from "./model/operation-appearance"; export { bankOperationKinds } from "./model/operation-kinds"; export { bankPeriod } from "./model/period"; export { BankCardFace } from "./ui/BankCardFace/BankCardFace"; export { BankCardForm } from "./ui/BankCardForm/BankCardForm"; export { BankFeedback } from "./ui/BankFeedback"; -export { BankOperationIcon } from "./ui/BankOperationIcon/BankOperationIcon"; -export { BankPeriodPicker } from "./ui/BankPeriodPicker"; +export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker"; export { BankSummaryView } from "./ui/BankSummary/BankSummary.view"; export { CategoryManager } from "./ui/CategoryManager/CategoryManager"; export { useBankFormFocus } from "./ui/useBankFormFocus"; diff --git a/src/entities/bank-card/model/category.schema.ts b/src/entities/bank-card/model/category.schema.ts new file mode 100644 index 0000000..f485f91 --- /dev/null +++ b/src/entities/bank-card/model/category.schema.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +import { validationMessage } from "@/shared/i18n"; + +import type { ExpenseCategory } from "../api/queries"; +const MAX_CATEGORY_NAME_LENGTH = 80; + +function categorySchema(categories?: ExpenseCategory[], editingId?: string) { + return z.object({ + name: z + .string() + .trim() + .refine( + value => value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH, + { error: validationMessage("bank.validation.category") }, + ) + .refine( + value => + !categories?.some( + c => + c.id !== editingId + && c.name.toLocaleLowerCase("ru-RU") + === value.toLocaleLowerCase("ru-RU"), + ), + { error: validationMessage("bank.validation.duplicate") }, + ), + }); +} + +export { categorySchema }; diff --git a/src/entities/bank-card/model/form-validation.test.ts b/src/entities/bank-card/model/form-validation.test.ts index 3545479..a99a3c5 100644 --- a/src/entities/bank-card/model/form-validation.test.ts +++ b/src/entities/bank-card/model/form-validation.test.ts @@ -1,14 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { - bankCardSchema, - bankDateInputValue, - bankDateSchema, - bankMoneyValue, - bankMonth, - bankToday, - positiveMoneySchema, -} from "./form-validation"; +import { bankDateInputValue, bankMoneyValue, bankMonth, bankToday } from "./form-validation"; +import { bankCardSchema, bankDateSchema, positiveMoneySchema } from "./form.schema"; afterEach(() => vi.useRealTimers()); describe("bank form input", () => { @@ -16,6 +9,7 @@ describe("bank form input", () => { expect(positiveMoneySchema.safeParse("92233720368547758.07").success).toBe( true, ); + for (const invalid of [ "92233720368547758.08", "1.001", @@ -26,7 +20,9 @@ describe("bank form input", () => { ]) { expect(positiveMoneySchema.safeParse(invalid).success).toBe(false); } + expect(bankMoneyValue(" -00010,20 ")).toBe("-10.20"); + const values = { bankName: "", lastFour: "", @@ -34,6 +30,7 @@ describe("bank form input", () => { openingBalance: "-92233720368547758.08", openingDate: "2026-01-01", }; + expect(bankCardSchema.safeParse(values).success).toBe(true); expect( bankCardSchema.safeParse({ @@ -62,6 +59,7 @@ it("converts local date input without accepting impossible or unfinished dates", expect( bankDateSchema.safeParse(bankDateInputValue("29.02.2024")).success, ).toBe(true); + for (const value of ["31.02.2026", "07.08.", ""]) { expect(bankDateSchema.safeParse(bankDateInputValue(value)).success).toBe( false, diff --git a/src/entities/bank-card/model/form-validation.ts b/src/entities/bank-card/model/form-validation.ts index 4a201bc..0d6415a 100644 --- a/src/entities/bank-card/model/form-validation.ts +++ b/src/entities/bank-card/model/form-validation.ts @@ -1,28 +1,25 @@ -import { z } from "zod"; - -import { validationMessage } from "@/shared/i18n"; - const DECIMAL_PART_COUNT = 2; const CENTS_PRECISION = 2; -const ISO_DATE_LENGTH = 10; const ISO_MONTH_LENGTH = 7; -const MAX_CARD_NAME_LENGTH = 100; function bankDateInputValue(value: string) { return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1"); } + function bankMoneyValue(value: string) { return value .trim() .replace(",", ".") .replace(/^(-?)0+(?=\d)/, "$1"); } + function bankMonth(value: null | string) { return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value) ? value : bankToday().slice(0, ISO_MONTH_LENGTH); } + function bankToday() { return new Intl.DateTimeFormat("en-CA", { day: "2-digit", @@ -31,84 +28,21 @@ function bankToday() { year: "numeric", }).format(new Date()); } + function moneyCents(value: string): bigint | null { const normalized = value.trim().replace(",", "."); - if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized)) return /* eslint-disable-line unicorn/no-null -- The money parser’s callers distinguish invalid input with null. */ null; - const [whole, fraction = ""] = normalized.replace("-", "").split(".", DECIMAL_PART_COUNT); + if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized)) + // eslint-disable-next-line unicorn/no-null -- The money parser’s callers distinguish invalid input with null. + return null; + + const [whole, fraction = ""] = normalized + .replace("-", "") + .split(".", DECIMAL_PART_COUNT); + return ( BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`) * (normalized.startsWith("-") ? -1n : 1n) ); } -const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; -const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; -const moneySchema = z.string().refine( - (value) => { - const cents = moneyCents(value); - return ( - cents !== null - && cents >= MIN_BALANCE_KOPECKS - && cents <= MAX_BALANCE_KOPECKS - ); - }, - { error: validationMessage("bank.validation.money") }, -); -const positiveMoneySchema = moneySchema.refine( - (value) => { - const cents = moneyCents(value); - return cents === null || cents > 0n; - }, - { error: validationMessage("bank.validation.positive") }, -); -const bankDateSchema = z - .string() - .refine( - value => - /^\d{4}-\d{2}-\d{2}$/.test(value) - && value >= "0001-01-01" - && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)) - && new Date(`${value}T00:00:00Z`).toISOString().slice(0, ISO_DATE_LENGTH) === value - && value <= bankToday(), - { error: validationMessage("bank.validation.date") }, - ); -const bankCardSchema = z.object({ - bankName: z - .string() - .trim() - .max( - MAX_CARD_NAME_LENGTH, - validationMessage("bank.validation.bankName")(undefined), - ), - lastFour: z.string().refine(value => !value || /^\d{4}$/.test(value), { - error: validationMessage("bank.validation.lastFour"), - }), - name: z - .string() - .trim() - .min(1, validationMessage("bank.validation.name")(undefined)) - .max( - MAX_CARD_NAME_LENGTH, - validationMessage("bank.validation.name")(undefined), - ), - openingBalance: moneySchema, - openingDate: bankDateSchema, - paymentSystem: z - .enum(["", "mir", "visa", "mastercard"], { - error: validationMessage("bank.validation.paymentSystem"), - }) - .optional(), -}); -type BankCardValues = z.infer; - -export { - bankCardSchema, - type BankCardValues, - bankDateInputValue, - bankDateSchema, - bankMoneyValue, - bankMonth, - bankToday, - moneyCents, - positiveMoneySchema, -}; +export { bankDateInputValue, bankMoneyValue, bankMonth, bankToday, moneyCents }; diff --git a/src/entities/bank-card/model/form.schema.ts b/src/entities/bank-card/model/form.schema.ts new file mode 100644 index 0000000..3d4346b --- /dev/null +++ b/src/entities/bank-card/model/form.schema.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; + +import { validationMessage } from "@/shared/i18n"; + +import { bankToday, moneyCents } from "./form-validation"; +const ISO_DATE_LENGTH = 10; +const MAX_CARD_NAME_LENGTH = 100; +const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; +const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; + +const moneySchema = z.string().refine( + (value) => { + const cents = moneyCents(value); + + return ( + cents !== null + && cents >= MIN_BALANCE_KOPECKS + && cents <= MAX_BALANCE_KOPECKS + ); + }, + { error: validationMessage("bank.validation.money") }, +); + +const positiveMoneySchema = moneySchema.refine( + (value) => { + const cents = moneyCents(value); + + return cents === null || cents > 0n; + }, + { error: validationMessage("bank.validation.positive") }, +); + +const bankDateSchema = z + .string() + .refine( + value => + /^\d{4}-\d{2}-\d{2}$/.test(value) + && value >= "0001-01-01" + && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)) + && new Date(`${value}T00:00:00Z`).toISOString().slice(0, ISO_DATE_LENGTH) + === value + && value <= bankToday(), + { error: validationMessage("bank.validation.date") }, + ); + +const bankCardSchema = z.object({ + bankName: z + .string() + .trim() + .max( + MAX_CARD_NAME_LENGTH, + validationMessage("bank.validation.bankName")(undefined), + ), + lastFour: z.string().refine(value => !value || /^\d{4}$/.test(value), { + error: validationMessage("bank.validation.lastFour"), + }), + name: z + .string() + .trim() + .min(1, validationMessage("bank.validation.name")(undefined)) + .max( + MAX_CARD_NAME_LENGTH, + validationMessage("bank.validation.name")(undefined), + ), + openingBalance: moneySchema, + openingDate: bankDateSchema, + paymentSystem: z + .enum(["", "mir", "visa", "mastercard"], { + error: validationMessage("bank.validation.paymentSystem"), + }) + .optional(), +}); + +type BankCardValues = z.infer; + +export { + bankCardSchema, + type BankCardValues, + bankDateSchema, + positiveMoneySchema, +}; diff --git a/src/entities/bank-card/model/operation-appearance.ts b/src/entities/bank-card/model/operation-appearance.ts index e13f17a..b54e169 100644 --- a/src/entities/bank-card/model/operation-appearance.ts +++ b/src/entities/bank-card/model/operation-appearance.ts @@ -1,124 +1,132 @@ +import type { OperationIconProps } from "@wealthledger/icons"; + import type { BankOperation } from "../api/queries"; -type OperationIcon = { animation?: string; color: string; path: string }; +type OperationIcon = { + animation?: string; + color: string; + variant: OperationIconProps["variant"]; +}; const categoryIcons: Record = { "алкоголь": { animation: "wine", color: "grape", - path: "M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10v1a5 5 0 0 1-10 0V7Z M12 13v8 M8 21h8", + variant: "wine", }, "вклады": { animation: "deposit", color: "green", - path: "M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0 M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3 M4 16c0 4 16 4 16 0 M4 19c0 4 16 4 16 0", + variant: "deposit", }, - "жилье": { color: "orange", path: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8" }, - "здоровье": { color: "red", path: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z" }, + "жилье": { color: "orange", variant: "home" }, + "здоровье": { color: "red", variant: "medical" }, "инвестиции и накопления": { animation: "growth", color: "green", - path: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5", + variant: "growth", }, "кафе": { animation: "coffee", color: "orange", - path: "M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z M16 9h2a3 3 0 0 1 0 6h-2 M8 6c-2-1 2-2 0-4 M12 6c-2-1 2-2 0-4 M3 22h15", + variant: "coffee", }, "образование": { color: "blue", - path: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9", + variant: "education", }, "переводы": { animation: "transfer", color: "blue", - path: "M3 7h18l-4-4 M21 17H3l4 4", + variant: "transfer", }, "погашение кредита": { animation: "repayment", color: "orange", - path: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4", + variant: "repayment", }, "подписки": { color: "grape", - path: "M4 10a8 8 0 0 1 13-6l3 3 M20 3v4h-4 M20 14a8 8 0 0 1-13 6l-3-3 M4 21v-4h4", + variant: "subscriptions", }, "покупки": { color: "grape", - path: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2", + variant: "shopping", }, "продукты": { animation: "basket", color: "green", - path: "M3 9h18l-2 11H5L3 9Z M8 9l4-6 4 6 M9 13v3 M15 13v3 M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0", + variant: "basket", }, "путешествия": { color: "blue", - path: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z", + variant: "travel", }, "развлечения": { color: "grape", - path: "M9 5v14a3 3 0 1 1-3-3h3 M9 5l11-2v14a3 3 0 1 1-3-3h3 M9 9l11-2", + variant: "music", }, "связь и интернет": { animation: "wifi", color: "blue", - path: "M3 7a14 14 0 0 1 18 0 M6 11a9 9 0 0 1 12 0 M9 15a4 4 0 0 1 6 0 M12 19h.01", + variant: "wifi", }, "снятие наличных": { color: "green", - path: "M4 10V3h16v7 M7 7h10v14H7V7Z M7 11h10 M10 16h4", + variant: "cashWithdrawal", }, "ставки": { color: "grape", - path: "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z M7 7h.01 M17 7h.01 M12 12h.01 M7 17h.01 M17 17h.01", + variant: "dice", }, "страхование": { color: "green", - path: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6", + variant: "shield", }, "табак": { color: "orange", - path: "M3 14h18v5H3v-5Z M16 14v5 M8 10V8c0-2 3-2 3-4V2 M15 10V8c0-2 3-2 3-4", + variant: "smoking", }, "такси": { color: "yellow", - path: "M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2", + variant: "taxi", }, "транспорт": { animation: "car", color: "blue", - path: "M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2 M8 16h8 M6 11h12m-6-4v4 M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4 M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4", + variant: "car", }, "хостинг и ии-сервисы": { animation: "server", color: "grape", - path: "M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3", + variant: "server", }, "цифровые покупки": { color: "blue", - path: "M3 3h18v13H3V3Z M8 21h8 M12 16v5 M12 6v7 M9 10l3 3 3-3", + variant: "download", }, }; + const otherIcon: OperationIcon = { animation: "grid", color: "gray", - path: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z", + variant: "grid", }; + const operationIcons: Record< Exclude, OperationIcon > = { - refund: { color: "teal", path: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14" }, + refund: { color: "teal", variant: "refund" }, topup: { animation: "topup", color: "green", - path: "M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5m-2.5-2.5h5", + variant: "topup", }, transfer: { animation: "transfer", color: "blue", - path: "M3 7h18l-4-4 M21 17H3l4 4", + variant: "transfer", }, }; @@ -126,12 +134,18 @@ function bankOperationAppearance( kind: BankOperation["kind"], categoryName?: null | string, ) { - if (kind !== "expense") return /* eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn. */ operationIcons[kind]; + if (kind !== "expense") + // eslint-disable-next-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn. + return operationIcons[kind]; + const name = categoryName ?.trim() .toLocaleLowerCase("ru-RU") .replaceAll("ё", "е"); - return name && Object.hasOwn(categoryIcons, name) ? /* eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn. */ categoryIcons[name] : otherIcon; + + return name && Object.hasOwn(categoryIcons, name) + ? categoryIcons[name] // eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn. + : otherIcon; } export { bankOperationAppearance }; diff --git a/src/entities/bank-card/model/period.test.ts b/src/entities/bank-card/model/period.test.ts index 343ec53..bb8de22 100644 --- a/src/entities/bank-card/model/period.test.ts +++ b/src/entities/bank-card/model/period.test.ts @@ -17,6 +17,7 @@ it.each([ it("rejects zero year consistently in legacy and explicit periods", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-09-27T10:00:00Z")); + for (const query of ["month=0000-01", "from=0000-01-01&to=0000-01-31"]) { expect(bankPeriod(new URLSearchParams(query))).toEqual({ from: "2026-09-01", diff --git a/src/entities/bank-card/model/period.ts b/src/entities/bank-card/model/period.ts index bd099e9..93cba2b 100644 --- a/src/entities/bank-card/model/period.ts +++ b/src/entities/bank-card/model/period.ts @@ -1,15 +1,16 @@ -import { z } from "zod"; +import { calendarDate } from "@/shared/lib"; import { bankMonth } from "./form-validation"; -const calendarDate = z.iso.date(); const ISO_DATE_LENGTH = 10; + function bankPeriod(parameters: URLSearchParams) { const month = bankMonth(parameters.get("month")); const monthEnd = new Date(`${month}-01T00:00:00Z`); monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1, 0); const from = parameters.get("from"); const to = parameters.get("to"); + return from && to && calendarDate.safeParse(from).success diff --git a/src/entities/bank-card/ui/BankCardFace/BankCardFace.stories.tsx b/src/entities/bank-card/ui/BankCardFace/BankCardFace.stories.tsx index 99cf1c9..d1b56df 100644 --- a/src/entities/bank-card/ui/BankCardFace/BankCardFace.stories.tsx +++ b/src/entities/bank-card/ui/BankCardFace/BankCardFace.stories.tsx @@ -4,53 +4,66 @@ import { SimpleGrid } from "@mantine/core"; import { BankCardFace } from "./BankCardFace"; -const meta = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ -{ - args: { - card: { - archivedAt: null, - balance: { currency: "RUB", value: "128450.75" }, - bankName: "Сбербанк", - id: "fictional", - lastFour: "4829", - name: "Для повседневных покупок", - openingBalance: { currency: "RUB", value: "0" }, - openingDate: "2026-01-01", - paymentSystem: "mir", - version: 1, +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +const meta + = { + args: { + card: { + archivedAt: null, + balance: { currency: "RUB", value: "128450.75" }, + bankName: "Сбербанк", + id: "fictional", + lastFour: "4829", + name: "Для повседневных покупок", + openingBalance: { currency: "RUB", value: "0" }, + openingDate: "2026-01-01", + paymentSystem: "mir", + version: 1, + }, }, - }, - component: BankCardFace, - tags: ["autodocs"], - title: "Product/BankCards/CardAppearance", -} -/* eslint-enable unicorn/no-null */) satisfies Meta; + component: BankCardFace, + tags: ["autodocs"], + title: "Product/BankCards/CardAppearance", + } satisfies /* eslint-enable unicorn/no-null */ Meta; + export default meta; + type Story = StoryObj; + export const Banks: Story = { render: arguments_ => ( - {["Сбербанк", "Т-Банк", "Альфа-Банк", "ВТБ", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null].map( - (bankName, index) => ( - ( + - ), - )} + } + /* eslint-enable unicorn/no-null */ + } + key={bankName ?? "unknown"} + /> + ))} ), }; -export const MissingDetails: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ +export const MissingDetails: Story + = { args: { card: { ...meta.args.card, @@ -59,8 +72,8 @@ export const MissingDetails: Story = (/* eslint-disable unicorn/no-null -- Fixtu paymentSystem: null, }, }, - } -/* eslint-enable unicorn/no-null */); + }; +/* eslint-enable unicorn/no-null */ export const LongArchived: Story = { args: { card: { diff --git a/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx b/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx index 2788fc2..baeadd2 100644 --- a/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx +++ b/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx @@ -1,5 +1,6 @@ import type { FC } from "react"; +import { ContactlessIcon } from "@wealthledger/icons"; import { useTranslation } from "react-i18next"; import { formatMoney } from "@/shared/lib"; @@ -9,51 +10,46 @@ import type { BankCard } from "../../api/queries"; import classes from "./BankCardFace.module.css"; type BankCardFaceProps = { card: BankCard }; + const BankCardFace: FC = (props) => { const { card } = props; const { t } = useTranslation(); const bank = card.bankName?.toLocaleLowerCase("ru") ?? ""; const tone = bankTone(bank); + return (
{card.bankName || t("bank.card")} + {card.archivedAt && ( {t("bank.archived")} )}
+ -
+ +
{`•••• •••• •••• ${card.lastFour || "••••"}`}
+
{card.name}
+
{formatMoney(card.balance)}
+ {card.paymentSystem && ( {card.paymentSystem === "mastercard" && ( )} @@ -66,6 +62,7 @@ function bankTone(bank: string) { if (/сбер|sber/.test(bank)) return "green"; if (/тинькофф|т[ -]?банк|t[ -]?bank|tinkoff/.test(bank)) return "yellow"; if (/альфа|alfa/.test(bank)) return "red"; + return /втб|vtb|газпром|gazprom/.test(bank) ? "blue" : "graphite"; } diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts b/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts new file mode 100644 index 0000000..2823136 --- /dev/null +++ b/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts @@ -0,0 +1,76 @@ +import type { ComponentProps } from "react"; + +import { DateInput } from "@mantine/dates"; +import { useController, useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import { bankDateInputValue } from "../../model/form-validation"; +import { type BankCardValues, bankDateSchema } from "../../model/form.schema"; + +function useBankCardFieldsVm() { + const { + control, + formState: { errors }, + register, + } = useFormContext(); + + const { t } = useTranslation(); + + const { field: paymentSystemField } = useController({ + control, + name: "paymentSystem", + }); + + const { field: openingDateField } = useController({ + control, + name: "openingDate", + }); + + const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({ + label: t(`bank.paymentSystems.${value}`), + value, + })); + + const onPaymentSystemChange: (value: null | string) => void = value => + paymentSystemField.onChange(value ?? ""); + + const dateParser: ComponentProps["dateParser"] = value => + bankDateSchema.safeParse(bankDateInputValue(value)).success + ? bankDateInputValue(value) + : null; // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled openingDateField as null. + + const onOpeningDateBlur: ComponentProps["onBlur"] = ( + event, + ) => { + openingDateField.onChange(bankDateInputValue(event.currentTarget.value)); + openingDateField.onBlur(); + }; + + const onOpeningDateChange: (value: null | string) => void = value => + openingDateField.onChange(value ?? ""); + + const onOpeningDateInput: ComponentProps["onInput"] = event => + openingDateField.onChange(bankDateInputValue(event.currentTarget.value)); + + const openingDateValue = bankDateSchema.safeParse(openingDateField.value) + .success + ? openingDateField.value + : undefined; + + return { + dateParser, + errors, + onOpeningDateBlur, + onOpeningDateChange, + onOpeningDateInput, + onPaymentSystemChange, + openingDateField, + openingDateValue, + paymentSystemField, + paymentSystems, + register, + t, + }; +} + +export { useBankCardFieldsVm }; diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.stories.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.stories.tsx index ff12e6a..3bc5e06 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.stories.tsx +++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { FormProvider, useForm } from "react-hook-form"; -import type { BankCardValues } from "../../model/form-validation"; +import type { BankCardValues } from "../../model/form.schema"; import { BankCardFields } from "./BankCardForm.view"; @@ -20,6 +20,7 @@ const meta = { openingDate: "2026-08-01", }, }); + return ( @@ -38,8 +39,11 @@ const meta = { tags: ["autodocs"], title: "Product/BankCards/CardFields", } satisfies Meta; + export default meta; + type Story = StoryObj; + export const Active: Story = { name: "Независимая карта в рублях" }; export const Archived: Story = { args: { archived: true }, diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx index 5692bf3..6ced133 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx +++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx @@ -12,8 +12,10 @@ import type { BankCard } from "../../api/queries"; import { BankCardForm } from "./BankCardForm"; -const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +const card: BankCard + = { archivedAt: null, balance: { currency: "RUB", value: "50" }, bankName: "Банк", @@ -24,8 +26,9 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t openingDate: "2026-01-01", paymentSystem: null, version: 1, - } -/* eslint-enable unicorn/no-null */); + }; + +/* eslint-enable unicorn/no-null */ function setup(value: BankCard) { configureAuthClient({ accessToken: () => "test", @@ -41,8 +44,10 @@ function setup(value: BankCard) { , ); + return close; } + describe("BankCardForm", () => { it("waits for the exit transition before notifying the parent", async () => { const close = setup(card); @@ -64,20 +69,23 @@ describe("BankCardForm", () => { server.use( http.patch("*/api/v1/bank-cards/a", async ({ request }) => { body = await request.json(); + return HttpResponse.json(card); }), ); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(close).toHaveBeenCalled()); - expect(body).toEqual((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + expect(body).toEqual( { bankName: null, lastFour: null, name: "Основная", paymentSystem: null, version: 1, - } - /* eslint-enable unicorn/no-null */)); + }, + /* eslint-enable unicorn/no-null */ + ); }); it("loads and saves the selected payment system", async () => { const close = setup({ ...card, paymentSystem: "mir" }); @@ -92,6 +100,7 @@ describe("BankCardForm", () => { server.use( http.patch("*/api/v1/bank-cards/a", async ({ request }) => { body = await request.json(); + return HttpResponse.json({ ...card, paymentSystem: "visa" }); }), ); @@ -132,6 +141,7 @@ describe("BankCardForm", () => { const body = (await request.json()) as Record; bodies.push(body); version = 2; + return body.version === 1 ? HttpResponse.json( { code: "BANK_VERSION_CONFLICT" }, @@ -146,9 +156,11 @@ describe("BankCardForm", () => { fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await screen.findByText(/Запись уже изменена/); fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" })); + const adopt = await screen.findByRole("button", { name: "Использовать актуальную версию 2", }); + expect(screen.getByLabelText(/Название карты/)).toHaveValue("Мой черновик"); fireEvent.click(adopt); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx index a7e30db..493dc8d 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx +++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx @@ -1,121 +1,61 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button, Group, Modal, Stack, Text } from "@mantine/core"; -import { type FC, useState } from "react"; -import { FormProvider, useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; +import { type FC } from "react"; +import { FormProvider } from "react-hook-form"; import { formatDate } from "@/shared/lib"; -import { useBankMutations } from "../../api/mutations"; -import { type BankCard, useBankCard } from "../../api/queries"; -import { - bankCardSchema, - type BankCardValues, - bankMoneyValue, - bankToday, -} from "../../model/form-validation"; +import { type BankCard } from "../../api/queries"; import { BankFeedback } from "../BankFeedback"; import classes from "../BankForms.module.css"; -import { useBankFormFocus } from "../useBankFormFocus"; import { BankCardFields } from "./BankCardForm.view"; +import { useBankCardFormVm } from "./BankCardForm.vm"; type BankCardFormProps = { card?: BankCard; onClose: () => void }; + const BankCardForm: FC = (props) => { - useBankFormFocus(); - const [opened, setOpened] = useState(true); - const { t } = useTranslation(); - const [intent] = useState(() => crypto.randomUUID()); - const [version, setVersion] = useState(props.card?.version ?? 1); - const [error, setError] = useState(); - const [latest, setLatest] = useState(); - const fresh = useBankCard(props.card?.id); - const mutation = useBankMutations(); - const methods = useForm({ - defaultValues: { - bankName: props.card?.bankName ?? "", - lastFour: props.card?.lastFour ?? "", - name: props.card?.name ?? "", - openingBalance: props.card?.openingBalance.value ?? "0", - openingDate: props.card?.openingDate ?? bankToday(), - paymentSystem: props.card?.paymentSystem ?? "", - }, - resolver: zodResolver(bankCardSchema), - }); - const isPending - = !opened - || methods.formState.isSubmitting - || (fresh.isFetching && Boolean(error)); + const { + dialogTitle, + error, + isPending, + latest, + methods, + onAdoptVersion, + onCancel, + onReloadRecord, + onRequestClose, + onSubmit, + opened, + t, + } = useBankCardFormVm(props); + return ( !isPending && setOpened(false)} + onClose={onRequestClose} onExitTransitionEnd={props.onClose} opened={opened} - title={t(props.card ? "bank.editCard" : "bank.addCard")} + title={dialogTitle} > -
{ - setError(undefined); - try { - const metadata = { - bankName: values.bankName || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null, - lastFour: values.lastFour || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null, - name: values.name, - paymentSystem: values.paymentSystem || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null, - }; - const financial = { - openingBalance: { - currency: "RUB" as const, - value: bankMoneyValue(values.openingBalance), - }, - openingDate: values.openingDate, - }; - if (props.card) - await mutation.updateCard.mutateAsync({ - body: { - ...metadata, - ...(!props.card.archivedAt && financial), - version, - }, - id: props.card.id, - }); - else - await mutation.createCard.mutateAsync({ - id: intent, - ...metadata, - ...financial, - }); - setOpened(false); - } - catch (error_) { - setError(error_); - } - })} - > +
- {!!(error) && (( + + {!!error && ( <> + {props.card && ( - )} - ))} + )} + {latest && ( <> @@ -123,23 +63,19 @@ const BankCardForm: FC = (props) => { details: `${latest.name} · ${latest.openingBalance.value} RUB · ${formatDate(latest.openingDate)}`, })} - )} + - diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx index 75c1ad5..288f3a2 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx +++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx @@ -2,24 +2,28 @@ import type { FC } from "react"; import { Select, Stack, Text, TextInput } from "@mantine/core"; import { DateInput } from "@mantine/dates"; -import { Controller, useFormContext } from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import { - type BankCardValues, - bankDateInputValue, - bankDateSchema, - bankToday, -} from "../../model/form-validation"; +import { bankToday } from "../../model/form-validation"; +import { useBankCardFieldsVm } from "./BankCardFields.vm"; type BankCardFieldsProps = { archived: boolean }; + const BankCardFields: FC = (props) => { const { - control, - formState: { errors }, + dateParser, + errors, + onOpeningDateBlur, + onOpeningDateChange, + onOpeningDateInput, + onPaymentSystemChange, + openingDateField, + openingDateValue, + paymentSystemField, + paymentSystems, register, - } = useFormContext(); - const { t } = useTranslation(); + t, + } = useBankCardFieldsVm(); + return ( = (props) => { {...register("name")} error={errors.name?.message} /> + + - ( - + = (props) => { {...register("openingBalance")} error={errors.openingBalance?.message} /> - ( - - bankDateSchema.safeParse(bankDateInputValue(value)).success - ? bankDateInputValue(value) - : /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null} - error={errors.openingDate?.message} - fixOnBlur={false} - label={t("bank.openingDate")} - maxDate={bankToday()} - onBlur={(event) => { - field.onChange(bankDateInputValue(event.currentTarget.value)); - field.onBlur(); - }} - onChange={value => field.onChange(value ?? "")} - onInput={event => - field.onChange(bankDateInputValue(event.currentTarget.value))} - popoverProps={{ - middlewares: { shift: { crossAxis: true } }, - withinPortal: false, - }} - readOnly={props.archived} - required - value={ - (bankDateSchema.safeParse(field.value).success) ? (field.value) : undefined - } - valueFormat="DD.MM.YYYY" - /> - )} + + + {t("bank.openingHint")} + {props.archived && {t("bank.archiveHint")}} ); diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts b/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts new file mode 100644 index 0000000..a0745e2 --- /dev/null +++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts @@ -0,0 +1,127 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import { useBankMutations } from "../../api/mutations"; +import { type BankCard, useBankCard } from "../../api/queries"; +import { bankMoneyValue, bankToday } from "../../model/form-validation"; +import { bankCardSchema, type BankCardValues } from "../../model/form.schema"; +import { useBankFormFocus } from "../useBankFormFocus"; + +type BankCardFormProps = { card?: BankCard; onClose: () => void }; + +function useBankCardFormVm(props: BankCardFormProps) { + useBankFormFocus(); + const [opened, setOpened] = useState(true); + const { t } = useTranslation(); + const [intent] = useState(() => crypto.randomUUID()); + const [version, setVersion] = useState(props.card?.version ?? 1); + const [error, setError] = useState(); + const [latest, setLatest] = useState(); + const fresh = useBankCard(props.card?.id); + const mutation = useBankMutations(); + + const methods = useForm({ + defaultValues: { + bankName: props.card?.bankName ?? "", + lastFour: props.card?.lastFour ?? "", + name: props.card?.name ?? "", + openingBalance: props.card?.openingBalance.value ?? "0", + openingDate: props.card?.openingDate ?? bankToday(), + paymentSystem: props.card?.paymentSystem ?? "", + }, + resolver: zodResolver(bankCardSchema), + }); + + const isPending + = !opened + || methods.formState.isSubmitting + || (fresh.isFetching && Boolean(error)); + + const onRequestClose = () => !isPending && setOpened(false); + const dialogTitle = t(props.card ? "bank.editCard" : "bank.addCard"); + + const onSubmit = methods.handleSubmit(async (values) => { + setError(undefined); + + try { + const metadata = { + bankName: + values.bankName + // eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields. + || null, + lastFour: + values.lastFour + // eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields. + || null, + name: values.name, + paymentSystem: + values.paymentSystem + // eslint-disable-next-line unicorn/no-null -- PATCH uses null to clear these optional card fields. + || null, + }; + + const financial = { + openingBalance: { + currency: "RUB" as const, + value: bankMoneyValue(values.openingBalance), + }, + openingDate: values.openingDate, + }; + + if (props.card) + await mutation.updateCard.mutateAsync({ + body: { + ...metadata, + ...(!props.card.archivedAt && financial), + version, + }, + id: props.card.id, + }); + else + await mutation.createCard.mutateAsync({ + id: intent, + ...metadata, + ...financial, + }); + setOpened(false); + } + catch (error_) { + setError(error_); + } + }); + + const onReloadRecord = async () => { + const response = await fresh.refetch(); + if (response.data && !response.error) setLatest(response.data); + else setError(response.error); + }; + + const onAdoptVersion = () => { + if (!latest) return; + setVersion(latest.version); + setLatest(undefined); + setError(undefined); + }; + + const onCancel = () => setOpened(false); + + return { + dialogTitle, + error, + isPending, + latest, + methods, + onAdoptVersion, + onCancel, + onReloadRecord, + onRequestClose, + onSubmit, + opened, + t, + version, + }; +} + +export { useBankCardFormVm }; diff --git a/src/entities/bank-card/ui/BankFeedback.tsx b/src/entities/bank-card/ui/BankFeedback.tsx index d252712..e9e2861 100644 --- a/src/entities/bank-card/ui/BankFeedback.tsx +++ b/src/entities/bank-card/ui/BankFeedback.tsx @@ -11,9 +11,11 @@ type BankFeedbackProps = { loadingError?: boolean; retry?: () => void; }; + const BankFeedback: FC = (props) => { const { t } = useTranslation(); const code = apiErrorCode(props.error); + return ( @@ -26,6 +28,7 @@ const BankFeedback: FC = (props) => { ), })} + {props.retry && ( )} + {props.data && props.fetching && ( {t("bank.refreshing")} )} diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx b/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx index dde6ec3..5c7468d 100644 --- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx +++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx @@ -23,18 +23,23 @@ describe("CategoryManager", () => { clear: () => {}, refresh: () => Promise.resolve(false), }); + const categories = [ { id: "a", isDefault: false, name: "Первая", version: 1 }, { id: "b", isDefault: false, name: "Вторая", version: 4 }, ]; + let reads = 0; - const { promise: reloaded, resolve: release } = Promise.withResolvers(); + const { promise: reloaded, resolve: release } + = Promise.withResolvers(); + const patches: { body: unknown; id: unknown }[] = []; server.use( http.get("*/api/v1/bank-cards/categories", async () => { reads++; if (reads === 3) await reloaded; + return HttpResponse.json( categories.map(category => category.id === "a" && reads > 1 @@ -51,6 +56,7 @@ describe("CategoryManager", () => { "*/api/v1/bank-cards/categories/:id", async ({ params, request }) => { patches.push({ body: await request.json(), id: params.id }); + return patches.length === 1 ? HttpResponse.json( { code: "BANK_VERSION_CONFLICT" }, @@ -67,7 +73,11 @@ describe("CategoryManager", () => { , ); - const renameButtons = await screen.findAllByRole("button", { name: "Переименовать" }); + + const renameButtons = await screen.findAllByRole("button", { + name: "Переименовать", + }); + fireEvent.click(renameButtons[0]); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); fireEvent.click( @@ -122,6 +132,7 @@ describe("CategoryManager", () => { ), http.patch("*/api/v1/bank-cards/categories/a", () => { version = 2; + return ++writes === 1 ? HttpResponse.json( { code: "BANK_VERSION_CONFLICT" }, @@ -163,12 +174,14 @@ describe("CategoryManager", () => { clear: () => {}, refresh: () => Promise.resolve(false), }); + let category = { id: "default", isDefault: true, name: "Другое", version: 1, }; + let creates = 0; let body: unknown; server.use( @@ -177,6 +190,7 @@ describe("CategoryManager", () => { ), http.post("*/api/v1/bank-cards/categories", () => { creates++; + return HttpResponse.json({}); }), http.patch( @@ -184,6 +198,7 @@ describe("CategoryManager", () => { async ({ request }) => { body = await request.json(); category = { ...category, name: "Остальное", version: 2 }; + return HttpResponse.json(category); }, ), diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx index ba2ab23..db368c5 100644 --- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx +++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx @@ -1,4 +1,3 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Badge, Button, @@ -8,218 +7,148 @@ import { Text, TextInput, } from "@mantine/core"; -import { type FC, useRef, useState } from "react"; -import { - Controller, - FormProvider, - useForm, - useFormContext, -} from "react-hook-form"; +import { OperationIcon } from "@wealthledger/icons"; +import { type FC } from "react"; +import { FormProvider, useController, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { z } from "zod"; -import { validationMessage } from "@/shared/i18n"; - -import { useBankMutations } from "../../api/mutations"; -import { type ExpenseCategory, useBankCategories } from "../../api/queries"; +import { bankOperationAppearance } from "../../model/operation-appearance"; import { BankFeedback } from "../BankFeedback"; import classes from "../BankForms.module.css"; -import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon"; -import { useBankFormFocus } from "../useBankFormFocus"; - -const MAX_CATEGORY_NAME_LENGTH = 80; +import { useCategoryManagerVm } from "./CategoryManager.vm"; type CategoryManagerProps = { onClose: () => void; onCreated?: (id: string) => void; }; + type CategoryValues = { name: string }; + const CategoryFields: FC = () => { const { t } = useTranslation(); + const { control, formState: { errors }, } = useFormContext(); + + const { field: nameField } = useController({ control, name: "name" }); + return ( - ( - - )} + ); }; + const CategoryManager: FC = (props) => { - useBankFormFocus(); - const [opened, setOpened] = useState(true); - const { t } = useTranslation(); - const categories = useBankCategories(); - const mutation = useBankMutations(); - const [editing, setEditing] = useState(); - const editSessionRef = useRef(0); - const [error, setError] = useState(); - const [latest, setLatest] = useState(); - const schema = z.object({ - name: z - .string() - .trim() - .refine( - value => - value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH, - { error: validationMessage("bank.validation.category") }, - ) - .refine( - value => - !categories.data?.some( - c => - c.id !== editing?.id - && c.name.toLocaleLowerCase("ru-RU") - === value.toLocaleLowerCase("ru-RU"), - ), - { error: validationMessage("bank.validation.duplicate") }, - ), - }); - const methods = useForm({ defaultValues: { name: "" }, resolver: zodResolver(schema) }); - const isPending = !opened || methods.formState.isSubmitting; + const { + categories, + categoryRows, + editing, + error, + isPending, + latest, + methods, + onAdoptVersion, + onCancel, + onDismiss, + onReloadRecord, + onRequestClose, + onSubmit, + opened, + retryCategories, + t, + } = useCategoryManagerVm(props); + return ( !isPending && setOpened(false)} + onClose={onRequestClose} onExitTransitionEnd={props.onClose} opened={opened} title={t("bank.categories")} > - { - void methods.handleSubmit(async (values) => { - setError(undefined); - try { - if (editing) - await mutation.updateCategory.mutateAsync({ - body: { name: values.name, version: editing.version }, - id: editing.id, - }); - else { - const result = await mutation.createCategory.mutateAsync({ - name: values.name, - }); - if (result) props.onCreated?.(result.id); - } - editSessionRef.current++; - setEditing(undefined); - setLatest(undefined); - methods.reset({ name: "" }); - } - catch (error_) { - setError(error_); - } - })(event); - }} - > +
- {categories.data?.map(category => ( - - - - {category.name} - {category.isDefault && ( - {t("bank.defaultCategory")} - )} + {categoryRows?.map((category) => { + return ( + + + + + {category.name} + + {category.isDefault && ( + {t("bank.defaultCategory")} + )} + + + - - - ))} + ); + })} + {categories.isLoading && ( {t("bank.loading")} )} + {categories.isError && ( void categories.refetch()} + retry={retryCategories} /> )} + - {!!(error) && (( + + {!!error && ( <> + {editing && ( - )} - ))} + )} + {latest && latest.id === editing?.id && ( <> {t("bank.versionHint", { details: latest.name })} - )} + + {editing && ( - )} - diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts b/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts new file mode 100644 index 0000000..2fa967d --- /dev/null +++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts @@ -0,0 +1,128 @@ +import type { ComponentProps } from "react"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import { useBankMutations } from "../../api/mutations"; +import { type ExpenseCategory, useBankCategories } from "../../api/queries"; +import { categorySchema } from "../../model/category.schema"; +import { useBankFormFocus } from "../useBankFormFocus"; + +type CategoryManagerProps = { + onClose: () => void; + onCreated?: (id: string) => void; +}; + +type CategoryValues = { name: string }; + +function useCategoryManagerVm(props: CategoryManagerProps) { + useBankFormFocus(); + const [opened, setOpened] = useState(true); + const { t } = useTranslation(); + const categories = useBankCategories(); + const mutation = useBankMutations(); + const [editing, setEditing] = useState(); + const editSessionRef = useRef(0); + const [error, setError] = useState(); + const [latest, setLatest] = useState(); + const schema = categorySchema(categories.data, editing?.id); + + const methods = useForm({ + defaultValues: { name: "" }, + resolver: zodResolver(schema), + }); + + const isPending = !opened || methods.formState.isSubmitting; + const onRequestClose = () => !isPending && setOpened(false); + + const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => { + void methods.handleSubmit(async (values) => { + setError(undefined); + + try { + if (editing) + await mutation.updateCategory.mutateAsync({ + body: { name: values.name, version: editing.version }, + id: editing.id, + }); + else { + const result = await mutation.createCategory.mutateAsync({ + name: values.name, + }); + + if (result) props.onCreated?.(result.id); + } + + editSessionRef.current++; + setEditing(undefined); + setLatest(undefined); + methods.reset({ name: "" }); + } + catch (error_) { + setError(error_); + } + })(event); + }; + + const retryCategories = () => void categories.refetch(); + + const onReloadRecord = async () => { + if (!editing) return; + const session = editSessionRef.current; + const response = await categories.refetch(); + if (session !== editSessionRef.current) return; + if (response.error) setError(response.error); + else setLatest(response.data?.find(c => c.id === editing.id)); + }; + + const onAdoptVersion = () => { + setEditing(latest); + setLatest(undefined); + setError(undefined); + }; + + const onCancel = () => { + editSessionRef.current++; + setEditing(undefined); + setLatest(undefined); + methods.reset({ name: "" }); + setError(undefined); + }; + + const onDismiss = () => setOpened(false); + + const categoryRows = categories.data?.map((category) => { + const onRename = () => { + editSessionRef.current++; + setEditing(category); + methods.reset({ name: category.name }); + setError(undefined); + setLatest(undefined); + }; + + return { ...category, onRename }; + }); + + return { + categories, + categoryRows, + editing, + error, + isPending, + latest, + methods, + onAdoptVersion, + onCancel, + onDismiss, + onReloadRecord, + onRequestClose, + onSubmit, + opened, + retryCategories, + t, + }; +} + +export { useCategoryManagerVm }; diff --git a/src/entities/bond/api/queries.ts b/src/entities/bond/api/queries.ts index 61c0b27..80d254b 100644 --- a/src/entities/bond/api/queries.ts +++ b/src/entities/bond/api/queries.ts @@ -18,6 +18,7 @@ function bondQueryOptions(uid: string) { staleTime: BOND_STALE_TIME_MS, }; } + function useBond(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( @@ -28,6 +29,7 @@ function useBond(identifier: string) { ...bondQueryOptions(identifier), enabled: Boolean(identifier) && isUid, }); + const byTicker = useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", { params: { path: { ticker: identifier } }, @@ -35,6 +37,7 @@ function useBond(identifier: string) { enabled: Boolean(identifier) && !isUid, staleTime: BOND_STALE_TIME_MS, }); + return isUid ? byUid : byTicker; } @@ -47,6 +50,7 @@ function useBondCandles(uid: string, period: BondPeriod) { staleTime: BOND_STALE_TIME_MS, }); } + function useBondCoupons(uid: string) { return useResourceQuery({ meta: { accessGroup: "brokerage" }, @@ -57,6 +61,7 @@ function useBondCoupons(uid: string) { staleTime: BOND_STALE_TIME_MS, }); } + function useBondMetrics(uid: string) { return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", { @@ -66,6 +71,7 @@ function useBondMetrics(uid: string) { staleTime: BOND_STALE_TIME_MS, }); } + function useBondPosition(uid: string) { return useResourceQuery({ meta: { accessGroup: "brokerage" }, @@ -76,6 +82,7 @@ function useBondPosition(uid: string) { staleTime: BOND_STALE_TIME_MS, }); } + function useBondQuote(uid: string) { return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", { @@ -85,6 +92,7 @@ function useBondQuote(uid: string) { staleTime: BOND_STALE_TIME_MS, }); } + function useBondSearch(query: string) { return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bonds", { @@ -95,7 +103,8 @@ function useBondSearch(query: string) { }); } -export { type BondPeriod, +export { + type BondPeriod, MIN_BOND_SEARCH_LENGTH, useBond, useBondCandles, diff --git a/src/entities/bond/index.ts b/src/entities/bond/index.ts index 1f0a2a2..f5e91aa 100644 --- a/src/entities/bond/index.ts +++ b/src/entities/bond/index.ts @@ -1,4 +1,5 @@ -export { type BondPeriod, +export { + type BondPeriod, MIN_BOND_SEARCH_LENGTH, useBond, useBondCandles, diff --git a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx index aaafda3..6207a33 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx +++ b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx @@ -22,6 +22,7 @@ describe("useBrokerageOperations", () => { cursor: "next", }), ).toEqual({ categories: ["income", "tax"], cursor: "next" }); + const { result } = renderHook( () => useBrokerageOperations("account-1", { limit: 5 }), { wrapper: createQueryWrapper().wrapper }, diff --git a/src/entities/broker-operation/api/useBrokerageOperations.ts b/src/entities/broker-operation/api/useBrokerageOperations.ts index 80eaf39..1eb71b3 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.ts +++ b/src/entities/broker-operation/api/useBrokerageOperations.ts @@ -8,7 +8,9 @@ type BrokerageOperationsQuery = Omit< NonNullable, "sync" >; + type OperationCategory = BrokerageOperation["category"]; + type OperationsEndpoint = paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"]; @@ -42,7 +44,11 @@ function brokerageOperationsQueryParameters(query: BrokerageOperationsQuery) { ...(query.instrumentUid && { instrumentUid: query.instrumentUid }), ...(query.from && { from: query.from }), ...(query.to && { to: query.to }), - ...(query.categories?.length && { categories: query.categories.toSorted((left, right) => left.localeCompare(right)) }), + ...(query.categories?.length && { + categories: query.categories.toSorted((left, right) => + left.localeCompare(right), + ), + }), ...(query.cursor && { cursor: query.cursor }), ...(query.limit && { limit: query.limit }), }; diff --git a/src/entities/broker-operation/model/display.ts b/src/entities/broker-operation/model/display.ts index c4e834c..411272b 100644 --- a/src/entities/broker-operation/model/display.ts +++ b/src/entities/broker-operation/model/display.ts @@ -22,7 +22,9 @@ const operationTransferPrefix = "OPERATION_TYPE_TRANS_"; type PaymentPresentation = { amount: string; label: string; - tone: "negative" | "neutral" | "positive"; + tone: "negative" + | "neutral" + | "positive"; }; function entryLabelKey(operation: BrokerageOperation): string { @@ -30,6 +32,7 @@ function entryLabelKey(operation: BrokerageOperation): string { return isOperationType(operation.type, OperationType.Coupon) ? "ledger.entryLabel.income.coupon" : "ledger.entryLabel.income.dividend"; + return `ledger.category.${operation.category}`; } @@ -43,19 +46,15 @@ function isZeroDecimal(value: string): boolean { function operationLabelKey(type: string): string { if ( - [ - OperationType.Buy, - OperationType.BuyCard, - OperationType.BuyMargin, - ].some(candidate => isOperationType(type, candidate)) + [OperationType.Buy, OperationType.BuyCard, OperationType.BuyMargin].some( + candidate => isOperationType(type, candidate), + ) ) return "ledger.operationType.buy"; if ( - [ - OperationType.Sell, - OperationType.SellCard, - OperationType.SellMargin, - ].some(candidate => isOperationType(type, candidate)) + [OperationType.Sell, OperationType.SellCard, OperationType.SellMargin].some( + candidate => isOperationType(type, candidate), + ) ) return "ledger.operationType.sell"; if ( @@ -72,7 +71,10 @@ function operationLabelKey(type: string): string { return "ledger.operationType.input"; if (isOperationType(type, OperationType.OutputSecurities)) return "ledger.operationType.output"; - return type.startsWith(operationTransferPrefix) ? "ledger.operationType.transfer" : "ledger.operationType.other"; + + return type.startsWith(operationTransferPrefix) + ? "ledger.operationType.transfer" + : "ledger.operationType.other"; } function paymentPresentation( @@ -99,6 +101,7 @@ function paymentPresentation( label: entryLabelKey(operation), tone: "negative", }; + return { amount: `+${formatMoney(operation.payment)}`, label: entryLabelKey(operation), diff --git a/src/entities/broker-operation/ui/LedgerEntryRow.tsx b/src/entities/broker-operation/ui/LedgerEntryRow.tsx index c9efaf3..edb7aeb 100644 --- a/src/entities/broker-operation/ui/LedgerEntryRow.tsx +++ b/src/entities/broker-operation/ui/LedgerEntryRow.tsx @@ -24,50 +24,59 @@ type LedgerEntryRowProps = { const LedgerEntryRow: FC = (props) => { const { operation } = props; const { t } = useTranslation(); + const instrument = operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument"); + const payment = paymentPresentation(operation); + const metadata = [ t("ledger.occurredAt", { date: formatSnapshotTime(operation.occurredAt) }), instrument, - props.withDetails ? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null : operation.account?.name, + props.withDetails + ? null // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + : operation.account?.name, ] .filter(Boolean) .join(" · "); + let details; + if (props.withDetails) { + details = ( + <> + + {operation.quantity != undefined && ( + + {t("ledger.quantity", { + value: formatDecimal(operation.quantity), + })} + + )} + + {!!operation.price && ( + + {t("ledger.price", { value: formatMoney(operation.price) })} + + )} + + + {!!operation.account && ( + + {operation.account.name} + + )} + + ); + } + else details = undefined; + return ( - - {operation.quantity != undefined && (( - - {t("ledger.quantity", { - value: formatDecimal(operation.quantity), - })} - - ))} - {!!(operation.price) && (( - - {t("ledger.price", { value: formatMoney(operation.price) })} - - ))} - - {!!(operation.account) && (( - - {operation.account.name} - - ))} - - )) - : undefined - } + details={details} imageSrc={instrumentLogoUrl(operation.logoName)} initial={(instrument.trim()[0] ?? "?").toUpperCase()} metadata={metadata} diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx index ecb44b1..74cf150 100644 --- a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx +++ b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx @@ -56,6 +56,7 @@ describe("useBrokerageAccounts", () => { server.use( http.get("*/api/v1/brokerage-accounts", () => { calls += 1; + return calls === 1 ? HttpResponse.json( resource([ @@ -70,6 +71,7 @@ describe("useBrokerageAccounts", () => { : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); }), ); + const { result } = renderHook(() => useBrokerageAccounts(), { wrapper: createQueryWrapper().wrapper, }); diff --git a/src/entities/brokerage-account/model/display.ts b/src/entities/brokerage-account/model/display.ts index 7cf64ea..1154e4f 100644 --- a/src/entities/brokerage-account/model/display.ts +++ b/src/entities/brokerage-account/model/display.ts @@ -7,7 +7,8 @@ const VISIBLE_CURRENCY_LIMIT = 2; type BrokerageAccountCard = { account: BrokerageAccount; - positionCounts: null | { bonds: number; stocks: number }; + positionCounts: null + | { bonds: number; stocks: number }; source?: NonNullable< ReturnType["meta"] >["dependencies"] extends (infer T)[] | undefined @@ -38,20 +39,26 @@ function brokerageAccountCards( item => item.resourceKey === `portfolio/${encodeURIComponent(account.id)}`, ); + const summary = account.portfolioSummary; if (!summary) return { account, - positionCounts: /* eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. */ null, + positionCounts: + // eslint-disable-next-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. + null, source, summary: { state: "unavailable" }, }; + const positionCounts = { bonds: summary.bondPositionCount, stocks: summary.stockPositionCount, }; + if (summary.totalsByCurrency.length === 0) return { account, positionCounts, source, summary: { state: "empty" } }; + return { account, positionCounts, diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx index 731a4db..47c79e9 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx @@ -64,8 +64,10 @@ export const Available: Story = { }, }; -export const Unavailable: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +export const Unavailable: Story + = { args: { accounts: brokerageAccountCards([ { ...syntheticAccount, openedOn: null, portfolioSummary: null }, @@ -76,12 +78,12 @@ export const Unavailable: Story = (/* eslint-disable unicorn/no-null -- Fixture docs: { description: { story: - "Счёт остаётся доступен для открытия, даже если сводка временно недоступна. Неизвестные суммы не заменяются нулём; отсутствующая дата открытия не отображается.", + "Счёт остаётся доступен для открытия, даже если сводка временно недоступна. Неизвестные суммы не заменяются нулём; отсутствующая дата открытия не отображается.", }, }, }, - } -/* eslint-enable unicorn/no-null */); + }; +/* eslint-enable unicorn/no-null */ export const Empty: Story = { args: { @@ -191,8 +193,10 @@ export const LargeValues: Story = { name: "Длинные суммы", }; -export const IndependentFreshness: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +export const IndependentFreshness: Story + = { args: { accounts: brokerageAccountCards( [ @@ -231,5 +235,5 @@ export const IndependentFreshness: Story = (/* eslint-disable unicorn/no-null -- ), }, name: "Независимая свежесть и ошибка сводок", - } -/* eslint-enable unicorn/no-null */); + }; +/* eslint-enable unicorn/no-null */ diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx index 844e889..0b326a8 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx @@ -29,29 +29,26 @@ describe("BrokerageAccountList", () => { ], }, type: "BROKERAGE", + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + }, + { + id: "account-2", + name: "Недоступный", + openedOn: null, + portfolioSummary: null, + type: "BROKERAGE", + }, + /* eslint-enable unicorn/no-null */ { + id: "account-3", + name: "Пустой", + openedOn: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture. + portfolioSummary: { + bondPositionCount: 0, + stockPositionCount: 2, + totalsByCurrency: [], + }, + type: "BROKERAGE", }, - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - id: "account-2", - name: "Недоступный", - openedOn: null, - portfolioSummary: null, - type: "BROKERAGE", - } - /* eslint-enable unicorn/no-null */), - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - id: "account-3", - name: "Пустой", - openedOn: null, - portfolioSummary: { - bondPositionCount: 0, - stockPositionCount: 2, - totalsByCurrency: [], - }, - type: "BROKERAGE", - } - /* eslint-enable unicorn/no-null */), ])} /> @@ -71,19 +68,11 @@ describe("BrokerageAccountList", () => { expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument(); expect(screen.getAllByText("Открыть счет")).toHaveLength(3); const available = within(screen.getByRole("link", { name: /Основной/ })); - expect(available.getAllByRole("definition")[0]).toHaveTextContent( - "5", - ); - expect( - available.getAllByRole("definition")[1], - ).toHaveTextContent("12"); + expect(available.getAllByRole("definition")[0]).toHaveTextContent("5"); + expect(available.getAllByRole("definition")[1]).toHaveTextContent("12"); const unvalued = within(screen.getByRole("link", { name: /Пустой/ })); - expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent( - "2", - ); - expect( - unvalued.getAllByRole("definition")[1], - ).toHaveTextContent("0"); + expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent("2"); + expect(unvalued.getAllByRole("definition")[1]).toHaveTextContent("0"); expect( within(screen.getByRole("link", { name: /Недоступный/ })).queryByText( "Акции", @@ -94,19 +83,22 @@ describe("BrokerageAccountList", () => { it("shows portfolio summary freshness independently for each account", () => { const accounts = brokerageAccountCards( - ["fresh", "stale"].map(id => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - id, - name: id, - openedOn: null, - portfolioSummary: { - bondPositionCount: 0, - stockPositionCount: 1, - totalsByCurrency: [{ currency: "RUB", value: "10" }], - }, - type: "BROKERAGE" as const, - } - /* eslint-enable unicorn/no-null */))), + ["fresh", "stale"].map( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + id => + ({ + id, + name: id, + openedOn: null, + portfolioSummary: { + bondPositionCount: 0, + stockPositionCount: 1, + totalsByCurrency: [{ currency: "RUB", value: "10" }], + }, + type: "BROKERAGE" as const, + }), + /* eslint-enable unicorn/no-null */ + ), [ resource(null, { freshness: "fresh", resourceKey: "portfolio/fresh" }) .meta, @@ -114,6 +106,7 @@ it("shows portfolio summary freshness independently for each account", () => { .meta, ], ); + render( diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx index dd27da7..b3a7c3b 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx @@ -1,6 +1,7 @@ import type { FC } from "react"; import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; +import { WalletIcon } from "@wealthledger/icons"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; @@ -40,13 +41,14 @@ const BrokerageAccountSummary: FC = (props) => { {formatMoney(total)} ))} - {!!(summary.remainingCurrencyCount) && (( + + {!!summary.remainingCurrencyCount && ( {t("accounts.remainingCurrencies", { count: summary.remainingCurrencyCount, })} - ))} + )} ); }; @@ -72,76 +74,78 @@ const BrokerageAccountList: FC = (props) => { {t(accountTypeKey(account.type))} - + + + {account.name} + {t("accounts.currentValue")} + - {!!(source?.lastSuccessfulAt) && (( + + {!!source?.lastSuccessfulAt && ( {t("investment.sourceTime", { date: formatSnapshotTime(source.lastSuccessfulAt), })} - ))} + )} + {(source?.sync.state === "running" - || source?.sync.state === "queued") && (( + || source?.sync.state === "queued") && ( {t("accounts.refreshing")} - ))} - {(source?.sync.state === "failed") && (( + )} + + {source?.sync.state === "failed" && ( {t("accounts.refreshError")} - ))} - {(source?.freshness === "stale") && (( + )} + + {source?.freshness === "stale" && ( {t("investment.stale")} - ))} + )} - {!!(positionCounts) && (( + + {!!positionCounts && (
{t("accounts.stocks")}
+
{new Intl.NumberFormat("ru-RU").format(positionCounts.stocks)}
+
{t("accounts.bonds")}
+
{new Intl.NumberFormat("ru-RU").format(positionCounts.bonds)}
- ))} + )} + - {!!(account.openedOn) && (( + {!!account.openedOn && ( {t("accounts.openedOn", { date: formatDate(account.openedOn), })} - ))} + )} + {t("accounts.openAccount")} + diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx b/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx index 868dcc4..2854b4d 100644 --- a/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx +++ b/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx @@ -20,9 +20,11 @@ describe("useBrokerageEvents", () => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/events", requested), ); + const { result } = renderHook(() => useBrokerageEvents(undefined), { wrapper: createQueryWrapper().wrapper, }); + await waitFor(() => expect(result.current.fetchStatus).toBe("idle")); expect(requested).not.toHaveBeenCalled(); }); @@ -35,7 +37,8 @@ describe("useBrokerageEvents", () => { server.use( http.get("*/api/v1/brokerage-accounts/account-1/events", () => HttpResponse.json( - resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( { accountId: "account-1", calculatedAt: "2026-08-14T00:00:00Z", @@ -52,8 +55,9 @@ describe("useBrokerageEvents", () => { type: "dividend", }, ], - } - /* eslint-enable unicorn/no-null */)), + }, + /* eslint-enable unicorn/no-null */ + ), ), ), ); @@ -63,20 +67,20 @@ describe("useBrokerageEvents", () => { }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ expect(result.current.data?.events).toEqual([ - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - date: "2026-09-01", - dateKind: "registry", - displayName: "Сбер", - figi: "figi-1", - instrumentUid: null, - logoName: "sber3.png", - projectedAmount: { currency: "RUB", value: "12.34" }, - ticker: "SBER", - type: "dividend", - } - /* eslint-enable unicorn/no-null */), + { + date: "2026-09-01", + dateKind: "registry", + displayName: "Сбер", + figi: "figi-1", + instrumentUid: null, + logoName: "sber3.png", + projectedAmount: { currency: "RUB", value: "12.34" }, + ticker: "SBER", + type: "dividend", + }, + /* eslint-enable unicorn/no-null */ ]); }); }); diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.ts b/src/entities/brokerage-event/api/useBrokerageEvents.ts index ba80d6d..f85324a 100644 --- a/src/entities/brokerage-event/api/useBrokerageEvents.ts +++ b/src/entities/brokerage-event/api/useBrokerageEvents.ts @@ -3,6 +3,7 @@ import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; type BrokerageEvent = NonNullable< EventsEndpoint["responses"][200]["content"]["application/json"]["data"] >["events"][number]; + type EventsEndpoint = paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"]; diff --git a/src/entities/brokerage-event/ui/BrokerageEventCard.tsx b/src/entities/brokerage-event/ui/BrokerageEventCard.tsx index 8c15780..1587651 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventCard.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventCard.tsx @@ -17,13 +17,19 @@ const BrokerageEventCard: FC = (props) => { const metadata = [ date, - event.dateKind === "registry" ? t("events.dateKind.registry") : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null, + event.dateKind === "registry" + ? t("events.dateKind.registry") + : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. event.isin, event.account?.name, ] .filter(Boolean) .join(" · "); + const projectedAmount = event.projectedAmount + ? formatMoney(event.projectedAmount) + : t("events.noAmount"); + return ( = (props) => { metadata={metadata} title={[t(`events.type.${event.type}`), event.displayName].join(" · ")} tone="neutral" - value={ - event.projectedAmount - ? formatMoney(event.projectedAmount) - : t("events.noAmount") - } + value={projectedAmount} valueLabel={t("events.projectedAmount")} /> ); diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories.tsx index cee9b4e..6dca17b 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories.tsx @@ -6,20 +6,21 @@ import type { BrokerageEvent } from "../../api/useBrokerageEvents"; import { BrokerageEventsPreview } from "./BrokerageEventsPreview"; -const syntheticEvent = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ -{ - date: "2026-10-15", - dateKind: "event", - displayName: "Учебные облигации", - figi: null, - instrumentUid: null, - isin: "RU000A0JX0J2", - logoName: null, - projectedAmount: { currency: "RUB", value: "850.50" }, - ticker: "DEMO", - type: "coupon", -} -/* eslint-enable unicorn/no-null */) satisfies BrokerageEvent; +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +const syntheticEvent + = { + date: "2026-10-15", + dateKind: "event", + displayName: "Учебные облигации", + figi: null, + instrumentUid: null, + isin: "RU000A0JX0J2", + logoName: null, + projectedAmount: { currency: "RUB", value: "850.50" }, + ticker: "DEMO", + type: "coupon", + } satisfies /* eslint-enable unicorn/no-null */ BrokerageEvent; const eventsPath = "/accounts/storybook-account/events"; @@ -120,8 +121,10 @@ export const Empty: Story = { }, }; -export const MissingAmount: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +export const MissingAmount: Story + = { args: { events: { items: [{ ...syntheticEvent, projectedAmount: null }], @@ -134,22 +137,24 @@ export const MissingAmount: Story = (/* eslint-disable unicorn/no-null -- Fixtur docs: { description: { story: - "Событие остаётся видимым при неизвестной ожидаемой сумме. Отсутствие оценки не подменяется нулевой выплатой.", + "Событие остаётся видимым при неизвестной ожидаемой сумме. Отсутствие оценки не подменяется нулевой выплатой.", }, }, }, - } -/* eslint-enable unicorn/no-null */); + }; +/* eslint-enable unicorn/no-null */ -export const LongEvent: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +export const LongEvent: Story + = { args: { events: { items: [ { ...syntheticEvent, displayName: - "Учебные облигации компании с очень длинным названием инструмента", + "Учебные облигации компании с очень длинным названием инструмента", projectedAmount: null, type: "offer", }, @@ -159,5 +164,5 @@ export const LongEvent: Story = (/* eslint-disable unicorn/no-null -- Fixture pr }, }, name: "Длинное название на узком экране", - } -/* eslint-enable unicorn/no-null */); + }; +/* eslint-enable unicorn/no-null */ diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx index 2f4e58c..0939a75 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx @@ -24,7 +24,9 @@ type BrokerageEventsPreviewState state: "loaded" | "partial"; to: string; } - | { state: "empty" | "error" | "loading"; to: string }; + | { state: "empty" + | "error" + | "loading"; to: string; }; const BrokerageEventsPreview: FC = (props) => { const { events } = props; @@ -32,51 +34,60 @@ const BrokerageEventsPreview: FC = (props) => { return ( - {(events.state === "loaded") && (( + {events.state === "loaded" && ( <> - {!!(events.lastSuccessfulAt) && (( + {!!events.lastSuccessfulAt && ( {t("investment.sourceTime", { date: formatSnapshotTime(events.lastSuccessfulAt), })} - ))} - {!!(events.stale) && ({t("investment.stale")})} - {!!(events.refreshError) && (( + )} + + {!!events.stale && {t("investment.stale")}} + + {!!events.refreshError && ( {t("accounts.refreshError")} - ))} + )} - ))} - {(events.state === "loading") && (( + )} + + {events.state === "loading" && ( {["first", "second", "third"].map(placeholder => ( ))} - ))} - {(events.state === "error") && (( + )} + + {events.state === "error" && ( {t("events.recentError")} - ))} - {(events.state === "empty") && (( + )} + + {events.state === "empty" && ( {t("events.recentEmpty")} - ))} - {(events.state === "partial") && (( + )} + + {events.state === "partial" && ( {t("investment.partial")} - ))} - {(events.state === "loaded" || events.state === "partial") && (events.items.map(event => ( - - )))} + )} + + {(events.state === "loaded" || events.state === "partial") + && events.items.map(event => ( + + ))} + diff --git a/src/entities/income/api/useIncome.ts b/src/entities/income/api/useIncome.ts index 96db67b..a6357e8 100644 --- a/src/entities/income/api/useIncome.ts +++ b/src/entities/income/api/useIncome.ts @@ -3,6 +3,7 @@ import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; type IncomeAnalytics = NonNullable< paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"] >; + type IncomeSummary = NonNullable< paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"] >; @@ -28,7 +29,7 @@ function useIncomeAnalytics( }); } -function useIncomeSummary(accountId: string | undefined) { +function useIncomeSummary(accountId?: string) { return useResourceQuery({ meta: { accessGroup: "brokerage" }, ...apiQueryClient.queryOptions( diff --git a/src/entities/portfolio/api/resource-lifecycle.test.tsx b/src/entities/portfolio/api/resource-lifecycle.test.tsx index bc64aff..f613e55 100644 --- a/src/entities/portfolio/api/resource-lifecycle.test.tsx +++ b/src/entities/portfolio/api/resource-lifecycle.test.tsx @@ -21,6 +21,7 @@ const snapshot: Portfolio = { structure: { currencies: [], unvaluedCategories: [] }, totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }], }; + function auth() { configureAuthClient({ accessToken: () => "synthetic", @@ -41,6 +42,7 @@ describe("persistent resource query lifecycle", () => { const mode = new URL(request.url).searchParams.get("sync"); modes.push(mode); if (mode) observations += 1; + return HttpResponse.json( resource(snapshot, { stateVersion: observations > 1 ? "new" : "old", @@ -76,22 +78,26 @@ describe("persistent resource query lifecycle", () => { ), ), ); + const { result } = renderHook(() => usePortfolio("a"), { wrapper: createQueryWrapper().wrapper, }); + await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed")); expect(result.current.data).toBeUndefined(); expect(result.current.isSuccess).toBe(false); expect(result.current.status).toBe("pending"); expect(result.current.isPending).toBe(true); expect(result.current.isLoading).toBe(false); - expect(result.current.transport).toEqual((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + expect(result.current.transport).toEqual( { error: null, fetchStatus: "idle", status: "success", - } - /* eslint-enable unicorn/no-null */)); + }, + /* eslint-enable unicorn/no-null */ + ); expect(result.current.meta?.availability).toBe("missing"); }); it("keeps the refetch envelope while exposing the refreshed resource payload", async () => { @@ -103,9 +109,11 @@ describe("persistent resource query lifecycle", () => { HttpResponse.json(isReady ? refreshed : resource(null)), ), ); + const { result } = renderHook(() => usePortfolio("a"), { wrapper: createQueryWrapper().wrapper, }); + await waitFor(() => expect(result.current.transport.status).toBe("success"), ); @@ -130,20 +138,25 @@ describe("persistent resource query lifecycle", () => { it("rejects a late ordinary response when an observe response for that resource arrived first", async () => { auth(); - const { promise: blocked, resolve: release } = Promise.withResolvers(); + const { promise: blocked, resolve: release } + = Promise.withResolvers(); + server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/portfolio", async ({ request }) => { if (!new URL(request.url).searchParams.has("sync")) await blocked; + return HttpResponse.json(resource(snapshot)); }, ), ); + const old = apiClient.GET( "/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId: "a" } } }, ); + // eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below. const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" }); await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", { @@ -156,21 +169,28 @@ describe("persistent resource query lifecycle", () => { auth(); let isDenied = false; - const { promise: blocked, resolve: release } = Promise.withResolvers(); + const { promise: blocked, resolve: release } + = Promise.withResolvers(); + server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/portfolio", async ({ params }) => { if (params.accountId === "b") await blocked; - return isDenied && params.accountId === "a" ? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }) : HttpResponse.json(resource(snapshot)); + + return isDenied && params.accountId === "a" + ? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }) + : HttpResponse.json(resource(snapshot)); }, ), ); const { wrapper } = createQueryWrapper(); + const { result } = renderHook( () => ({ a: usePortfolio("a"), b: usePortfolio("b") }), { wrapper }, ); + await waitFor(() => expect(result.current.a.data).toEqual(snapshot)); isDenied = true; await act(() => result.current.a.refetch()); diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx index 6f21b24..87733c5 100644 --- a/src/entities/portfolio/api/usePortfolio.test.tsx +++ b/src/entities/portfolio/api/usePortfolio.test.tsx @@ -16,6 +16,7 @@ describe("usePortfolio", () => { clear: vi.fn(), refresh: () => Promise.resolve(true), }); + const { result } = renderHook(() => usePortfolio("account-1"), { wrapper: createQueryWrapper().wrapper, }); @@ -34,6 +35,7 @@ describe("usePortfolio", () => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { calls += 1; + return calls === 1 ? HttpResponse.json( resource({ @@ -48,6 +50,7 @@ describe("usePortfolio", () => { : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); }), ); + const { result } = renderHook(() => usePortfolio("account-1"), { wrapper: createQueryWrapper().wrapper, }); diff --git a/src/entities/portfolio/api/usePortfolio.ts b/src/entities/portfolio/api/usePortfolio.ts index 0514463..1d0baa2 100644 --- a/src/entities/portfolio/api/usePortfolio.ts +++ b/src/entities/portfolio/api/usePortfolio.ts @@ -8,7 +8,7 @@ type Portfolio = NonNullable< paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]["data"] >; -function portfolioQueryOptions(accountId: string | undefined) { +function portfolioQueryOptions(accountId?: string) { return apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/portfolio", @@ -16,7 +16,7 @@ function portfolioQueryOptions(accountId: string | undefined) { ); } -function usePortfolio(accountId: string | undefined) { +function usePortfolio(accountId?: string) { return useResourceQuery({ meta: { accessGroup: "brokerage" }, ...portfolioQueryOptions(accountId), diff --git a/src/entities/portfolio/model/display.ts b/src/entities/portfolio/model/display.ts index c84b1b8..dea89ec 100644 --- a/src/entities/portfolio/model/display.ts +++ b/src/entities/portfolio/model/display.ts @@ -8,14 +8,22 @@ function formatShare( numerator: Money | null, denominator: Money | null, ): null | string { - if (!numerator || !denominator) return /* eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. */ null; + if (!numerator || !denominator) + // eslint-disable-next-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. + return null; const total = Number(denominator.value); - if (!Number.isFinite(total) || total <= 0) return /* eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. */ null; + if (!Number.isFinite(total) || total <= 0) + // eslint-disable-next-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. + return null; const percent = (Number(numerator.value) / total) * PERCENT_SCALE; - if (!Number.isFinite(percent)) return /* eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. */ null; + if (!Number.isFinite(percent)) + // eslint-disable-next-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. + return null; + const rounded = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 1, }).format(percent); + return `${rounded} %`; } diff --git a/src/entities/stock/api/queries.ts b/src/entities/stock/api/queries.ts index fa52bbc..969158e 100644 --- a/src/entities/stock/api/queries.ts +++ b/src/entities/stock/api/queries.ts @@ -24,6 +24,7 @@ function stockQueryOptions(uid: string) { staleTime: STOCK_STALE_TIME_MS, }; } + 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( @@ -34,6 +35,7 @@ function useStock(identifier: string) { ...stockQueryOptions(identifier), enabled: Boolean(identifier) && isUid, }); + const byTicker = useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", { params: { path: { ticker: identifier } }, @@ -41,6 +43,7 @@ function useStock(identifier: string) { enabled: Boolean(identifier) && !isUid, staleTime: STOCK_STALE_TIME_MS, }); + return isUid ? byUid : byTicker; } @@ -63,6 +66,7 @@ function useStockCompany(uid: string) { staleTime: STOCK_STALE_TIME_MS, }); } + function useStockDividends(uid: string) { return useResourceQuery({ meta: { accessGroup: "brokerage" }, @@ -73,6 +77,7 @@ function useStockDividends(uid: string) { staleTime: STOCK_STALE_TIME_MS, }); } + function useStockPosition(uid: string) { return useResourceQuery({ meta: { accessGroup: "brokerage" }, @@ -83,6 +88,7 @@ function useStockPosition(uid: string) { staleTime: STOCK_STALE_TIME_MS, }); } + function useStockQuote(uid: string) { return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/quote", { @@ -92,16 +98,23 @@ function useStockQuote(uid: string) { staleTime: STOCK_STALE_TIME_MS, }); } + function useStocks(uids: string[]) { const uniqueUids = [...new Set(uids)]; - const queries = useAuthenticatedQueries(uniqueUids.map(uniqueUid => stockQueryOptions(uniqueUid))); + + const queries = useAuthenticatedQueries( + uniqueUids.map(uniqueUid => stockQueryOptions(uniqueUid)), + ); + return new Map( queries.map((query, index) => [ - /* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ uniqueUids[index], + // eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. + uniqueUids[index], unwrapResourceResult(query).data, ]), ); } + function useStockSearch(query: string) { return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/stocks", { @@ -112,7 +125,8 @@ function useStockSearch(query: string) { }); } -export { MIN_STOCK_SEARCH_LENGTH, +export { + MIN_STOCK_SEARCH_LENGTH, type StockPeriod, useStock, useStockCandles, diff --git a/src/entities/stock/index.ts b/src/entities/stock/index.ts index f4ddf46..f3018b2 100644 --- a/src/entities/stock/index.ts +++ b/src/entities/stock/index.ts @@ -1,4 +1,5 @@ -export { MIN_STOCK_SEARCH_LENGTH, +export { + MIN_STOCK_SEARCH_LENGTH, type StockPeriod, useStock, useStockCandles, diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts index da4ea1a..2f2a4ab 100644 --- a/src/features/auth/index.ts +++ b/src/features/auth/index.ts @@ -1,5 +1,5 @@ export { AuthProvider } from "./model/auth"; export { useAuth } from "./model/auth-context"; export { AuthPageLayout } from "./ui/AuthPageLayout/AuthPageLayout"; -export { LoginForm } from "./ui/LoginForm"; -export { RegisterForm } from "./ui/RegisterForm"; +export { LoginForm } from "./ui/LoginForm/LoginForm"; +export { RegisterForm } from "./ui/RegisterForm/RegisterForm"; diff --git a/src/features/auth/model/auth-context.ts b/src/features/auth/model/auth-context.ts index 5446045..083e5b2 100644 --- a/src/features/auth/model/auth-context.ts +++ b/src/features/auth/model/auth-context.ts @@ -11,13 +11,16 @@ type AuthContextValue = { register: (email: string, password: string) => Promise; user: undefined | User; }; + type User = paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"]; + const AuthContext = createContext(undefined); function useAuth() { const context = use(AuthContext); if (!context) throw new Error("AuthProvider is required"); + return context; } diff --git a/src/features/auth/model/validation.ts b/src/features/auth/model/auth.schema.ts similarity index 99% rename from src/features/auth/model/validation.ts rename to src/features/auth/model/auth.schema.ts index 77d6675..a602a77 100644 --- a/src/features/auth/model/validation.ts +++ b/src/features/auth/model/auth.schema.ts @@ -12,6 +12,7 @@ const MAX_PASSWORD_BYTES = 72; function isEmailCharacterInvalid(character: string): boolean { const codePoint = character.codePointAt(0); + return ( /\s/.test(character) || (codePoint !== undefined && codePoint < ASCII_SPACE) @@ -31,6 +32,7 @@ const email = z && value.split("").every(element => !isEmailCharacterInvalid(element)), { error: validationMessage("auth.validation.invalidEmail") }, ); + const password = z .string() .refine( @@ -45,6 +47,7 @@ const loginSchema = z.object({ password, passwordConfirmation: z.string().optional(), }); + const registerSchema = loginSchema.refine( value => value.password === value.passwordConfirmation, { diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx index d026447..700aea8 100644 --- a/src/features/auth/model/auth.test.tsx +++ b/src/features/auth/model/auth.test.tsx @@ -17,7 +17,9 @@ const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", { params: { query: { includeArchived: "false" } }, }).queryKey; -function createAuthWrapper(queryClient: ReturnType): FC { +function createAuthWrapper( + queryClient: ReturnType, +): FC { return props => ( {props.children} @@ -27,7 +29,10 @@ function createAuthWrapper(queryClient: ReturnType describe("AuthProvider", () => { it("authorizes requests after restoring the initial session", async () => { - let authorization: null | string = /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null; + let authorization: null | string + // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + = null; + server.use( http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ @@ -37,11 +42,13 @@ describe("AuthProvider", () => { ), http.get("*/api/v1/brokerage-accounts", ({ request }) => { authorization = request.headers.get("Authorization"); + return HttpResponse.json(resource([])); }), ); const queryClient = createTestQueryClient(); const wrapper: FC = createAuthWrapper(queryClient); + const { rerender, result } = renderHook( () => ({ ...useAuth(), queryClient: useQueryClient() }), { wrapper }, @@ -80,6 +87,7 @@ describe("AuthProvider", () => { server.use( http.post("*/api/v1/auth/refresh", () => { refreshCalls += 1; + return refreshCalls === 1 ? HttpResponse.json({ accessToken: "restored-token", @@ -119,15 +127,18 @@ describe("AuthProvider", () => { ), ); const queryClient = createTestQueryClient(); + const statusKey = apiQueryClient.queryOptions( "get", "/api/v1/tbank-connection", ).queryKey; + const portfolioKey = apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId: "account-1" } } }, ).queryKey; + queryClient.setQueryData(statusKey, { connected: true, connectedAt: "2026-08-14T00:00:00Z", @@ -157,7 +168,9 @@ describe("AuthProvider", () => { ])( "serializes late refresh (failure=%s, login=%s)", async (failure, loginAfterLogout) => { - const { promise: pending, resolve: release } = Promise.withResolvers(); + const { promise: pending, resolve: release } + = Promise.withResolvers(); + let calls = 0; const order: string[] = []; server.use( @@ -167,6 +180,7 @@ describe("AuthProvider", () => { await pending; order.push("refresh-cookie"); } + return failure && calls > 1 ? HttpResponse.error() : HttpResponse.json({ @@ -176,10 +190,12 @@ describe("AuthProvider", () => { }), http.post("*/api/v1/auth/logout", () => { order.push("logout-cookie"); + return new HttpResponse(null, { status: 204 }); }), http.post("*/api/v1/auth/login", () => { order.push("login-cookie"); + return HttpResponse.json({ accessToken: "b", user: { email: "b@example.com", id: "b" }, @@ -193,11 +209,13 @@ describe("AuthProvider", () => { const wrapper: FC = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.ready).toBe(true)); + const oldRequest = apiClient .GET("/api/v1/brokerage-accounts") // eslint-disable-next-line unicorn/prefer-await -- Attach rejection handling before releasing the deliberately pending query. .catch(() => {}); + await waitFor(() => expect(order).toContain("refresh-start")); let logout!: Promise; act(() => { @@ -214,8 +232,14 @@ describe("AuthProvider", () => { release(); await Promise.all([oldRequest, logout, login]); }); - expect(result.current.user?.id ?? /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null).toBe( - loginAfterLogout ? "b" : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, + expect( + result.current.user?.id + // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + ?? null, + ).toBe( + loginAfterLogout + ? "b" + : null, // eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. ); expect(order).toEqual([ "refresh-start", @@ -244,17 +268,20 @@ describe("AuthProvider", () => { await act(() => result.current.refresh()); expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]); let release!: (value: string) => void; + const pending = queryClient .query({ queryFn: () => { const deferred = Promise.withResolvers(); release = deferred.resolve; + return deferred.promise; }, queryKey: ["pending-finance"], }) // eslint-disable-next-line unicorn/prefer-await -- Attach rejection handling before releasing the deliberately pending query. .catch(() => {}); + identity = "b"; await act(() => result.current.refresh()); release("a-data"); @@ -278,6 +305,7 @@ describe("AuthProvider", () => { ); const queryClient = createTestQueryClient(); const wrapper: FC = createAuthWrapper(queryClient); + const { result } = renderHook( () => ({ auth: useAuth(), @@ -289,6 +317,7 @@ describe("AuthProvider", () => { }), { wrapper }, ); + await waitFor(() => expect(result.current.cards.data?.[0].id).toBe("a-card"), ); diff --git a/src/features/auth/model/auth.tsx b/src/features/auth/model/auth.tsx index da42a57..dfe2a45 100644 --- a/src/features/auth/model/auth.tsx +++ b/src/features/auth/model/auth.tsx @@ -1,157 +1,12 @@ import type { FC, PropsWithChildren } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { apiClient, configureAuthClient, type paths } from "@/shared/api"; - -import { AuthContext, type User } from "./auth-context"; - -type Authentication - = paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"]; -type Credentials - = paths["/api/v1/auth/login"]["post"]["requestBody"]["content"]["application/json"]; -async function request( - path: "/api/v1/auth/login" | "/api/v1/auth/register", - email: string, - password: string, -): Promise { - const { data, error } = await apiClient.POST(path, { - body: { email, password }, - } satisfies { body: Credentials }); - // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. - if (error) throw error; - return data; -} +import { AuthContext } from "./auth-context"; +import { useAuthProviderVm } from "./auth.vm"; const AuthProvider: FC = (props) => { - const { children } = props; - const queryClient = useQueryClient(); - const [user, setUser] = useState(undefined); - const [accessToken, setAccessToken] = useState(undefined); - const accessTokenRef = useRef(undefined); - const [ready, setReady] = useState(false); + const value = useAuthProviderVm(); - const generationRef = useRef(0); - const userRef = useRef(undefined); - const authQueueRef = useRef>(Promise.resolve()); - const refreshPromiseRef = useRef | undefined>(undefined); - // Cookie-writing requests share ordering, including across tabs with Web Locks. - const serialize = useCallback((run: () => Promise): Promise => { - // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. - const next = authQueueRef.current.then(async () => - "locks" in navigator - ? navigator.locks.request("wealth-ledger-refresh", run) - : run(), - ); - // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. - authQueueRef.current = next.catch(() => {}); - return next; - }, []); - const applyAuthentication = useCallback( - (authentication: Authentication) => { - const changedIdentity - = userRef.current && userRef.current.id !== authentication.user.id; - accessTokenRef.current = authentication.accessToken; - if (changedIdentity) { - generationRef.current += 1; - queryClient.removeQueries({ type: "inactive" }); - queryClient.getMutationCache().clear(); - void queryClient.resetQueries(); - } - userRef.current = authentication.user; - setUser(authentication.user); - setAccessToken(authentication.accessToken); - }, - [queryClient], - ); - const clearSession = useCallback(() => { - generationRef.current += 1; - userRef.current = undefined; - setUser(undefined); - setAccessToken(undefined); - accessTokenRef.current = undefined; - queryClient.clear(); - }, [queryClient]); - - const refresh = useCallback((): Promise => { - if (refreshPromiseRef.current) return refreshPromiseRef.current; - const started = generationRef.current; - const pending = serialize(async () => { - if (started !== generationRef.current) return false; - try { - const { data } = await apiClient.POST("/api/v1/auth/refresh"); - if (started !== generationRef.current) return false; - if (!data) { - clearSession(); - return false; - } - applyAuthentication(data); - return true; - } - catch { - if (started === generationRef.current) clearSession(); - return false; - } - // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. - }).finally(() => { - if (refreshPromiseRef.current === pending) refreshPromiseRef.current = undefined; - }); - refreshPromiseRef.current = pending; - return pending; - }, [applyAuthentication, clearSession, serialize]); - - useEffect(() => { - void refresh().finally(() => setReady(true)); - }, [refresh]); - - async function authenticate( - path: "/api/v1/auth/login" | "/api/v1/auth/register", - email: string, - password: string, - ) { - clearSession(); - const started = generationRef.current; - await serialize(async () => { - const authentication = await request(path, email, password); - if (started === generationRef.current) applyAuthentication(authentication); - }); - } - - async function logout() { - clearSession(); - await serialize(async () => { - await apiClient.POST("/api/v1/auth/logout"); - }); - } - - useEffect(() => { - configureAuthClient({ - accessToken: () => accessTokenRef.current, - clear: clearSession, - generation: () => generationRef.current, - refresh, - }); - }); - useEffect(() => () => configureAuthClient(), []); - - return ( - - authenticate("/api/v1/auth/login", email, password), - logout, - ready, - refresh, - register: (email, password) => - authenticate("/api/v1/auth/register", email, password), - user, - }} - > - {children} - - ); + return {props.children}; }; export { AuthProvider }; diff --git a/src/features/auth/model/auth.vm.ts b/src/features/auth/model/auth.vm.ts new file mode 100644 index 0000000..cb757bd --- /dev/null +++ b/src/features/auth/model/auth.vm.ts @@ -0,0 +1,173 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { apiClient, configureAuthClient, type paths } from "@/shared/api"; + +import { type User } from "./auth-context"; + +type Authentication + = paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"]; + +type Credentials + = paths["/api/v1/auth/login"]["post"]["requestBody"]["content"]["application/json"]; + +async function request( + path: "/api/v1/auth/login" | "/api/v1/auth/register", + email: string, + password: string, +): Promise { + const { data, error } = await apiClient.POST(path, { + body: { email, password }, + } satisfies { body: Credentials }); + + // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. + if (error) throw error; + + return data; +} + +function useAuthProviderVm() { + const queryClient = useQueryClient(); + const [user, setUser] = useState(undefined); + const [accessToken, setAccessToken] = useState(undefined); + const accessTokenRef = useRef(undefined); + const [ready, setReady] = useState(false); + + const generationRef = useRef(0); + const userRef = useRef(undefined); + const authQueueRef = useRef>(Promise.resolve()); + const refreshPromiseRef = useRef | undefined>(undefined); + + // Cookie-writing requests share ordering, including across tabs with Web Locks. + const serialize = useCallback((run: () => Promise): Promise => { + // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. + const next = authQueueRef.current.then(async () => + "locks" in navigator + ? navigator.locks.request("wealth-ledger-refresh", run) + : run(), + ); + + // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. + authQueueRef.current = next.catch(() => {}); + + return next; + }, []); + + const applyAuthentication = useCallback( + (authentication: Authentication) => { + const changedIdentity + = userRef.current && userRef.current.id !== authentication.user.id; + + accessTokenRef.current = authentication.accessToken; + + if (changedIdentity) { + generationRef.current += 1; + queryClient.removeQueries({ type: "inactive" }); + queryClient.getMutationCache().clear(); + void queryClient.resetQueries(); + } + + userRef.current = authentication.user; + setUser(authentication.user); + setAccessToken(authentication.accessToken); + }, + [queryClient], + ); + + const clearSession = useCallback(() => { + generationRef.current += 1; + userRef.current = undefined; + setUser(undefined); + setAccessToken(undefined); + accessTokenRef.current = undefined; + queryClient.clear(); + }, [queryClient]); + + const refresh = useCallback((): Promise => { + if (refreshPromiseRef.current) return refreshPromiseRef.current; + const started = generationRef.current; + + const pending = serialize(async () => { + if (started !== generationRef.current) return false; + + try { + const { data } = await apiClient.POST("/api/v1/auth/refresh"); + if (started !== generationRef.current) return false; + + if (!data) { + clearSession(); + + return false; + } + + applyAuthentication(data); + + return true; + } + catch { + if (started === generationRef.current) clearSession(); + + return false; + } + // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. + }).finally(() => { + if (refreshPromiseRef.current === pending) + refreshPromiseRef.current = undefined; + }); + + refreshPromiseRef.current = pending; + + return pending; + }, [applyAuthentication, clearSession, serialize]); + + useEffect(() => { + void refresh().finally(() => setReady(true)); + }, [refresh]); + + async function authenticate( + path: "/api/v1/auth/login" | "/api/v1/auth/register", + email: string, + password: string, + ) { + clearSession(); + const started = generationRef.current; + await serialize(async () => { + const authentication = await request(path, email, password); + if (started === generationRef.current) + applyAuthentication(authentication); + }); + } + + async function logout() { + clearSession(); + await serialize(async () => { + await apiClient.POST("/api/v1/auth/logout"); + }); + } + + useEffect(() => { + configureAuthClient({ + accessToken: () => accessTokenRef.current, + clear: clearSession, + generation: () => generationRef.current, + refresh, + }); + }); + useEffect(() => () => configureAuthClient(), []); + + const authContextValue = { + accessToken, + login: (email: string, password: string) => + authenticate("/api/v1/auth/login", email, password), + logout, + ready, + refresh, + register: (email: string, password: string) => + authenticate("/api/v1/auth/register", email, password), + user, + }; + + return authContextValue; +} + +export { useAuthProviderVm }; diff --git a/src/features/auth/ui/AuthForm/AuthForm.tsx b/src/features/auth/ui/AuthForm/AuthForm.tsx index 4b1c560..5e90388 100644 --- a/src/features/auth/ui/AuthForm/AuthForm.tsx +++ b/src/features/auth/ui/AuthForm/AuthForm.tsx @@ -11,7 +11,7 @@ import { type AuthFormValues, loginSchema, registerSchema, -} from "../../model/validation"; +} from "../../model/auth.schema"; import classes from "./AuthForm.module.css"; type AuthFormProps = { @@ -23,26 +23,33 @@ const AuthForm: FC = (props) => { const { onSubmit, register } = props; const { t } = useTranslation(); const schema = register ? registerSchema : loginSchema; + const methods = useForm({ resolver: zodResolver(schema) as Resolver, }); + const { formState: { errors, isSubmitting }, } = methods; + const [submitError, setSubmitError] = useState(null); + + const onSubmit2 = methods.handleSubmit(async (values) => { + setSubmitError(null); + + try { + await onSubmit(values.email, values.password); + } + catch { + setSubmitError(t("auth.requestError")); + } + }); + + const to = register ? "/login" : "/register"; + return ( - { - setSubmitError(null); - try { - await onSubmit(values.email, values.password); - } - catch { - setSubmitError(t("auth.requestError")); - } - })} - > +
= (props) => { {...methods.register("email")} error={errors.email?.message as string} /> + + {register && ( = (props) => { error={errors.passwordConfirmation?.message as string} /> )} + {submitError && {submitError}} + - diff --git a/src/features/auth/ui/AuthPageLayout/AuthPageLayout.tsx b/src/features/auth/ui/AuthPageLayout/AuthPageLayout.tsx index d96773f..1068808 100644 --- a/src/features/auth/ui/AuthPageLayout/AuthPageLayout.tsx +++ b/src/features/auth/ui/AuthPageLayout/AuthPageLayout.tsx @@ -8,11 +8,13 @@ type AuthPageLayoutProps = PropsWithChildren<{ title: string }>; const AuthPageLayout: FC = (props) => { const { children, title } = props; + return (
{title} + {children} diff --git a/src/features/auth/ui/LoginForm.tsx b/src/features/auth/ui/LoginForm.tsx deleted file mode 100644 index 0a55834..0000000 --- a/src/features/auth/ui/LoginForm.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { FC } from "react"; - -import { useLocation, useNavigate } from "react-router"; - -import { useAuth } from "../model/auth-context"; -import { AuthForm } from "./AuthForm/AuthForm"; - -const LoginForm: FC = () => { - const { login } = useAuth(); - const navigate = useNavigate(); - const location = useLocation(); - const returnTo - = (location.state as null | { returnTo?: string })?.returnTo ?? "/accounts"; - - return ( - { - await login(email, password); - await navigate(returnTo, { replace: true }); - }} - /> - ); -}; - -export { LoginForm }; diff --git a/src/features/auth/ui/LoginForm/LoginForm.tsx b/src/features/auth/ui/LoginForm/LoginForm.tsx new file mode 100644 index 0000000..d3a6cea --- /dev/null +++ b/src/features/auth/ui/LoginForm/LoginForm.tsx @@ -0,0 +1,12 @@ +import type { FC } from "react"; + +import { AuthForm } from "../AuthForm/AuthForm"; +import { useLoginFormVm } from "./LoginForm.vm"; + +const LoginForm: FC = () => { + const { onSubmit } = useLoginFormVm(); + + return ; +}; + +export { LoginForm }; diff --git a/src/features/auth/ui/LoginForm/LoginForm.vm.ts b/src/features/auth/ui/LoginForm/LoginForm.vm.ts new file mode 100644 index 0000000..0392611 --- /dev/null +++ b/src/features/auth/ui/LoginForm/LoginForm.vm.ts @@ -0,0 +1,28 @@ +import type { ComponentProps } from "react"; + +import { useLocation, useNavigate } from "react-router"; + +import { useAuth } from "../../model/auth-context"; +import { AuthForm } from "../AuthForm/AuthForm"; + +function useLoginFormVm() { + const { login } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const returnTo + = (location.state as null + | { returnTo?: string })?.returnTo ?? "/accounts"; + + const onSubmit: ComponentProps["onSubmit"] = async ( + email, + password, + ) => { + await login(email, password); + await navigate(returnTo, { replace: true }); + }; + + return { onSubmit }; +} + +export { useLoginFormVm }; diff --git a/src/features/auth/ui/RegisterForm.tsx b/src/features/auth/ui/RegisterForm.tsx deleted file mode 100644 index 61afca4..0000000 --- a/src/features/auth/ui/RegisterForm.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { FC } from "react"; - -import { useLocation, useNavigate } from "react-router"; - -import { useAuth } from "../model/auth-context"; -import { AuthForm } from "./AuthForm/AuthForm"; - -const RegisterForm: FC = () => { - const { register } = useAuth(); - const navigate = useNavigate(); - const location = useLocation(); - const returnTo - = (location.state as null | { returnTo?: string })?.returnTo ?? "/accounts"; - - return ( - { - await register(email, password); - await navigate(returnTo, { replace: true }); - }} - register - /> - ); -}; - -export { RegisterForm }; diff --git a/src/features/auth/ui/RegisterForm/RegisterForm.tsx b/src/features/auth/ui/RegisterForm/RegisterForm.tsx new file mode 100644 index 0000000..9832dd1 --- /dev/null +++ b/src/features/auth/ui/RegisterForm/RegisterForm.tsx @@ -0,0 +1,12 @@ +import type { FC } from "react"; + +import { AuthForm } from "../AuthForm/AuthForm"; +import { useRegisterFormVm } from "./RegisterForm.vm"; + +const RegisterForm: FC = () => { + const { onSubmit } = useRegisterFormVm(); + + return ; +}; + +export { RegisterForm }; diff --git a/src/features/auth/ui/RegisterForm/RegisterForm.vm.ts b/src/features/auth/ui/RegisterForm/RegisterForm.vm.ts new file mode 100644 index 0000000..35a3235 --- /dev/null +++ b/src/features/auth/ui/RegisterForm/RegisterForm.vm.ts @@ -0,0 +1,28 @@ +import type { ComponentProps } from "react"; + +import { useLocation, useNavigate } from "react-router"; + +import { useAuth } from "../../model/auth-context"; +import { AuthForm } from "../AuthForm/AuthForm"; + +function useRegisterFormVm() { + const { register } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const returnTo + = (location.state as null + | { returnTo?: string })?.returnTo ?? "/accounts"; + + const onSubmit: ComponentProps["onSubmit"] = async ( + email, + password, + ) => { + await register(email, password); + await navigate(returnTo, { replace: true }); + }; + + return { onSubmit, register }; +} + +export { useRegisterFormVm }; diff --git a/src/features/instrument-details/index.ts b/src/features/instrument-details/index.ts index a8df803..2cd23d8 100644 --- a/src/features/instrument-details/index.ts +++ b/src/features/instrument-details/index.ts @@ -1,6 +1,6 @@ export { section } from "./model/section"; export { useInstrumentOperations } from "./model/useInstrumentOperations"; -export { InstrumentChart } from "./ui/InstrumentChart.view"; +export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view"; export { InstrumentEvents } from "./ui/InstrumentEvents.view"; export { InstrumentPosition } from "./ui/InstrumentPosition.view"; export { InstrumentSection } from "./ui/InstrumentSection.view"; diff --git a/src/features/instrument-details/model/section.test.ts b/src/features/instrument-details/model/section.test.ts index d32f363..b2ff22d 100644 --- a/src/features/instrument-details/model/section.test.ts +++ b/src/features/instrument-details/model/section.test.ts @@ -9,6 +9,7 @@ describe("instrument source and transport presentation", () => { "keeps %s separate from successful transport", (availability) => { const data = availability === "missing" ? undefined : { rows: [] }; + const state = section({ data, isError: false, @@ -16,6 +17,7 @@ describe("instrument source and transport presentation", () => { meta: resource(data, { availability }).meta, refetch: vi.fn(), }); + expect(state.loading).toBe(availability === "missing"); expect(state.partial).toBe(availability === "partial"); expect(state.hasData).toBe(availability !== "missing"); @@ -24,13 +26,16 @@ describe("instrument source and transport presentation", () => { it("preserves a full fresh source and timestamp when force fails, while offering scoped retry", () => { const retry = vi.fn(); const refetch = vi.fn(); + const meta = resource([], { sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" }, }).meta; + const state = section( { data: [], isError: false, isFetching: false, meta, refetch }, retry, ); + expect(state).toMatchObject({ error: true, hasData: true, @@ -43,17 +48,16 @@ describe("instrument source and transport presentation", () => { expect(refetch).not.toHaveBeenCalled(); }); it("shows source failure without a false loading or empty result", () => { - const state = section(( - { - data: undefined, - isError: false, - isFetching: false, - meta: resource(null, { - sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" }, - }).meta, - refetch: vi.fn(), - } - )); + const state = section({ + data: undefined, + isError: false, + isFetching: false, + meta: resource(null, { + sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" }, + }).meta, + refetch: vi.fn(), + }); + expect(state).toMatchObject({ error: true, hasData: false, diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts index a1a9161..386ac17 100644 --- a/src/features/instrument-details/model/section.ts +++ b/src/features/instrument-details/model/section.ts @@ -2,6 +2,7 @@ import type { paths } from "@/shared/api"; type Meta = paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number]; + type SectionState = { error: boolean; hasData: boolean; @@ -13,6 +14,7 @@ type SectionState = { sourceError?: boolean; stale?: boolean; }; + function section( query: { data: unknown; @@ -24,6 +26,7 @@ function section( retry?: () => unknown, ): SectionState { const isError = query.isError || query.meta?.sync.state === "failed"; + return { error: isError, hasData: Boolean(query.data), diff --git a/src/features/instrument-details/model/types.ts b/src/features/instrument-details/model/types.ts index 959561f..9ff7ecb 100644 --- a/src/features/instrument-details/model/types.ts +++ b/src/features/instrument-details/model/types.ts @@ -6,7 +6,9 @@ import type { SectionState } from "./section"; type Candles = NonNullable< paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"] >; + type ChartRow = Candles["candles"][number] & { date: string; price: number }; + type InstrumentChartVm = { averageLine: null | number; candles?: Candles; @@ -18,6 +20,7 @@ type InstrumentChartVm = { priceRows: ChartRow[]; setPeriod: (value: string) => void; }; + type InstrumentEventsVm = { eventRows: BrokerageOperation[]; filter: string; @@ -28,10 +31,12 @@ type InstrumentEventsVm = { operationsState: SectionState; setFilter: (value: string) => void; }; + type InstrumentPositionVm = { position?: Position; positionState: SectionState; }; + type Position = NonNullable< paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"] >; diff --git a/src/features/instrument-details/model/useInstrumentOperations.ts b/src/features/instrument-details/model/useInstrumentOperations.ts index 1eca40f..d67b5bf 100644 --- a/src/features/instrument-details/model/useInstrumentOperations.ts +++ b/src/features/instrument-details/model/useInstrumentOperations.ts @@ -14,19 +14,28 @@ function useInstrumentOperations( categories: OperationCategory[], ) { const scope = `${uid}:${filter}`; + const [pagination, setPagination] = useState<{ cursors: string[]; scope: string; }>({ cursors: [], scope }); + const cursors = pagination.scope === scope ? pagination.cursors : []; const client = useQueryClient(); const firstPage = { categories, instrumentUid: uid, limit: 20 }; + const operations = useBrokerageOperations(uid ? "all" : undefined, { ...firstPage, cursor: cursors.at(-1), }); + const retry = (retryScreen: () => unknown) => { - if (isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor)) { + if ( + isApiErrorCode( + apiErrorCode(operations.error), + ApiErrorCode.InvalidOperationsCursor, + ) + ) { setPagination({ cursors: [], scope }); void client.invalidateQueries({ exact: true, @@ -35,6 +44,7 @@ function useInstrumentOperations( } else void retryScreen(); }; + return { hasPrevious: cursors.length > 0, onNext: () => { diff --git a/src/features/instrument-details/ui/InstrumentChart.view.tsx b/src/features/instrument-details/ui/InstrumentChart.view.tsx deleted file mode 100644 index de6036c..0000000 --- a/src/features/instrument-details/ui/InstrumentChart.view.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import type { FC } from "react"; - -import { AreaChart } from "@mantine/charts"; -import { - Accordion, - Badge, - ScrollArea, - SegmentedControl, - Table, - Text, -} from "@mantine/core"; -import { useTranslation } from "react-i18next"; - -import { formatDecimal, formatMoney } from "@/shared/lib"; - -import type { InstrumentChartVm } from "../model/types"; - -import classes from "./InstrumentDetails.module.css"; -import { InstrumentSection } from "./InstrumentSection.view"; - -type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm }; -const InstrumentChart: FC = (props) => { - const { vm } = props; - const { t } = useTranslation(); - const priceLabel = (value: string) => { - if (props.priceUnit === "percent") return `${formatDecimal(value)} %`; - const currency = vm.candles?.currency; - return formatMoney(currency ? { currency, value } : undefined); - }; - - return ( - - ({ - label: t(`stock.periods.${value}`), - value, - }))} - onChange={vm.setPeriod} - value={vm.period} - /> -
- )} - large - state={vm.candlesState} - title={t("stock.chart")} - > - {!!(vm.candles) && (( - vm.chartData.length > 0 - ? ( - <> - - new Intl.NumberFormat("ru-RU", { - maximumFractionDigits: 4, - }).format(value)} - withDots={vm.chartData.length === 1} - withGradient - yAxisProps={{ domain: ["auto", "auto"], width: 64 }} - /> - - {t( - props.priceUnit === "percent" - ? "bond.chartNote" - : "stock.chartNote", - )} - - {!!(vm.hasIncompleteCandle) && (( - - {t("stock.incomplete")} - - ))} - {vm.averageLine !== null && (( - - {t("stock.averageLine")} - : - {" "} - {formatMoney(vm.position?.averagePurchasePrice)} - - ))} - - - - {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} - - {priceLabel(candle.close)} - - - - {t( - candle.isComplete - ? "stock.complete" - : "stock.forming", - )} - - - - ))} - -
-
-
-
-
- - ) - : ( - - {t( - vm.candlesState.partial - ? "investment.partial" - : "stock.noCandles", - )} - - ) - ))} - - ); -}; - -export { InstrumentChart }; diff --git a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx new file mode 100644 index 0000000..7aa826c --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx @@ -0,0 +1,195 @@ +import type { FC } from "react"; + +import { AreaChart } from "@mantine/charts"; +import { + Accordion, + Badge, + ScrollArea, + SegmentedControl, + Table, + Text, +} from "@mantine/core"; + +import { formatMoney } from "@/shared/lib"; + +import type { InstrumentChartVm } from "../../model/types"; + +import classes from "../InstrumentDetails.module.css"; +import { InstrumentSection } from "../InstrumentSection.view"; +import { useInstrumentChartVm } from "./InstrumentChart.vm"; + +type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm }; + +const InstrumentChart: FC = (props) => { + const { + chartLabel, + formatChartValue, + periodOptions, + priceRows, + referenceLines, + t, + vm, + } = useInstrumentChartVm(props); + + let chartContent; + + if (vm.candles) { + if (vm.chartData.length > 0) { + chartContent = ( + <> + + + + {t( + props.priceUnit === "percent" + ? "bond.chartNote" + : "stock.chartNote", + )} + + + {!!vm.hasIncompleteCandle && ( + + {t("stock.incomplete")} + + )} + + {vm.averageLine !== null && ( + + {t("stock.averageLine")}:{" "} + {formatMoney(vm.position?.averagePurchasePrice)} + + )} + + + + + {t("stock.priceTable")} + + + {t("stock.priceTableHint", { count: vm.chartData.length })} + + + + + + + + + {t("stock.date")} + + {t("stock.price")} + + {t("stock.candle")} + + + + + {priceRows.map((candle) => { + const displayColor = candle.isComplete + ? "gray" + : "blue"; + + return ( + + {candle.date} + + + {candle.price} + + + + + {t( + candle.isComplete + ? "stock.complete" + : "stock.forming", + )} + + + + ); + })} + +
+
+
+
+
+ + ); + } + else { + chartContent = ( + + {t( + vm.candlesState.partial + ? "investment.partial" + : "stock.noCandles", + )} + + ); + } + } + + return ( + + +
+ )} + large + state={vm.candlesState} + title={t("stock.chart")} + > + {chartContent} + + ); +}; + +export { InstrumentChart }; diff --git a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts new file mode 100644 index 0000000..02c6d0c --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts @@ -0,0 +1,64 @@ +import { useTranslation } from "react-i18next"; + +import { formatChartValue, formatDecimal, formatMoney } from "@/shared/lib"; + +import type { InstrumentChartVm } from "../../model/types"; + +type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm }; + +function useInstrumentChartVm(props: InstrumentChartProps) { + const { vm } = props; + const { t } = useTranslation(); + + const priceRows = vm.priceRows.map((candle) => { + const currency = vm.candles?.currency; + const value = candle.close; + + const amount = currency ? { currency, value } : undefined; + + const price = props.priceUnit === "percent" + ? `${formatDecimal(value)} %` + : formatMoney(amount); + + return { ...candle, price }; + }); + + const periodOptions = ["month", "halfYear", "year", "all"].map(value => ({ + label: t(`stock.periods.${value}`), + value, + })); + + const chartLabel = t( + props.priceUnit === "percent" ? "bond.chartLabel" : "stock.chartLabel", + { + currency: + props.priceUnit === "percent" + ? t("bond.percentOfNominal") + : (vm.candles?.currency ?? ""), + }, + ); + + const referenceLines + = vm.averageLine === null + ? [] + : [ + { + color: "orange.7", + ifOverflow: "extendDomain" as const, + label: t("stock.averageLine"), + y: vm.averageLine, + }, + ]; + + return { + chartLabel, + formatChartValue, + periodOptions, + priceRows, + referenceLines, + t, + vm, + }; +} + +export { useInstrumentChartVm }; diff --git a/src/features/instrument-details/ui/InstrumentEvents.view.tsx b/src/features/instrument-details/ui/InstrumentEvents.view.tsx index df9de31..6d6b75d 100644 --- a/src/features/instrument-details/ui/InstrumentEvents.view.tsx +++ b/src/features/instrument-details/ui/InstrumentEvents.view.tsx @@ -20,25 +20,54 @@ type InstrumentEventsProps = { const InstrumentEvents: FC = (props) => { const { vm } = props; const { t } = useTranslation(); + + const options = [ + "all", + "trades", + props.incomeFilter ?? "dividends", + "other", + ].map(value => ({ + label: + value === (props.incomeFilter ?? "dividends") && props.incomeLabel + ? props.incomeLabel + : t(`stock.filters.${value}`), + value, + })); + + let eventsContent; + + if (vm.eventRows.length > 0) { + eventsContent = ( + + {vm.eventRows.map(event => ( + + ))} + + ); + } + else { + eventsContent = ( + + {t( + vm.operationsState.partial + ? "investment.partial" + : "stock.noOperations", + )} + + ); + } + return ( ({ - label: - value === (props.incomeFilter ?? "dividends") - && props.incomeLabel - ? props.incomeLabel - : t(`stock.filters.${value}`), - value, - }))} + data={options} onChange={vm.setFilter} value={vm.filter} /> @@ -47,32 +76,14 @@ const InstrumentEvents: FC = (props) => { state={vm.operationsState} title={t("stock.history")} > - {!!(vm.operations) && (( + {!!vm.operations && ( <> {props.historyNote ?? t("stock.historyNote")} - {vm.eventRows.length > 0 - ? ( - - {vm.eventRows.map(event => ( - - ))} - - ) - : ( - - {t( - vm.operationsState.partial - ? "investment.partial" - : "stock.noOperations", - )} - - )} + + {eventsContent} + + - ))} + )} ); }; diff --git a/src/features/instrument-details/ui/InstrumentPosition.view.tsx b/src/features/instrument-details/ui/InstrumentPosition.view.tsx index f4abd37..6c827ec 100644 --- a/src/features/instrument-details/ui/InstrumentPosition.view.tsx +++ b/src/features/instrument-details/ui/InstrumentPosition.view.tsx @@ -16,130 +16,148 @@ type InstrumentPositionProps = { returnNote?: string; vm: InstrumentPositionVm; }; + const InstrumentPosition: FC = (props) => { const { vm } = props; const { t } = useTranslation(); if (vm.position && vm.position.accounts.length === 0) return; + + const positionReturnColor = vm.position?.absoluteReturn?.value.startsWith("-") + ? "red.8" + : "green.8"; + return ( - {!!(vm.position) && (( + {!!vm.position && ( - {!!(vm.position.isStale) && (( + {!!vm.position.isStale && ( {t("portfolio.staleWarning")} - ))} + )} +
{props.quantityLabel ?? 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 && (( + + {vm.position.returnPercent !== null && ( - {formatDecimal(vm.position.returnPercent)} - {" "} - % + {formatDecimal(vm.position.returnPercent)} % - ))} + )}
+ {props.returnNote ?? t("stock.openReturnNote")} +
- {vm.position.accounts.map(account => ( -
- {account.account - ? ( - - {account.account.name} - - ) - : ( - {t("stock.portfolio")} - )} -
- - {props.quantityLabel ?? 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)} - {" "} - % + {vm.position.accounts.map((account) => { + const accountReturnColor = account.absoluteReturn?.value.startsWith("-") + ? "red.8" + : undefined; + + return ( +
+ {account.account + ? ( + + {account.account.name} + + ) + : ( + {t("stock.portfolio")} + )} + +
+ + {props.quantityLabel ?? 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)} % + + )} +
-
- ))} + ); + })}
+ {t("stock.snapshotAt", { date: formatSnapshotTime(vm.position.observedAt), })} - ))} + )} ); }; diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx index e021b77..9f3e9d7 100644 --- a/src/features/instrument-details/ui/InstrumentSection.view.tsx +++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx @@ -29,12 +29,46 @@ type InstrumentSectionProps = PropsWithChildren<{ state: SectionState; title: string; }>; + const InstrumentSection: FC = (props) => { const { t } = useTranslation(); - const readError = t(props.state.hasData ? "accounts.refreshError" : "stock.sectionError"); + + const readError = t( + props.state.hasData ? "accounts.refreshError" : "stock.sectionError", + ); + + const sectionClassName = props.embedded + ? classes.embeddedSection + : classes.section; + + const skeletonHeight = props.large + ? CHART_SKELETON_HEIGHT_PX + : SECTION_SKELETON_HEIGHT_PX; + + let sectionContent; + + if (props.state.loading) { + sectionContent = ( + + + + + + + + ); + } + else { + sectionContent = ( + props.children + ); + } + return ( @@ -42,34 +76,37 @@ const InstrumentSection: FC = (props) => { {props.title} + {props.controls} - {!!(props.state.stale) && (( - {t("investment.stale")} - ))} - {!!(props.state.partial) && (( + + {!!props.state.stale && {t("investment.stale")}} + + {!!props.state.partial && ( {t("investment.partial")} - ))} - {!!(props.state.lastSuccessfulAt) && (( + )} + + {!!props.state.lastSuccessfulAt && ( {t("investment.sourceTime", { date: formatSnapshotTime(props.state.lastSuccessfulAt), })} - ))} - {!!(props.state.refreshing) && (( + )} + + {!!props.state.refreshing && ( {t("accounts.refreshing")} - ))} - {!!(props.state.error) && (( + )} + + {!!props.state.error && ( - {props.state.sourceError - ? t("investment.syncFailed") - : readError} + {props.state.sourceError ? t("investment.syncFailed") : readError} + - ))} - {props.state.loading - ? ( - - - - - - - ) - : ( - props.children - )} + )} + + {sectionContent} ); }; diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts index 5e23015..a25a7fe 100644 --- a/src/features/tbank-connection/model/connection-boundary.ts +++ b/src/features/tbank-connection/model/connection-boundary.ts @@ -31,6 +31,7 @@ function commitTbankConnectionBoundary( void queryClient.resetQueries({ predicate: ({ queryKey }) => { const [method, path] = queryKey; + return ( method === "get" && typeof path === "string" diff --git a/src/features/tbank-connection/model/screen-sync.test.tsx b/src/features/tbank-connection/model/screen-sync.test.tsx index fec1d07..d1a223a 100644 --- a/src/features/tbank-connection/model/screen-sync.test.tsx +++ b/src/features/tbank-connection/model/screen-sync.test.tsx @@ -19,11 +19,14 @@ function auth() { refresh: () => Promise.resolve(true), }); } + describe("page-owned screen synchronization", () => { it("ignores a late sync acceptance after the account scope changes", async () => { auth(); - const { promise: blocked, resolve: release } = Promise.withResolvers(); + const { promise: blocked, resolve: release } + = Promise.withResolvers(); + const posted: string[] = []; const polled: string[] = []; server.use( @@ -32,8 +35,10 @@ describe("page-owned screen synchronization", () => { accountId: string; screen: "account"; }; + posted.push(scope.accountId); await blocked; + return HttpResponse.json( { id: scope.accountId, now: "2026-09-25T00:00:00Z", scope }, { status: 202 }, @@ -41,6 +46,7 @@ describe("page-owned screen synchronization", () => { }), http.get("*/api/v1/tbank-sync/:id", ({ params }) => { polled.push(String(params.id)); + return HttpResponse.json({ id: params.id, resources: [], @@ -48,6 +54,7 @@ describe("page-owned screen synchronization", () => { }); }), ); + const { rerender, result } = renderHook( ({ accountId }) => useScreenSync({ accountId, screen: "account" }, []), { @@ -55,6 +62,7 @@ describe("page-owned screen synchronization", () => { wrapper: createQueryWrapper().wrapper, }, ); + let pending!: Promise; act(() => { pending = result.current.refresh(); @@ -79,6 +87,7 @@ describe("page-owned screen synchronization", () => { server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { modes.push(new URL(request.url).searchParams.get("sync")); + return isUnavailable ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }) : HttpResponse.json( @@ -87,14 +96,17 @@ describe("page-owned screen synchronization", () => { }), http.post("*/api/v1/tbank-sync", () => { posts += 1; + return new HttpResponse(null, { status: 500 }); }), ); const { queryClient, wrapper } = createQueryWrapper(); + const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); const disabled = useStockQuote(""); + return { accounts, disabled, @@ -103,6 +115,7 @@ describe("page-owned screen synchronization", () => { }, { wrapper }, ); + if (cached) { await waitFor(() => // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. @@ -111,6 +124,7 @@ describe("page-owned screen synchronization", () => { isUnavailable = true; await act(() => result.current.accounts.refetch()); } + await waitFor(() => expect(result.current.accounts.isError).toBe(true)); isUnavailable = false; await act(() => result.current.sync.retry()); @@ -119,7 +133,14 @@ describe("page-owned screen synchronization", () => { cached ? "3" : "2", ); expect(modes).toEqual( - cached ? [null, /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, "observe"] : [null, "observe"], + cached + ? [ + null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture. + // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + null, + "observe", + ] + : [null, "observe"], // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture. ); expect(posts).toBe(0); expect(result.current.disabled.data).toBeUndefined(); @@ -141,7 +162,9 @@ describe("page-owned screen synchronization", () => { async (boundary, pending) => { auth(); - const { promise: blocked, resolve: release } = Promise.withResolvers(); + const { promise: blocked, resolve: release } + = Promise.withResolvers(); + let isRecovering = false; let isChanged = false; server.use( @@ -154,18 +177,22 @@ describe("page-owned screen synchronization", () => { : HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }); isRecovering = true; await blocked; + return HttpResponse.json(resource([], { stateVersion: "old" })); }), ); const { queryClient, wrapper } = createQueryWrapper(); const published: unknown[] = []; + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { if (event.type === "updated" && event.action.type === "success") published.push(event.query.state.data); }); + const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); + return { accounts, sync: useScreenSync({ screen: "accounts" }, [accounts]), @@ -173,6 +200,7 @@ describe("page-owned screen synchronization", () => { }, { wrapper }, ); + await waitFor(() => expect(result.current.accounts.isError).toBe(true)); let recovery!: Promise; act(() => { @@ -182,6 +210,7 @@ describe("page-owned screen synchronization", () => { if (pending) await waitFor(() => expect(isRecovering).toBe(true)); await act(async () => { isChanged = true; + switch (boundary) { case "access": { invalidateResourceAccess(queryClient, "brokerage", { @@ -190,24 +219,29 @@ describe("page-owned screen synchronization", () => { break; } + case "logout": { configureAuthClient(); queryClient.clear(); break; } + case "session": { auth(); queryClient.clear(); break; } - case "connection": { commitTbankConnectionBoundary(queryClient, { - connected: true, - connectedAt: "2026-09-27T00:00:00Z", - }); + + case "connection": { + commitTbankConnectionBoundary(queryClient, { + connected: true, + connectedAt: "2026-09-27T00:00:00Z", + }); } } + release(); await recovery; }); @@ -233,6 +267,7 @@ describe("page-owned screen synchronization", () => { server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { modes.push(new URL(request.url).searchParams.get("sync")); + return modes.length === 1 ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }) : HttpResponse.json(resource([])); @@ -242,6 +277,7 @@ describe("page-owned screen synchronization", () => { ), http.get("*/api/v1/tbank-sync/:id", () => { polls += 1; + return retryStatus && polls === 1 ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }) : HttpResponse.json({ @@ -252,10 +288,12 @@ describe("page-owned screen synchronization", () => { }), ); const { queryClient, wrapper } = createQueryWrapper(); + const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); const disabled = useStockQuote(""); + return { accounts, sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]), @@ -263,9 +301,11 @@ describe("page-owned screen synchronization", () => { }, { wrapper }, ); + await waitFor(() => expect(result.current.accounts.isError).toBe(true)); expect(result.current.accounts.meta).toBeUndefined(); await act(() => result.current.sync.refresh()); + if (retryStatus) { await waitFor(() => // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. @@ -273,6 +313,7 @@ describe("page-owned screen synchronization", () => { ); await act(() => result.current.sync.retry()); } + await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true)); expect(result.current.accounts.data).toEqual([]); // eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response. @@ -290,16 +331,20 @@ describe("page-owned screen synchronization", () => { const bodies: unknown[] = []; let isCompleted = false; let polls = 0; + const meta = () => resource([], { stateVersion: isCompleted ? "2" : "1" }).meta; + server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { modes.push(new URL(request.url).searchParams.get("sync")); + return HttpResponse.json(resource([], meta())); }), http.post("*/api/v1/tbank-sync", async ({ request }) => { bodies.push(await request.json()); isCompleted = true; + return HttpResponse.json( { id: "screen", @@ -311,7 +356,10 @@ describe("page-owned screen synchronization", () => { }), http.get("*/api/v1/tbank-sync/:id", () => { polls++; - return HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + + return HttpResponse.json( { errorCode: null, id: "screen", @@ -320,13 +368,16 @@ describe("page-owned screen synchronization", () => { resources: [meta()], scope: { screen: "accounts" }, state: "succeeded", - } - /* eslint-enable unicorn/no-null */)); + }, + /* eslint-enable unicorn/no-null */ + ); }), ); + const { result, unmount } = renderHook( () => { const accounts = useBrokerageAccounts(); + return { accounts, sync: useScreenSync({ screen: "accounts" }, [accounts]), @@ -334,6 +385,7 @@ describe("page-owned screen synchronization", () => { }, { wrapper: createQueryWrapper().wrapper }, ); + await waitFor(() => expect(result.current.accounts.data).toEqual([])); await act(async () => { await Promise.all([ @@ -348,6 +400,7 @@ describe("page-owned screen synchronization", () => { // eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response. expect(modes).toEqual([null, "observe"]); vi.useFakeTimers(); + try { await act(() => vi.advanceTimersByTimeAsync(120_000)); expect(polls).toBe(1); @@ -365,26 +418,36 @@ describe("page-owned screen synchronization", () => { "publishes completed empty coverage while history runs, then stops on %s", async (boundary) => { vi.useFakeTimers({ toFake: ["Date"] }); + try { auth(); let isCompleted = false; let polls = 0; const modes: (null | string)[] = []; + const snapshot = () => - resource(isCompleted ? [] : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( + isCompleted + ? [] + : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable API fixture. { stateVersion: isCompleted ? "covered" : "initial", - sync: { errorCode: null, id: "history", state: "running" }, - } - /* eslint-enable unicorn/no-null */)); + sync: { errorCode: null, id: "history", state: "running" }, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture. + }, + ); + server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { modes.push(new URL(request.url).searchParams.get("sync")); + return HttpResponse.json(snapshot()); }), http.get("*/api/v1/tbank-sync/:id", () => { polls++; - return HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + + return HttpResponse.json( { errorCode: null, id: "history", @@ -397,13 +460,16 @@ describe("page-owned screen synchronization", () => { resources: [snapshot().meta], scope: null, state: "running", - } - /* eslint-enable unicorn/no-null */)); + }, + /* eslint-enable unicorn/no-null */ + ); }), ); + const { result, unmount } = renderHook( () => { const accounts = useBrokerageAccounts(); + return { accounts, sync: useScreenSync({ screen: "accounts" }, [accounts]), @@ -411,6 +477,7 @@ describe("page-owned screen synchronization", () => { }, { wrapper: createQueryWrapper().wrapper }, ); + await waitFor(() => expect(result.current.accounts.meta?.sync.id).toBe("history"), ); @@ -447,20 +514,25 @@ describe("page-owned screen synchronization", () => { server.use( http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json( - resource([], (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + [], { sync: { errorCode: null, id: "revoked", state: "running" }, - } - /* eslint-enable unicorn/no-null */)), + }, + /* eslint-enable unicorn/no-null */ + ), ), ), http.get("*/api/v1/tbank-sync/:id", () => HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }), ), ); + const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); + return { accounts, sync: useScreenSync({ screen: "accounts" }, [accounts]), @@ -468,6 +540,7 @@ describe("page-owned screen synchronization", () => { }, { wrapper: createQueryWrapper().wrapper }, ); + await waitFor(() => expect(result.current.accounts.isError).toBe(true)); expect(result.current.accounts.data).toBeUndefined(); }); @@ -475,7 +548,10 @@ describe("page-owned screen synchronization", () => { auth(); let isObserved = false; const modes: (null | string)[] = []; - const dependency = resource([], (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + + const dependency = resource( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + [], { resourceKey: "operations", stateVersion: isObserved ? "covered" : "initial", @@ -484,24 +560,36 @@ describe("page-owned screen synchronization", () => { id: "dependency", state: isObserved ? "idle" : "running", }, - } - /* eslint-enable unicorn/no-null */)).meta; + }, + /* eslint-enable unicorn/no-null */ + ).meta; + server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { const mode = new URL(request.url).searchParams.get("sync"); modes.push(mode); if (mode) isObserved = true; + return HttpResponse.json( - resource(isObserved ? [] : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, { - dependencies: [ - { ...dependency, stateVersion: isObserved ? "covered" : "initial" }, - ], - resourceKey: "projection", - }), + resource( + isObserved + ? [] + : null, // eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + { + dependencies: [ + { + ...dependency, + stateVersion: isObserved ? "covered" : "initial", + }, + ], + resourceKey: "projection", + }, + ), ); }), http.get("*/api/v1/tbank-sync/:id", () => - HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + HttpResponse.json( { errorCode: null, id: "dependency", @@ -510,18 +598,22 @@ describe("page-owned screen synchronization", () => { resources: [{ ...dependency, stateVersion: "covered" }], scope: null, state: "succeeded", - } - /* eslint-enable unicorn/no-null */)), + }, + /* eslint-enable unicorn/no-null */ + ), ), ); + const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); useScreenSync({ screen: "accounts" }, [accounts]); + return accounts; }, { wrapper: createQueryWrapper().wrapper }, ); + await waitFor(() => expect(result.current.data).toEqual([])); // eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response. expect(modes).toEqual([null, "observe"]); @@ -536,9 +628,11 @@ describe("page-owned screen synchronization", () => { HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), ), ); + const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); + return { accounts, sync: useScreenSync({ screen: "accounts" }, [accounts]), @@ -546,6 +640,7 @@ describe("page-owned screen synchronization", () => { }, { wrapper: createQueryWrapper().wrapper }, ); + await waitFor(() => expect(result.current.accounts.data).toEqual([])); await act(() => result.current.sync.refresh()); await waitFor(() => expect(result.current.sync.transportError).toBe(true)); diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts index 6bfd0a2..e6a26fa 100644 --- a/src/features/tbank-connection/model/screen-sync.ts +++ b/src/features/tbank-connection/model/screen-sync.ts @@ -15,15 +15,21 @@ import { type InvestmentScreen, useTbankSync } from "./sync"; type ResourceMeta = paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number]; + type ScreenResource = { meta?: ResourceMeta; observe: (version?: string) => Promise; queryKey: QueryKey; transport: { - fetchStatus: "fetching" | "idle" | "paused"; - status: "error" | "pending" | "success"; + fetchStatus: "fetching" + | "idle" + | "paused"; + status: "error" + | "pending" + | "success"; }; }; + const STATUS_INTERVAL_MS = 1000; function observeResource( @@ -38,27 +44,34 @@ function observeResource( if (observed.get(key) === completedId) return; observed.set(key, completedId); void resource.observe().catch(() => {}); + return; } + const changed = [ resource.meta, ...(resource.meta.dependencies ?? []), ].flatMap((current) => { const notified = latest.get(current.resourceKey); + return notified && notified.stateVersion !== current.stateVersion ? [notified] : []; }); + if (changed.length === 0) return; + const notification = JSON.stringify( changed.map(meta => [meta.resourceKey, meta.stateVersion]), ); - if (observed.get(resource.meta.resourceKey) === notification) - return; + + if (observed.get(resource.meta.resourceKey) === notification) return; observed.set(resource.meta.resourceKey, notification); + const own = changed.find( meta => meta.resourceKey === resource.meta?.resourceKey, ); + void resource.observe(own?.stateVersion).catch(() => {}); } @@ -67,17 +80,24 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { const client = useQueryClient(); const deniedRef = useRef(new Set()); const scopeKey = JSON.stringify(scope); - const [requested, setRequested] = useState(null); + + const [requested, setRequested] = useState(null); + const requestInFlightRef = useRef(null); const currentScopeRef = useRef(scopeKey); useLayoutEffect(() => { currentScopeRef.current = scopeKey; }, [scopeKey]); - const isCurrentMutation = JSON.stringify(mutation.variables?.body) === scopeKey; + + const isCurrentMutation + = JSON.stringify(mutation.variables?.body) === scopeKey; + const observedRef = useRef(new Map()); + const ids = [ ...new Set([ ...(requested?.scope === scopeKey ? [requested.id] : []), @@ -93,6 +113,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { ), ]), ]; + const statuses = useAuthenticatedQueries( ids.map(id => ({ ...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", { @@ -101,12 +122,16 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { refetchInterval: (query: { state: { data?: { state: string }; error: unknown }; }) => - !(query.state.error || (query.state.data && query.state.data.state !== "running")) && STATUS_INTERVAL_MS, + !( + query.state.error + || (query.state.data && query.state.data.state !== "running") + ) && STATUS_INTERVAL_MS, refetchOnMount: false, refetchOnWindowFocus: false, retry: false, })), ); + useEffect(() => { for (const status of statuses) { if (!status.error || deniedRef.current.has(status.error)) { @@ -117,10 +142,16 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { if (!isApiErrorCode(apiErrorCode(status.error), ApiErrorCode.NotFound)) invalidateResourceAccess(client, "brokerage", status.error); } + const latest = new Map(); - const orderedStatuses = statuses.toSorted((a, b) => a.dataUpdatedAt - b.dataUpdatedAt); + + const orderedStatuses = statuses.toSorted( + (a, b) => a.dataUpdatedAt - b.dataUpdatedAt, + ); + for (const status of orderedStatuses) { const statusResources = status.data?.resources ?? []; + for (const meta of statusResources) { latest.set(meta.resourceKey, meta); const dependencies = meta.dependencies ?? []; @@ -128,10 +159,18 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { latest.set(dependency.resourceKey, dependency); } } - const completed = statuses.find(status => requested?.scope === scopeKey - && status.data?.id === requested.id && status.data.state !== "running")?.data; - for (const resource of resources) observeResource(resource, latest, completed?.id, observedRef.current); + + const completed = statuses.find( + status => + requested?.scope === scopeKey + && status.data?.id === requested.id + && status.data.state !== "running", + )?.data; + + for (const resource of resources) + observeResource(resource, latest, completed?.id, observedRef.current); }, [client, statuses, resources, requested, scopeKey]); + const isRefreshing = (isCurrentMutation && mutation.isPending) || statuses.some( @@ -139,15 +178,18 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { ) || resources.some(({ meta }) => { const status = statuses[ids.indexOf(meta?.sync.id ?? "")]; + return ( !status?.isError && (!status?.data || status.data.state === "running") && (meta?.sync.state === "running" || meta?.sync.state === "queued") ); }); + const refresh = async () => { if (isRefreshing || requestInFlightRef.current === scopeKey) return; requestInFlightRef.current = scopeKey; + try { const accepted = await mutation.mutateAsync({ body: scope }); if ( @@ -162,15 +204,20 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { */ } finally { - if (requestInFlightRef.current === scopeKey) requestInFlightRef.current = /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + if (requestInFlightRef.current === scopeKey) + requestInFlightRef.current + // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + = null; } }; + // Retry failed HTTP reads without asking the source to import again. // A source failure (or explicit refresh with no read error) still needs a sync. const retry = async () => { const failed = resources.filter( resource => resource.transport.status === "error", ); + const failedStatuses = statuses.filter(status => status.isError); if (failed.length > 0 || failedStatuses.length > 0) { await Promise.allSettled([ @@ -180,6 +227,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { } else await refresh(); }; + return { refresh, refreshing: isRefreshing, diff --git a/src/features/tbank-connection/model/sync.test.tsx b/src/features/tbank-connection/model/sync.test.tsx index 7f90b16..604d57d 100644 --- a/src/features/tbank-connection/model/sync.test.tsx +++ b/src/features/tbank-connection/model/sync.test.tsx @@ -25,25 +25,32 @@ describe("sync connection and session boundaries", () => { refresh: () => Promise.resolve(true), }); - const { promise: blocked, resolve: release } = Promise.withResolvers(); + const { promise: blocked, resolve: release } + = Promise.withResolvers(); + const accepted = { id: "work", now: "2026-09-25T12:00:00Z", scope: { screen: "accounts" }, }; + let started = 0; let isChanged = false; server.use( http.post("*/api/v1/tbank-sync", async () => { started += 1; await blocked; + return HttpResponse.json(accepted, { status: 202 }); }), http.get("*/api/v1/tbank-sync/:id", async () => { const now = isChanged ? "2026-09-25T12:01:00Z" : accepted.now; started += 1; await blocked; - return HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + + return HttpResponse.json( { ...accepted, errorCode: null, @@ -51,21 +58,26 @@ describe("sync connection and session boundaries", () => { progress: { completed: 1, discovering: false, total: 1 }, resources: [], state: "succeeded", - } - /* eslint-enable unicorn/no-null */)); + }, + /* eslint-enable unicorn/no-null */ + ); }), ); const { queryClient, wrapper } = createQueryWrapper(); const published: string[] = []; + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { - if ((event.query.queryKey as readonly unknown[])[1] - !== "/api/v1/tbank-sync/{id}") { + if ( + (event.query.queryKey as readonly unknown[])[1] + !== "/api/v1/tbank-sync/{id}" + ) { return; } const data = event.query.state.data as undefined | { now?: string }; if (data?.now) published.push(data.now); }); + const { result } = renderHook( () => ({ mutation: useTbankSync(), @@ -73,39 +85,49 @@ describe("sync connection and session boundaries", () => { }), { wrapper }, ); + const screen: InvestmentScreen = { screen: "accounts" }; let mutation!: Promise; act(() => { mutation = result.current.mutation.mutateAsync({ body: screen }); }); + // eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below. const discarded = expect(mutation).rejects.toMatchObject({ name: "AbortError", }); + await waitFor(() => expect(started).toBe(2)); await act(async () => { isChanged = true; + if (boundary === "connection") - commitTbankConnectionBoundary(queryClient, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + commitTbankConnectionBoundary( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + queryClient, { connected: false, connectedAt: null, - } - /* eslint-enable unicorn/no-null */)); + }, + /* eslint-enable unicorn/no-null */ + ); else { generation += 1; queryClient.clear(); } + release(); await blocked; }); await discarded; expect(result.current.mutation.data).toBeUndefined(); + const key = apiQueryClient.queryOptions( "get", "/api/v1/tbank-sync/{id}", { params: { path: { id: "work" } } }, ).queryKey; + // A new request may start after a successful connection boundary; an old result cannot win it. if (boundary === "session") // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. diff --git a/src/features/tbank-connection/model/sync.ts b/src/features/tbank-connection/model/sync.ts index 578b213..c53e2f6 100644 --- a/src/features/tbank-connection/model/sync.ts +++ b/src/features/tbank-connection/model/sync.ts @@ -6,11 +6,13 @@ import { type InvestmentScreen = paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"]; + function useTbankSync() { return apiQueryClient.useMutation("post", "/api/v1/tbank-sync", { retry: false, }); } + function useTbankSyncStatus(id: null | string) { return useAuthenticatedQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", { diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx index 8cd01eb..163c156 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx @@ -21,26 +21,31 @@ const brokerageAccountsQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts", ).queryKey; + const portfolioQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId: "account-1" } } }, ).queryKey; + const operationsQueryKey = apiQueryClient.queryOptions( "get", "/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 bondQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/bonds/{uid}/position", { params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } }, ).queryKey; + const bankCardsQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/bank-cards", @@ -48,6 +53,7 @@ const bankCardsQueryKey = apiQueryClient.queryOptions( params: { query: { includeArchived: "false" } }, }, ).queryKey; + const unrelatedQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/auth/me", @@ -59,6 +65,7 @@ function renderSection(client: QueryClient) { clear: vi.fn(), refresh: () => Promise.resolve(true), }); + return render( @@ -70,21 +77,22 @@ function renderSection(client: QueryClient) { function seededClient() { const client = createTestQueryClient(); + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ client.setQueryData(brokerageAccountsQueryKey, [ - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - id: "account-1", - name: "Основной счет", - openedOn: null, - type: "BROKERAGE", - } - /* eslint-enable unicorn/no-null */), + { + id: "account-1", + name: "Основной счет", + openedOn: null, + type: "BROKERAGE", + }, + /* eslint-enable unicorn/no-null */ ]); client.setQueryData(portfolioQueryKey, { source: "previous-connection" }); client.setQueryData(stockQueryKey, { source: "previous-connection" }); client.setQueryData(bondQueryKey, { source: "previous-connection" }); client.setQueryData(unrelatedQueryKey, { id: "user-1" }); client.setQueryData(bankCardsQueryKey, [{ id: "bank-card-1" }]); + return client; } @@ -92,18 +100,23 @@ describe("TbankConnectionSection", () => { it("preserves cached status and a draft entered during a delayed failed refetch", async () => { const status = { connected: true, connectedAt: "2026-08-14T00:00:00Z" }; - const { promise: response, resolve: finishRefetch } = Promise.withResolvers(); + const { promise: response, resolve: finishRefetch } + = Promise.withResolvers(); + let reads = 0; server.use( http.get("*/api/v1/tbank-connection", async () => { reads += 1; + if (reads === 1) { await response; + return HttpResponse.json( { code: "TBANK_UNAVAILABLE" }, { status: 503 }, ); } + return HttpResponse.json(status); }), ); @@ -141,11 +154,14 @@ describe("TbankConnectionSection", () => { server.use( http.get("*/api/v1/tbank-connection", () => { reads += 1; + return reads === 1 ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { connected: false, connectedAt: null } - /* eslint-enable unicorn/no-null */)); + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + : HttpResponse.json( + { connected: false, connectedAt: null }, + /* eslint-enable unicorn/no-null */ + ); }), ); renderSection(createTestQueryClient()); @@ -244,19 +260,24 @@ describe("TbankConnectionSection", () => { const client = seededClient(); let isAborted = false; - const { promise: previousResponse, resolve: finishPrevious } = Promise.withResolvers<{ source: string }>(); + + const { promise: previousResponse, resolve: finishPrevious } + = Promise.withResolvers<{ source: string }>(); + const previousRequest = client .query({ queryFn: ({ signal }) => { signal.addEventListener("abort", () => { isAborted = true; }); + return previousResponse; }, queryKey: operationsQueryKey, }) // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. .catch(() => {}); + renderSection(client); await screen.findByText("Не подключено"); @@ -302,7 +323,9 @@ describe("TbankConnectionSection", () => { it("keeps DELETE recovery inside the dialog and preserves cache until successful retry", async () => { let deletes = 0; - const { promise: response, resolve: finishDelete } = Promise.withResolvers(); + const { promise: response, resolve: finishDelete } + = Promise.withResolvers(); + server.use( http.get("*/api/v1/tbank-connection", () => HttpResponse.json({ @@ -313,6 +336,7 @@ describe("TbankConnectionSection", () => { http.delete("*/api/v1/tbank-connection", async () => { deletes += 1; if (deletes === 1) await response; + return deletes === 1 ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : new HttpResponse(null, { status: 204 }); @@ -324,9 +348,11 @@ describe("TbankConnectionSection", () => { fireEvent.click( await screen.findByRole("button", { name: "Удалить подключение" }), ); + const dialog = await screen.findByRole("dialog", { name: "Удалить подключение?", }); + await waitFor(() => // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. expect(dialog.contains(document.activeElement)).toBe(true), diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx index 205fd1b..5d8a8ec 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx @@ -10,7 +10,14 @@ import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded"; const TbankConnectionSection: FC = () => { const viewModel = useTbankConnectionViewModel(); if (viewModel.state === "loading") return ; - return viewModel.state === "error" ? : ; + + return viewModel.state === "error" + ? ( + + ) + : ( + + ); }; export { TbankConnectionSection }; diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx index 2029b30..ec68a16 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx @@ -10,11 +10,13 @@ import { describe("TbankConnectionSection state views", () => { it("renders loading and retries a status error", () => { const retry = vi.fn(); + const { rerender } = render( , ); + expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute( "aria-busy", "true", diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx index 9fb619c..67730de 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx @@ -14,6 +14,7 @@ const TbankConnectionLoading: FC = () => { return ( + ); diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts index aa1a587..9507c0e 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts @@ -6,9 +6,13 @@ function useTbankConnectionViewModel() { const status = useAuthenticatedQuery({ ...tbankConnectionQueryOptions(), }); + if (!status.data) { - return status.isPending ? { state: "loading" as const } : { retry: () => status.refetch(), state: "error" as const }; + return status.isPending + ? { state: "loading" as const } + : { retry: () => status.refetch(), state: "error" as const }; } + return { refreshError: status.isError, refreshing: status.isFetching, diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx index a84bf72..c16484e 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx @@ -8,19 +8,11 @@ import { Stack, Text, } from "@mantine/core"; -import { useQueryClient } from "@tanstack/react-query"; -import { useEffect, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import { ApiErrorCode, apiErrorCode, apiQueryClient, isApiErrorCode } from "@/shared/api"; +import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; -import { - commitTbankConnectionBoundary, - type TbankConnectionStatus, -} from "../../model/connection-boundary"; - -enum ConnectionErrorCode { RequestFailed = "REQUEST_ERROR" } +import { type TbankConnectionStatus } from "../../model/connection-boundary"; +import { useTbankConnectionSectionLoadedVm } from "./TbankConnectionSectionLoaded.vm"; type TbankConnectionSectionLoadedProps = { refreshError: boolean; @@ -32,48 +24,29 @@ type TbankConnectionSectionLoadedProps = { const TbankConnectionSectionLoaded: FC = ( props, ) => { - const { status } = props; - const { t } = useTranslation(); - const queryClient = useQueryClient(); - const [isDeleteOpened, setIsDeleteOpened] = useState(false); - const removalButtonRef = useRef(null); - const [requestError, setRequestError] = useState(null); const { - formState: { errors }, - handleSubmit, + errors, + isConnected, + isDeleteOpened, + isPending, + onCloseDisconnect, + onConfirmDisconnect, + onOpenDisconnect, + onSubmit, register, - reset, - } = useForm<{ token: string }>(); - const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", { - onError: error => setRequestError(apiErrorCode(error) ?? ConnectionErrorCode.RequestFailed), - onSuccess: (data) => { - commitTbankConnectionBoundary(queryClient, data); - reset(); - }, - }); - const remove = apiQueryClient.useMutation( - "delete", - "/api/v1/tbank-connection", - { - onSuccess: () => { - commitTbankConnectionBoundary(queryClient, { - connected: false, - connectedAt: /* eslint-disable-line unicorn/no-null -- The disconnected API snapshot requires a null connection timestamp. */ null, - }); - setIsDeleteOpened(false); - }, - }, - ); - const unavailableMessage = isApiErrorCode(requestError, ApiErrorCode.TbankUnavailable) ? "tbank.unavailable" : "tbank.requestError"; - const isPending = update.isPending || remove.isPending; - const isConnected = status.connected; - useEffect(() => { - if (isDeleteOpened && remove.isError) removalButtonRef.current?.focus(); - }, [isDeleteOpened, remove.isError]); + removalButtonRef, + remove, + requestError, + status, + t, + unavailableMessage, + update, + } = useTbankConnectionSectionLoadedVm(props); return ( {t("tbank.title")} + {isConnected ? t("tbank.connected", { @@ -83,18 +56,22 @@ const TbankConnectionSectionLoaded: FC = ( }) : t("tbank.disconnected")} + {t("tbank.hint")} + {props.refreshing && ( {t("tbank.refreshing")} )} + {props.refreshError && ( {t("tbank.refreshError")} + + {isConnected && ( - )} + !isPending && setIsDeleteOpened(false)} + onClose={onCloseDisconnect} opened={isDeleteOpened} title={t("tbank.deleteTitle")} > {t("tbank.deleteText")} + {remove.isError && ( {t("tbank.requestError")} )} + + @@ -55,10 +58,12 @@ describe("Upcoming event date filters", () => { server.use( http.all("*/api/v1/*", ({ request }) => { requests.push(request.url); + return new HttpResponse(null, { status: 503 }); }), ); const queryClient = createTestQueryClient(); + const { result } = renderHook(() => useAccountEventsPageViewModel(), { wrapper: ({ children }) => ( @@ -68,6 +73,7 @@ describe("Upcoming event date filters", () => { ), }); + await new Promise(resolve => setTimeout(resolve, 0)); expect(result.current).toEqual({ state: "loading" }); expect(queryClient.isFetching()).toBe(0); @@ -91,20 +97,25 @@ describe("Upcoming event date filters", () => { server.use( http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => { requested.push(new URL(request.url).search); + return HttpResponse.json( - resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + null, { sync: { errorCode: "TBANK_UNAVAILABLE", id: null, state: "failed", }, - } - /* eslint-enable unicorn/no-null */)), + }, + /* eslint-enable unicorn/no-null */ + ), ); }), http.post("*/api/v1/tbank-sync", async ({ request }) => { syncRequests.push(await request.json()); + return HttpResponse.json( { code: "TBANK_UNAVAILABLE" }, { status: 503 }, @@ -113,6 +124,7 @@ describe("Upcoming event date filters", () => { ); const period = Object.fromEntries(new URLSearchParams(search)); const queryClient = createTestQueryClient(); + const { result } = renderHook( () => useAccountEventsPageViewModel("all"), { @@ -125,6 +137,7 @@ describe("Upcoming event date filters", () => { ), }, ); + await waitFor(() => expect(result.current.state).toBe("error")); expect(requested).toEqual([`?${search}`]); expect(syncRequests).toEqual([]); @@ -153,6 +166,7 @@ describe("Upcoming event date filters", () => { "*/api/v1/brokerage-accounts/:accountId/events", ({ request }) => { searches.push(new URL(request.url).search); + return HttpResponse.json(resource({ events: [] })); }, ), @@ -171,6 +185,7 @@ describe("Upcoming event date filters", () => { initialIndex={0} > + } @@ -182,6 +197,7 @@ describe("Upcoming event date filters", () => { , ); await screen.findByLabelText("Период"); + for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) { fireEvent.click( screen.getByRole("button", { name: "History forward" }), @@ -192,6 +208,7 @@ describe("Upcoming event date filters", () => { ); await screen.findByLabelText("Период"); } + fireEvent.click(screen.getByRole("button", { name: "History back" })); await waitFor(() => expect(screen.getByLabelText("URL")).toHaveTextContent( @@ -227,6 +244,7 @@ describe("Upcoming event date filters", () => { initialEntries={[`/accounts/${accountId}/events?from=2099-02-01`]} > + } @@ -258,6 +276,7 @@ describe("Upcoming event date filters", () => { "*/api/v1/brokerage-accounts/:accountId/events", ({ request }) => { searches.push(new URL(request.url).search); + return HttpResponse.json( resource({ accountId, @@ -278,6 +297,7 @@ describe("Upcoming event date filters", () => { ]} > + } @@ -333,6 +353,7 @@ it("retains the previous complete forecast and timestamp when its source refresh clear: vi.fn(), refresh: () => Promise.resolve(true), }); + const events = [ { date: "2026-10-01", @@ -341,16 +362,19 @@ it("retains the previous complete forecast and timestamp when its source refresh type: "coupon", }, ]; + const snapshot = resource( { events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] }, { sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } }, ); + server.use( http.get("*/api/v1/brokerage-accounts/:accountId/events", () => HttpResponse.json(snapshot), ), ); const queryClient = createTestQueryClient(); + const { result } = renderHook(() => useAccountEventsPageViewModel("all"), { wrapper: ({ children }) => ( @@ -358,6 +382,7 @@ it("retains the previous complete forecast and timestamp when its source refresh ), }); + await waitFor(() => expect(result.current).toMatchObject({ events, diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index 1532319..22268e8 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -37,7 +37,8 @@ function renderPage() { describe("AccountEventsPage", () => { beforeEach(() => { refresh.mockClear(); - query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + query.mockReturnValue( { data: { accountId: "account", @@ -51,8 +52,8 @@ describe("AccountEventsPage", () => { sync: { errorCode: null, id: null, state: "idle" }, }, refetch: vi.fn(), - } - /* eslint-enable unicorn/no-null */) as never); + } as /* eslint-enable unicorn/no-null */ never, + ); }); it("passes the selected route account to the query", () => { @@ -72,7 +73,8 @@ describe("AccountEventsPage", () => { }); it("keeps the loading fallback when no result or account is available", () => { - query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + query.mockReturnValue( { data: undefined, isError: false, @@ -82,8 +84,8 @@ describe("AccountEventsPage", () => { sync: { errorCode: null, id: null, state: "idle" }, }, refetch: vi.fn(), - } - /* eslint-enable unicorn/no-null */) as never); + } as /* eslint-enable unicorn/no-null */ never, + ); renderPage(); expect(query).toHaveBeenCalledWith("", {}); expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute( @@ -93,7 +95,8 @@ describe("AccountEventsPage", () => { }); it("renders events and marks an offer without an amount", () => { - query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + query.mockReturnValue( { data: { accountId: "account", @@ -132,8 +135,8 @@ describe("AccountEventsPage", () => { sync: { errorCode: null, id: null, state: "idle" }, }, refetch: vi.fn(), - } - /* eslint-enable unicorn/no-null */) as never); + } as /* eslint-enable unicorn/no-null */ never, + ); renderPage(); expect(screen.getByText("1 250 ₽")).toBeInTheDocument(); expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument(); @@ -180,7 +183,8 @@ describe("AccountEventsPage", () => { }); it("shows a structural busy state before events are available", () => { - query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + query.mockReturnValue( { data: undefined, isError: false, @@ -190,8 +194,8 @@ describe("AccountEventsPage", () => { sync: { errorCode: null, id: null, state: "idle" }, }, refetch: vi.fn(), - } - /* eslint-enable unicorn/no-null */) as never); + } as /* eslint-enable unicorn/no-null */ never, + ); renderPage(); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx index 247e587..f12859a 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx @@ -10,7 +10,14 @@ type AccountEventsPageProps = { accountId?: string }; const AccountEventsPage: FC = (props) => { const viewModel = useAccountEventsPageViewModel(props.accountId); if (viewModel.state === "loading") return ; - return viewModel.state === "error" ? : ; + + return viewModel.state === "error" + ? ( + + ) + : ( + + ); }; export { AccountEventsPage }; diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 0fd1c23..5225ef9 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -1,12 +1,12 @@ import { useEffect, useState } from "react"; import { useParams, useSearchParams } from "react-router"; -import { z } from "zod"; import { type BrokerageEvent, useBrokerageEvents, } from "@/entities/brokerage-event"; import { useScreenSync } from "@/features/tbank-connection"; +import { isValidPeriod } from "@/shared/lib"; type AccountEventsPageViewModel = | { @@ -38,8 +38,15 @@ function useAccountEventsPageViewModel( const urlFrom = searchParameters.get("from"); const urlTo = searchParameters.get("to"); const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo); - const appliedFrom = isValidUrlPeriod ? urlFrom : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; - const appliedTo = isValidUrlPeriod ? urlTo : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + + const appliedFrom = isValidUrlPeriod + ? urlFrom + : null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + + const appliedTo = isValidUrlPeriod + ? urlTo + : null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + const [draftFrom, setDraftFrom] = useState(appliedFrom); const [draftTo, setDraftTo] = useState(appliedTo); useEffect(() => { @@ -49,19 +56,23 @@ function useAccountEventsPageViewModel( normalized.delete("to"); setSearchParameters(normalized, { replace: true }); }, [isValidUrlPeriod, searchParameters, setSearchParameters]); + const [previousPeriod, setPreviousPeriod] = useState([ appliedFrom, appliedTo, ]); + if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) { setPreviousPeriod([appliedFrom, appliedTo]); setDraftFrom(appliedFrom); setDraftTo(appliedTo); } + const events = useBrokerageEvents(accountId, { ...(appliedFrom && { from: appliedFrom }), ...(appliedTo && { to: appliedTo }), }); + const sync = useScreenSync( { accountId, @@ -71,12 +82,15 @@ function useAccountEventsPageViewModel( }, [events], ); + if (events.isLoading) return { state: "loading" }; + const onReset = () => { setDraftFrom(null); setDraftTo(null); setSearchParameters({}); }; + if ((events.isError || events.meta?.sync.state === "failed") && !events.data) return { onReset, @@ -86,6 +100,7 @@ function useAccountEventsPageViewModel( state: "error", }; if (!events.data) return { state: "loading" }; + return { canApply: (draftFrom !== appliedFrom || draftTo !== appliedTo) @@ -115,14 +130,4 @@ function useAccountEventsPageViewModel( }; } -const calendarDate = z.iso.date(); -function isValidPeriod(from: null | string, to: null | string) { - return ( - [from, to].every( - date => date === null || calendarDate.safeParse(date).success, - ) - && (!from || !to || from <= to) - ); -} - export { type AccountEventsPageViewModel, useAccountEventsPageViewModel }; diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx index 160df64..3f929e6 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; const AccountEventsPageEmpty: FC = () => { const { t } = useTranslation(); + return ( ); diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx index dd0156a..9630812 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx @@ -8,11 +8,13 @@ type AccountEventsPageErrorProps = { onReset: () => void; retry: () => void }; const AccountEventsPageError: FC = (props) => { const { t } = useTranslation(); + return ( + diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index 0415a3f..1f3724b 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -10,7 +10,6 @@ import { Title, } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; -import { useTranslation } from "react-i18next"; import { BrokerageEventCard } from "@/entities/brokerage-event"; import { formatSnapshotTime } from "@/shared/lib"; @@ -18,82 +17,94 @@ import { formatSnapshotTime } from "@/shared/lib"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view"; +import { useAccountEventsPageLoadedVm } from "./AccountEventsPageLoaded.vm"; type AccountEventsPageLoadedProps = Extract< AccountEventsPageViewModel, { state: "loaded" } >; -const AccountEventsPageLoaded: FC = (props) => { - const { t } = useTranslation(); - function renderEvents() { - if (props.events.length > 0) { - return ( - - {props.events.map(event => ( - - ))} - - ); - } - if (props.partial) { - return; - } - return ; +const AccountEventsPageLoadedEvents: FC = ( + props, +) => { + if (props.events.length > 0) { + return ( + + {props.events.map(event => ( + + ))} + + ); } + + if (props.partial) { + return; + } + + return ; +}; + +const AccountEventsPageLoaded: FC = (props) => { + const { onPeriodChange, t } = useAccountEventsPageLoadedVm(props); + return ( - {!!(props.lastSuccessfulAt) && (( + {!!props.lastSuccessfulAt && ( {t("investment.sourceTime", { date: formatSnapshotTime(props.lastSuccessfulAt), })} - ))} + )} + {t("events.title")} + - {!!(props.partial) && (( + + {!!props.partial && ( {t("investment.partial")} - ))} - {!!(props.refreshing) && (( + )} + + {!!props.refreshing && ( - ))} - {!!(props.refreshError) && (( + )} + + {!!props.refreshError && ( {t( props.sourceError ? "investment.syncFailed" : "accounts.refreshError", )} + - ))} + )} + {t("events.hint")} + { - props.onDraftFromChange(from); - props.onDraftToChange(to); - }} + onChange={onPeriodChange} popoverProps={{ middlewares: { shift: { crossAxis: true } }, returnFocus: true, @@ -103,15 +114,18 @@ const AccountEventsPageLoaded: FC = (props) => { valueFormat="DD.MM.YYYY" /> + + - {renderEvents()} + + ); }; diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts new file mode 100644 index 0000000..af03a32 --- /dev/null +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts @@ -0,0 +1,24 @@ +import { useTranslation } from "react-i18next"; + +import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; + +type AccountEventsPageLoadedProps = Extract< + AccountEventsPageViewModel, + { state: "loaded" } +>; + +function useAccountEventsPageLoadedVm(props: AccountEventsPageLoadedProps) { + const { t } = useTranslation(); + + const onPeriodChange: (value: [null | string, null | string]) => void = ([ + from, + to, + ]) => { + props.onDraftFromChange(from); + props.onDraftToChange(to); + }; + + return { onPeriodChange, t }; +} + +export { useAccountEventsPageLoadedVm }; diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx index a9de6bd..755d112 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx @@ -8,18 +8,25 @@ const skeletonRowIds = ["first", "second", "third", "fourth"]; const AccountEventsPageLoading: FC = () => { const { t } = useTranslation(); + return ( + + + + + + {skeletonRowIds.map(id => ( ))} diff --git a/src/pages/account-income/model/income.schema.ts b/src/pages/account-income/model/income.schema.ts new file mode 100644 index 0000000..9d22512 --- /dev/null +++ b/src/pages/account-income/model/income.schema.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +import { validationMessage } from "@/shared/i18n"; + +import { moscowDate, parseDraftDate } from "./period"; + +const draftDate = z + .string() + .refine(value => parseDraftDate(value) !== null, { + error: validationMessage("income.validation.date"), + }) + .refine( + (value) => { + const date = parseDraftDate(value); + + return !date || date <= moscowDate(new Date()); + }, + { error: validationMessage("income.validation.future") }, + ); + +const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine( + ({ from, to }) => { + const start = parseDraftDate(from); + const end = parseDraftDate(to); + + return !start || !end || start <= end; + }, + { path: ["to"], ...validationMessage("income.validation.period")({}) }, +); + +export { incomeFormSchema }; diff --git a/src/pages/account-income/model/period.ts b/src/pages/account-income/model/period.ts index 110e056..b0d002f 100644 --- a/src/pages/account-income/model/period.ts +++ b/src/pages/account-income/model/period.ts @@ -7,12 +7,14 @@ const datePattern = /^\d{4}-\d{2}-\d{2}$/; function formatDraftDate(value: string): string { const [year, month, day] = value.split("-", DATE_PART_COUNT); + return `${day}.${month}.${year}`; } function isDate(value: string): boolean { if (!datePattern.test(value)) return false; const parsed = new Date(`${value}T00:00:00Z`); + return ( Number.isFinite(parsed.valueOf()) && parsed.toISOString().slice(0, ISO_DATE_LENGTH) === value @@ -26,19 +28,27 @@ function moscowDate(value: Date): string { timeZone: "Europe/Moscow", year: "numeric", }).formatToParts(value); + const part = (type: string) => parts.find(item => item.type === type)?.value; + return `${part("year")}-${part("month")}-${part("day")}`; } function parseDraftDate(value: string): null | string { - if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return /* eslint-disable-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date. */ null; + if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) + // eslint-disable-next-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date. + return null; const [day, month, year] = value.split(".", DATE_PART_COUNT); const date = `${year}-${month}-${day}`; - return isDate(date) ? date : /* eslint-disable-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date. */ null; + + return isDate(date) + ? date + : null; // eslint-disable-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date. } -export { formatDraftDate, +export { + formatDraftDate, type IncomePeriod as IncomeForm, type IncomePeriod, isDate, diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index fe6240e..93bbea9 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -1,6 +1,5 @@ import type { FC } from "react"; -import "dayjs/locale/ru"; import { MantineProvider } from "@mantine/core"; import { DatesProvider } from "@mantine/dates"; import { QueryClientProvider } from "@tanstack/react-query"; @@ -13,6 +12,7 @@ import { waitFor, within, } from "@testing-library/react"; +import "dayjs/locale/ru"; import { http, HttpResponse } from "msw"; import { MemoryRouter, @@ -38,49 +38,50 @@ import { } from "./AccountIncomePage.vm"; import { IncomePayments } from "./components/IncomePayments.view"; -const analytics = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ -{ - accountId: "account-1", - bucketGranularity: "day", - buckets: [ - { - period: { from: "2026-08-01", to: "2026-08-01" }, - totalsByCurrency: [summary("RUB", "10", "2")], +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +const analytics + = { + accountId: "account-1", + bucketGranularity: "day", + buckets: [ + { + period: { from: "2026-08-01", to: "2026-08-01" }, + totalsByCurrency: [summary("RUB", "10", "2")], + }, + ], + calculatedAt: "2026-08-27T00:00:00Z", + entries: [ + entry(), + { + ...entry(), + id: "dividend-1", + payment: money("RUB", "2"), + type: "OPERATION_TYPE_DIVIDEND", + }, + ], + instruments: [ + { + coupon: money("RUB", "10"), + displayName: "BOND", + dividend: money("RUB", "2"), + figi: "FIGI", + instrumentType: "bond", + instrumentUid: null, + key: "income-group-opaque-v2", + receiptCount: 2, + ticker: "BOND", + total: money("RUB", "12"), + }, + ], + period: { from: "2026-08-01", to: "2026-08-27" }, + previous: { + period: { from: "2026-07-01", to: "2026-07-31" }, + totalsByCurrency: [comparison("RUB")], }, - ], - calculatedAt: "2026-08-27T00:00:00Z", - entries: [ - entry(), - { - ...entry(), - id: "dividend-1", - payment: money("RUB", "2"), - type: "OPERATION_TYPE_DIVIDEND", - }, - ], - instruments: [ - { - coupon: money("RUB", "10"), - displayName: "BOND", - dividend: money("RUB", "2"), - figi: "FIGI", - instrumentType: "bond", - instrumentUid: null, - key: "income-group-opaque-v2", - receiptCount: 2, - ticker: "BOND", - total: money("RUB", "12"), - }, - ], - period: { from: "2026-08-01", to: "2026-08-27" }, - previous: { - period: { from: "2026-07-01", to: "2026-07-31" }, - totalsByCurrency: [comparison("RUB")], - }, - totalsByCurrency: [summary("RUB", "10", "2")], - yearComparison: null, -} -/* eslint-enable unicorn/no-null */) satisfies IncomeAnalytics; + totalsByCurrency: [summary("RUB", "10", "2")], + yearComparison: null, + } satisfies /* eslint-enable unicorn/no-null */ IncomeAnalytics; function comparison(currency: string) { return { @@ -92,7 +93,8 @@ function comparison(currency: string) { } function entry(): IncomeAnalytics["entries"][number] { - return (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + return ( { accruedInterest: null, category: "income", @@ -111,7 +113,8 @@ function entry(): IncomeAnalytics["entries"][number] { ticker: "BOND", type: "OPERATION_TYPE_COUPON", } - /* eslint-enable unicorn/no-null */); + /* eslint-enable unicorn/no-null */ + ); } function money(currency: string, value: string) { @@ -125,6 +128,7 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") { refresh: () => Promise.resolve(true), }); const queryClient = createTestQueryClient(); + const view = render( @@ -133,11 +137,13 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") { initialEntries={[`/accounts/account-1/income?${search}`]} > + } path="/accounts/:accountId/income" /> + Events} path="/accounts/:accountId/events" @@ -148,6 +154,7 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") { , ); + return { ...view, queryClient }; } @@ -171,10 +178,12 @@ describe("AccountIncomePage", () => { server.use( http.all("*/api/v1/*", ({ request }) => { requests.push(request.url); + return new HttpResponse(null, { status: 503 }); }), ); const queryClient = createTestQueryClient(); + const { result } = renderHook(() => useAccountIncomePageViewModel(), { wrapper: ({ children }) => ( @@ -184,6 +193,7 @@ describe("AccountIncomePage", () => { ), }); + await new Promise(resolve => setTimeout(resolve, 0)); expect(result.current).toEqual({ state: "loading" }); expect(queryClient.isFetching()).toBe(0); @@ -198,21 +208,26 @@ describe("AccountIncomePage", () => { "*/api/v1/brokerage-accounts/:accountId/income-analytics", ({ request }) => { requested.push(new URL(request.url).search); + return HttpResponse.json( - resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + null, { sync: { errorCode: "TBANK_UNAVAILABLE", id: null, state: "failed", }, - } - /* eslint-enable unicorn/no-null */)), + }, + /* eslint-enable unicorn/no-null */ + ), ); }, ), http.post("*/api/v1/tbank-sync", async ({ request }) => { syncRequests.push(await request.json()); + return HttpResponse.json( { code: "TBANK_UNAVAILABLE" }, { status: 503 }, @@ -243,6 +258,7 @@ describe("AccountIncomePage", () => { "*/api/v1/brokerage-accounts/:accountId/income-analytics", ({ request }) => { requested.push(new URL(request.url).search); + return HttpResponse.json(resource(analytics)); }, ), @@ -255,18 +271,21 @@ describe("AccountIncomePage", () => { it("preserves every repeated-ID payment when an open card updates", async () => { const first = { ...entry(), description: "Выплата A", id: "same-id" }; + const second = { ...entry(), description: "Выплата B", id: "same-id", payment: money("RUB", "-2"), }; + const third = { ...entry(), description: "Выплата C", id: "other-id", payment: money("RUB", "1"), }; + function breakdown(entries: IncomeAnalytics["entries"], total: string) { const display = toIncomeDisplayModel({ ...analytics, @@ -281,19 +300,24 @@ describe("AccountIncomePage", () => { }, ], }); + expect(display).not.toBeNull(); + return ( ); } + const { rerender } = render(breakdown([first, second, third], "9")); const panel = await screen.findByRole("region", { name: "Выплаты" }); + const payments = () => within(panel) .getAllByText(/^Выплата [ABC]$/) .map(element => element.textContent); + expect(payments()).toEqual(["Выплата A", "Выплата B", "Выплата C"]); rerender(breakdown([third, first, second], "9")); @@ -323,6 +347,7 @@ describe("AccountIncomePage", () => { analytics.entries[1], ], }; + server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => HttpResponse.json(resource(response)), @@ -409,8 +434,9 @@ describe("AccountIncomePage", () => { }); it("renders the signed drill-down using opaque membership rather than instrument identity", async () => { - const response = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + const response + = { ...analytics, entries: [ { @@ -436,8 +462,9 @@ describe("AccountIncomePage", () => { total: money("RUB", "8"), }, ], - } - /* eslint-enable unicorn/no-null */); + }; + + /* eslint-enable unicorn/no-null */ server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => HttpResponse.json(resource(response)), @@ -486,9 +513,11 @@ describe("AccountIncomePage", () => { expect(eventsRequest).not.toHaveBeenCalled(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument(); const year = screen.getByRole("region", { name: "Годом ранее" }); + const selected = within(year).getByRole("region", { name: "01.08.2026 – 27.08.2026", }); + expect( screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }), ).toBeInTheDocument(); @@ -502,13 +531,17 @@ describe("AccountIncomePage", () => { ).toBeInTheDocument(); expect(screen.queryByText("Сравнения")).not.toBeInTheDocument(); expect(screen.queryByText("RUB", { exact: true })).not.toBeInTheDocument(); + for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) { expect(within(selected).getByText(amount)).toBeInTheDocument(); } + const difference = within(year).getByRole("region", { name: "Разница" }); + for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) { expect(within(difference).getByText(amount)).toBeInTheDocument(); } + expect(within(difference).getByText("+900 %")).toBeInTheDocument(); expect(within(difference).getByText("+1 100 %")).toBeInTheDocument(); expect(within(difference).queryByText("нет базы")).not.toBeInTheDocument(); @@ -523,14 +556,13 @@ describe("AccountIncomePage", () => { const dependencies = [ { ...resource(analytics).meta, role: "selected" as const }, { ...resource(analytics).meta, role: "previous" as const }, - ( - { - ...resource(null).meta, - role: "year" as const, - unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", - } - ), + { + ...resource(null).meta, + role: "year" as const, + unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", + }, ]; + server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => HttpResponse.json(resource(analytics, { dependencies })), @@ -572,16 +604,47 @@ describe("income comparison display", () => { ["1", "3", "+33,3 %"], ["0.01", "0.04", "+25 %"], ["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"], - ["10", "0", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null], - ["0", "0", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null], - ["10", "-0.00", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null], - ["10", "-100", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null], - ["9".repeat(400), "1", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null], - ["1", "9".repeat(400), /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null], + [ + "10", + "0", + // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + null, + ], + [ + "0", + "0", + // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + null, + ], + [ + "10", + "-0.00", + // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + null, + ], + [ + "10", + "-100", + // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + null, + ], + [ + "9".repeat(400), + "1", + // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + null, + ], + [ + "1", + "9".repeat(400), + // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + null, + ], ])("formats change %s against base %s", (difference, base, expected) => { const value = comparison("RUB"); value.difference.coupon = money("RUB", difference); value.compared.coupon = money("RUB", base); + const display = toIncomeDisplayModel({ ...analytics, yearComparison: { @@ -589,10 +652,13 @@ describe("income comparison display", () => { totalsByCurrency: [value], }, }); + const result = display?.comparison?.values[0]; - expect(result?.percentages.coupon?.replaceAll("\u{A0}", " ") ?? /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null).toBe( - expected, - ); + expect( + result?.percentages.coupon?.replaceAll("\u{A0}", " ") + // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + ?? null, + ).toBe(expected); expect(result?.difference.coupon.value).toBe(difference); expect(result?.compared.coupon.value).toBe(base); }); @@ -606,6 +672,7 @@ describe("income comparison display", () => { totalsByCurrency: [comparison("RUB"), comparison("USD")], }, }); + expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe( "01.12.2025 – 09.01.2026", ); @@ -622,9 +689,11 @@ describe("income comparison display", () => { const LocationProbe: FC = () => { const location = useLocation(); const navigate = useNavigate(); + return ( <> {location.search} + @@ -639,6 +708,7 @@ function respondWithRequestedPeriod(requests: string[]) { ({ request }) => { const url = new URL(request.url); requests.push(url.search); + return HttpResponse.json( resource({ ...analytics, @@ -753,12 +823,15 @@ describe("income period and snapshot boundary", () => { await screen.findByLabelText("Период"); selectPeriod(); - const { promise: pending, resolve: release } = Promise.withResolvers(); + const { promise: pending, resolve: release } + = Promise.withResolvers(); + server.use( http.get( "*/api/v1/brokerage-accounts/account-1/income-analytics", async () => { await pending; + return new HttpResponse(null, { status: 503 }); }, ), @@ -790,7 +863,8 @@ describe("income period and snapshot boundary", () => { ), ), http.get("*/api/v1/tbank-sync/:id", () => - HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + HttpResponse.json( { errorCode: null, id: "income", @@ -799,8 +873,9 @@ describe("income period and snapshot boundary", () => { resources: [resource(null, { stateVersion: "2" }).meta], scope: null, state: "succeeded", - } - /* eslint-enable unicorn/no-null */)), + }, + /* eslint-enable unicorn/no-null */ + ), ), http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => HttpResponse.json(resource(analytics, { stateVersion: "2" })), @@ -822,12 +897,15 @@ describe("income period and snapshot boundary", () => { renderPage(); await screen.findByLabelText("Период"); - const { promise: pending, resolve: release } = Promise.withResolvers(); + const { promise: pending, resolve: release } + = Promise.withResolvers(); + server.use( http.get( "*/api/v1/brokerage-accounts/account-1/income-analytics", async () => { await pending; + return HttpResponse.json( resource({ ...analytics, @@ -882,6 +960,7 @@ describe("income period and snapshot boundary", () => { total: money("RUB", "-3"), }, ]; + const display = toIncomeDisplayModel({ ...analytics, entries: instruments.map(instrument => ({ @@ -892,6 +971,7 @@ describe("income period and snapshot boundary", () => { instruments, totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")], }); + render( @@ -917,8 +997,11 @@ describe("income period and snapshot boundary", () => { it("maps comparison notices by role regardless of dependency order or resource key", () => { const selected = { ...resource(analytics).meta, role: "selected" as const }; - const previous = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + + const previous + = { ...resource(null).meta, resourceKey: "opaque/year-looking-key", role: "previous" as const, @@ -927,16 +1010,16 @@ it("maps comparison notices by role regardless of dependency order or resource k id: null, state: "failed" as const, }, - } - /* eslint-enable unicorn/no-null */); - const year = ( - { - ...resource(null).meta, - resourceKey: "opaque/previous-looking-key", - role: "year" as const, - unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", - } - ); + }; + + /* eslint-enable unicorn/no-null */ + const year = { + ...resource(null).meta, + resourceKey: "opaque/previous-looking-key", + role: "year" as const, + unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", + }; + for (const dependencies of [ [selected, previous, year], [year, selected, previous], @@ -945,6 +1028,7 @@ it("maps comparison notices by role regardless of dependency order or resource k ...resource(analytics).meta, dependencies, }); + expect(display?.previousNotice).toBe("comparisonFailed"); expect(display?.yearNotice).toBe("yearUnavailable"); expect(display?.previousComparison).not.toBeNull(); @@ -957,21 +1041,18 @@ it.each([false, true])( server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => HttpResponse.json( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ resource( - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { ...analytics, previous: null, yearComparison: null } - /* eslint-enable unicorn/no-null */), - ( - { - dependencies: legacy - ? [ - resource(null).meta, - resource(null).meta, - resource(null).meta, - ] - : [], - } - ), + { ...analytics, previous: null, yearComparison: null }, + /* eslint-enable unicorn/no-null */ { + dependencies: legacy + ? [ + resource(null).meta, + resource(null).meta, + resource(null).meta, + ] + : [], + }, ), ), ), @@ -992,21 +1073,22 @@ it.each([false, true])( it("shows selected income before comparison coverage without inventing a zero baseline", async () => { const dependencies = (["year", "selected", "previous"] as const).map( - role => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + role => + ({ ...resource(role === "selected" ? analytics : null).meta, role, - } - /* eslint-enable unicorn/no-null */)), + }), + /* eslint-enable unicorn/no-null */ ); + server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => HttpResponse.json( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ resource( - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { ...analytics, previous: null, yearComparison: null } - /* eslint-enable unicorn/no-null */), - { dependencies }, + { ...analytics, previous: null, yearComparison: null }, + /* eslint-enable unicorn/no-null */ { dependencies }, ), ), ), @@ -1031,20 +1113,26 @@ it("shows selected income before comparison coverage without inventing a zero ba it("keeps complete previous comparison while year source failed without inventing its amounts", async () => { const dependencies = [ - { ...resource(analytics).meta, role: "previous" as const }, - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - ...resource(null).meta, - role: "year" as const, - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: null, - state: "failed" as const, - }, - } - /* eslint-enable unicorn/no-null */), - { ...resource(analytics).meta, role: "selected" as const }, + { + ...resource(analytics).meta, + role: "previous" as const, + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + }, + { + ...resource(null).meta, + role: "year" as const, + sync: { + errorCode: "TBANK_UNAVAILABLE", + id: null, + state: "failed" as const, + }, + }, + /* eslint-enable unicorn/no-null */ { + ...resource(analytics).meta, + role: "selected" as const, + }, ]; + server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => HttpResponse.json(resource(analytics, { dependencies })), diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx index bdbfab6..353f09d 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx @@ -10,7 +10,14 @@ type AccountIncomePageProps = { accountId?: string }; const AccountIncomePage: FC = (props) => { const viewModel = useAccountIncomePageViewModel(props.accountId); if (viewModel.state === "loading") return ; - return viewModel.state === "error" ? : ; + + return viewModel.state === "error" + ? ( + + ) + : ( + + ); }; export { AccountIncomePage }; diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index ef00e28..c11cf4b 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -2,13 +2,12 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useParams, useSearchParams } from "react-router"; -import { z } from "zod"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { useScreenSync } from "@/features/tbank-connection"; -import { validationMessage } from "@/shared/i18n"; import { formatDate } from "@/shared/lib"; +import { incomeFormSchema } from "../../model/income.schema"; import { formatDraftDate, type IncomeForm, @@ -43,6 +42,7 @@ type AccountIncomePageViewModel state: "error"; } | { state: "loading" }; + type ComparisonNotice = | "comparisonFailed" | "comparisonPending" @@ -50,15 +50,18 @@ type ComparisonNotice | "yearUnavailable"; type IncomeDisplayModel = { - comparison: null | { - comparedPeriod: string; - selectedPeriod: string; - values: (NonNullable< - IncomeAnalytics["yearComparison"] - >["totalsByCurrency"][number] & { - percentages: Record<"coupon" | "dividend" | "total", null | string>; - })[]; - }; + comparison: null + | { + comparedPeriod: string; + selectedPeriod: string; + values: (NonNullable< + IncomeAnalytics["yearComparison"] + >["totalsByCurrency"][number] & { + percentages: Record<"coupon" + | "dividend" + | "total", null | string>; + })[]; + }; entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; previousComparison: IncomeDisplayModel["comparison"]; previousNotice: ComparisonNotice; @@ -67,33 +70,13 @@ type IncomeDisplayModel = { type IncomeMeta = ReturnType["meta"]; -const draftDate = z - .string() - .refine(value => parseDraftDate(value) !== null, { - error: validationMessage("income.validation.date"), - }) - .refine( - (value) => { - const date = parseDraftDate(value); - return !date || date <= moscowDate(new Date()); - }, - { error: validationMessage("income.validation.future") }, - ); -const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine( - ({ from, to }) => { - const start = parseDraftDate(from); - const end = parseDraftDate(to); - return !start || !end || start <= end; - }, - { path: ["to"], ...validationMessage("income.validation.period")({}) }, -); - const useAccountIncomePageViewModel = ( scopeId?: string, ): AccountIncomePageViewModel => { const { accountId: routeAccountId } = useParams(); const accountId = scopeId ?? routeAccountId ?? ""; const [searchParameters, setSearchParameters] = useSearchParams(); + const requested = useMemo( () => ({ from: searchParameters.get("from") ?? "", @@ -101,31 +84,39 @@ const useAccountIncomePageViewModel = ( }), [searchParameters], ); + const period = useMemo( () => (isValidPeriod(requested) ? requested : defaultPeriod()), [requested], ); + const form = useForm({ defaultValues: draftPeriod(period), resolver: zodResolver(incomeFormSchema), }); + const [resetRevision, setResetRevision] = useState(0); const analytics = useIncomeAnalytics(accountId, period); + const sync = useScreenSync( { accountId, from: period.from, screen: "income", to: period.to }, [analytics], ); useEffect(() => { - if (!isValidPeriod(requested)) setSearchParameters(period, { replace: true }); + if (!isValidPeriod(requested)) + setSearchParameters(period, { replace: true }); }, [period, requested, setSearchParameters]); useEffect(() => { form.reset(draftPeriod(period)); }, [form, period]); if (analytics.isLoading || !analytics.data) { - return analytics.isError || analytics.meta?.sync.state === "failed" ? { period, retry: sync.retry, state: "error" } : { state: "loading" }; + return analytics.isError || analytics.meta?.sync.state === "failed" + ? { period, retry: sync.retry, state: "error" } + : { state: "loading" }; } + const display = toIncomeDisplayModel(analytics.data, analytics.meta); if (!display) return { @@ -133,11 +124,13 @@ const useAccountIncomePageViewModel = ( retry: () => analytics.observe(), state: "error", }; + const onSubmit = (values: IncomeForm) => { const from = parseDraftDate(values.from); const to = parseDraftDate(values.to); if (from && to) setSearchParameters({ from, to }); }; + const onReset = () => { const next = defaultPeriod(); form.reset(draftPeriod(next)); @@ -145,6 +138,7 @@ const useAccountIncomePageViewModel = ( setResetRevision(revision => revision + 1); setSearchParameters(next, { replace: true }); }; + return { analytics: analytics.data, display, @@ -166,7 +160,10 @@ function comparisonDisplay( period: IncomePeriod, comparison: IncomeAnalytics["yearComparison"], ): IncomeDisplayModel["comparison"] { - if (!comparison) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + if (!comparison) + // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + return null; + return { comparedPeriod: formatComparisonPeriod(comparison.period), selectedPeriod: formatComparisonPeriod(period), @@ -192,11 +189,11 @@ function comparisonDisplay( function comparisonNotice( dependency: - | NonNullable["dependencies"]>[number] - | undefined, + NonNullable["dependencies"]>[number] | undefined, unavailable: "previousUnavailable" | "yearUnavailable", ): ComparisonNotice { if (!dependency || dependency.unavailableReason) return unavailable; + return dependency.sync.state === "failed" ? "comparisonFailed" : "comparisonPending"; @@ -204,6 +201,7 @@ function comparisonNotice( function defaultPeriod(): IncomePeriod { const today = moscowDate(new Date()); + return { from: `${today.slice(0, ISO_MONTH_LENGTH)}-01`, to: today }; } @@ -221,9 +219,14 @@ function formatIncomeChange( ): null | string { // Display-only ratio; server-provided Money values remain exact decimal strings. const base = Number(compared); - if (!Number.isFinite(base) || base <= 0) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + if (!Number.isFinite(base) || base <= 0) + // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + return null; const ratio = Number(difference) / base; - if (!Number.isFinite(ratio)) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + if (!Number.isFinite(ratio)) + // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + return null; + return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 1, signDisplay: "exceptZero", @@ -236,6 +239,7 @@ function isValidPeriod(period: IncomePeriod): period is IncomePeriod { const from = new Date(`${period.from}T00:00:00Z`); const to = new Date(`${period.to}T00:00:00Z`); const today = new Date(`${moscowDate(new Date())}T00:00:00Z`); + return ( from.toISOString().slice(0, ISO_DATE_LENGTH) === period.from && to.toISOString().slice(0, ISO_DATE_LENGTH) === period.to @@ -256,9 +260,11 @@ function toIncomeDisplayModel( receiptCount: number; } >(); + const currencies = new Set( analytics.totalsByCurrency.map(({ currency }) => currency), ); + for (const instrument of analytics.instruments) { if ( typeof instrument.key !== "string" @@ -274,8 +280,10 @@ function toIncomeDisplayModel( receiptCount: instrument.receiptCount, }); } + const entries: IncomeDisplayModel["entries"] = []; const occurrences = new Map(); + for (const entry of analytics.entries) { if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim()) // eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract. @@ -298,12 +306,16 @@ function toIncomeDisplayModel( operation: entry, }); } + for (const instrument of instruments.values()) { - if (instrument.includedCount !== instrument.receiptCount) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + if (instrument.includedCount !== instrument.receiptCount) + // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + return null; } const previous = meta?.dependencies?.find(({ role }) => role === "previous"); const year = meta?.dependencies?.find(({ role }) => role === "year"); + return { comparison: comparisonDisplay(analytics.period, analytics.yearComparison), entries, diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx index 51ad1a6..e4603ea 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx @@ -10,6 +10,7 @@ type ErrorProps = Extract; const AccountIncomePageError: FC = (props) => { const { t } = useTranslation(); + return ( {t("income.retry")}} diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx index cb5cfcd..67d4fb9 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx @@ -25,45 +25,46 @@ import { IncomeSummary } from "./IncomeSummary.view"; type LoadedProps = Extract; -const AccountIncomePageLoaded: FC = (props) => { - const { - analytics, - display, - form, - onReset, - onSubmit, - refreshError, - refreshing, - retry, - } = props; +const AccountIncomePageLoadedSummary: FC = (props) => { const { t } = useTranslation(); - function renderSummary() { - if (props.partial) { - return {t("investment.partial")}; - } - if (analytics.totalsByCurrency.length > 0) { - return ; - } - return ( - - ); + + const { analytics } = props; + + if (props.partial) { + return {t("investment.partial")}; } + + return analytics.totalsByCurrency.length > 0 + ? ( + + ) + : ( + + ); +}; + +const AccountIncomePageLoaded: FC = (props) => { + const { display, form, onReset, onSubmit, refreshError, refreshing, retry } + = props; + + const { t } = useTranslation(); + return ( - {!!(props.lastSuccessfulAt) && (( + {!!props.lastSuccessfulAt && ( {t("investment.sourceTime", { date: formatSnapshotTime(props.lastSuccessfulAt), })} - ))} + )} + {t("income.title")} + + = (props) => { onSubmit={form.handleSubmit(onSubmit)} /> - {!!(refreshing) && (( + + {!!refreshing && ( + {t("income.refreshing")} - ))} - {!!(refreshError) && (( + )} + + {!!refreshError && ( @@ -87,24 +91,30 @@ const AccountIncomePageLoaded: FC = (props) => { : "income.refreshError", )} + - ))} - {renderSummary()} + )} + + + {t("income.taxBoundary")} + + + ); diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx index b51136c..235d38d 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx @@ -8,24 +8,34 @@ import classes from "../AccountIncomePage.module.css"; const AccountIncomePageLoading: FC = () => { const { t } = useTranslation(); + return ( + +
+
+
+ + + + +
diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx index 9acf384..e01d963 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx @@ -12,12 +12,16 @@ import classes from "../AccountIncomePage.module.css"; type ComparisonProps = NonNullable & { title: string; }; + type ComparisonValuesProps = { - kind: "compared" | "difference" | "selected"; + kind: "compared" + | "difference" + | "selected"; label: string; percentages?: ComparisonProps["values"][number]["percentages"]; values: ComparisonProps["values"][number]["selected"]; }; + type IncomeComparisonsProps = { comparison: IncomeDisplayModel["comparison"]; notice: string; @@ -28,6 +32,7 @@ const IncomeComparisons: FC = (props) => { return ( {props.title} + {props.comparison ? ( @@ -45,6 +50,7 @@ const IncomeComparisons: FC = (props) => { const Comparison: FC = (props) => { const { t } = useTranslation(); + return (
@@ -61,11 +67,13 @@ const Comparison: FC = (props) => { label={props.selectedPeriod} values={value.selected} /> + + = (props) => {
))} - {props.values.length === 0 && (( + + {props.values.length === 0 && ( {t("income.comparisonEmpty")} - ))} + )} ); @@ -87,46 +96,59 @@ const Comparison: FC = (props) => { const ComparisonValues: FC = (props) => { const { t } = useTranslation(); + + const data_tone + = props.kind === "difference" + ? differenceTone(props.values.total.value) + : undefined; + return (
{props.label} +
{(["total", "coupon", "dividend"] as const).map((key) => { - const money = /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ props.values[key]; - const percentage = /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ props.percentages?.[key]; + const money + // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. + = props.values[key]; + + const percentage + // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. + = props.percentages?.[key]; + const tone = props.kind === "difference" ? differenceTone(money.value) : undefined; + + const appearanceClass + = key === "total" + ? classes.comparisonTotal + : classes.comparisonDetail; + return ( -
+
{t(`income.${key}`)}
+
{tone === "positive" ? "+" : ""} + {formatMoney(money)} - {!!(percentage) && (( + + {!!percentage && ( {percentage} - ))} + )}
); @@ -136,8 +158,11 @@ const ComparisonValues: FC = (props) => { ); }; -function differenceTone(value: string): "negative" | "neutral" | "positive" { +function differenceTone(value: string): "negative" + | "neutral" + | "positive" { if (!/[1-9]/.test(value)) return "neutral"; + return value.startsWith("-") ? "negative" : "positive"; } diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx index cf418ed..fd85150 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx @@ -10,13 +10,16 @@ import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import classes from "../AccountIncomePage.module.css"; type IncomePaymentsProps = { entries: LoadedProps["display"]["entries"] }; + type LoadedProps = Extract; const IncomePayments: FC = (props) => { const { t } = useTranslation(); + return ( {t("income.payments")} + {props.entries.map(({ key, operation }) => (
  • diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories.tsx index 8109871..eb26348 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories.tsx @@ -8,6 +8,7 @@ import type { IncomeForm } from "../../../../model/period"; import { IncomePeriodForm } from "./IncomePeriodForm.view"; const period = { from: "01.08.2026", to: "27.08.2026" }; + const errors = { to: { message: "Синтетическая ошибка периода", type: "manual" }, }; @@ -19,8 +20,13 @@ const PeriodExample: FC = (props) => { defaultValues: period, errors: props.invalid ? errors : undefined, }); + const { isSubmitSuccessful } = useFormState({ control: methods.control }); - const submitted = isSubmitSuccessful ? methods.getValues() : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null; + + const submitted = isSubmitSuccessful + ? methods.getValues() + : null; // eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + return ( = (props) => { }} onSubmit={methods.handleSubmit(() => {})} /> + {submitted ? `Применено: ${submitted.from} – ${submitted.to}` @@ -54,7 +61,9 @@ const meta = { } satisfies Meta; export default meta; + type Story = StoryObj; + export const Default: Story = {}; export const ValidationError: Story = { render: () => , diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx index 61d9953..a02faa5 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx @@ -2,17 +2,9 @@ import type { FC, SubmitEventHandler } from "react"; import { Button, Paper, Stack, Text } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; -import { useState } from "react"; -import { useController, useFormContext, useWatch } from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import { - formatDraftDate, - type IncomeForm, - moscowDate, - parseDraftDate, -} from "../../../../model/period"; import classes from "../../AccountIncomePage.module.css"; +import { useIncomePeriodFormVm } from "./IncomePeriodForm.vm"; type IncomePeriodFormProps = { onReset: () => void; @@ -20,23 +12,20 @@ type IncomePeriodFormProps = { }; const IncomePeriodForm: FC = (props) => { - const { t } = useTranslation(); const { - control, - formState: { isSubmitting }, - } = useFormContext(); - const { - field: { onBlur: onFromBlur, onChange: onFromChange, ref: fromRef }, - fieldState: { error: fromError }, - } = useController({ control, name: "from" }); - const { - field: { onBlur: onToBlur, onChange: onToChange }, - fieldState: { error: toError }, - } = useController({ control, name: "to" }); - const [from, to] = useWatch({ control, name: ["from", "to"] }); - const [today, setToday] = useState(() => moscowDate(new Date())); - const start = parseDraftDate(from); - const end = parseDraftDate(to); + end, + fromError, + fromRef, + isSubmitting, + onBlur2, + onPeriodChange, + popoverProps, + start, + t, + today, + toError, + } = useIncomePeriodFormVm(); + return ( = (props) => { > {t("income.period")} +
    = (props) => { error={fromError?.message ?? toError?.message} label={t("income.period")} maxDate={today} - onBlur={() => { - onFromBlur(); - onToBlur(); - }} - onChange={([from, to]) => { - onFromChange(from ? formatDraftDate(from) : ""); - onToChange(to ? formatDraftDate(to) : ""); - }} - popoverProps={{ - middlewares: { shift: { crossAxis: true } }, - onOpen: () => setToday(moscowDate(new Date())), - returnFocus: true, - }} + onBlur={onBlur2} + onChange={onPeriodChange} + popoverProps={popoverProps} ref={fromRef} type="range" value={[start, end]} valueFormat="DD.MM.YYYY" />
    +
    + + @@ -225,6 +230,7 @@ describe("AccountOperationsPage", () => { "*/api/v1/brokerage-accounts/:accountId/operations", ({ request }) => { searches.push(new URL(request.url).searchParams); + return HttpResponse.json( resource({ hasNext: true, @@ -249,6 +255,7 @@ describe("AccountOperationsPage", () => { initialIndex={0} > + } @@ -262,6 +269,7 @@ describe("AccountOperationsPage", () => { await screen.findByLabelText("Период"); expect(searches[0].get("from")).toBeNull(); expect(searches[0].get("to")).toBeNull(); + for (const expected of [ "?categories=trade", "?categories=trade", @@ -276,6 +284,7 @@ describe("AccountOperationsPage", () => { ); await screen.findByLabelText("Период"); } + expect(searches.at(-1)?.get("from")).toBe("2026-02-01T00:00:00.000Z"); expect(searches.at(-1)?.get("to")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Далее" })); @@ -313,9 +322,11 @@ describe("AccountOperationsPage", () => { new URL(request.url).searchParams.has("from") ? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }) : HttpResponse.json( - resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { hasNext: false, items: [], nextCursor: null } - /* eslint-enable unicorn/no-null */)), + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( + { hasNext: false, items: [], nextCursor: null }, + /* eslint-enable unicorn/no-null */ + ), ), ), ); @@ -328,6 +339,7 @@ describe("AccountOperationsPage", () => { ]} > + } @@ -354,16 +366,19 @@ describe("AccountOperationsPage", () => { ({ request }) => { const url = new URL(request.url); searches.push(url.search); + return HttpResponse.json( - resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( { accountId: "account-1", hasNext: !url.searchParams.get("cursor"), items: [operation], nextCursor: url.searchParams.get("cursor") ? null : "next-1", observedAt: "2026-08-14T00:00:00Z", - } - /* eslint-enable unicorn/no-null */)), + }, + /* eslint-enable unicorn/no-null */ + ), ); }, ), @@ -394,16 +409,19 @@ describe("AccountOperationsPage", () => { ({ request }) => { const url = new URL(request.url); searches.push(url.search); + return HttpResponse.json( - resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( { accountId: "account-1", hasNext: false, items: [operation], nextCursor: null, observedAt: "2026-08-14T00:00:00Z", - } - /* eslint-enable unicorn/no-null */)), + }, + /* eslint-enable unicorn/no-null */ + ), ); }, ), @@ -431,16 +449,19 @@ describe("AccountOperationsPage", () => { ({ request }) => { const url = new URL(request.url); searches.push(url.search); + return HttpResponse.json( - resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( { accountId: "account-1", hasNext: false, items: [operation], nextCursor: null, observedAt: "2026-08-14T00:00:00Z", - } - /* eslint-enable unicorn/no-null */)), + }, + /* eslint-enable unicorn/no-null */ + ), ); }, ), @@ -484,17 +505,26 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () => "*/api/v1/brokerage-accounts/:accountId/operations", ({ request }) => { requested.push(Object.fromEntries(new URL(request.url).searchParams)); + return HttpResponse.json( - resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + null, { - sync: { errorCode: "TBANK_UNAVAILABLE", id: null, state: "failed" }, - } - /* eslint-enable unicorn/no-null */)), + sync: { + errorCode: "TBANK_UNAVAILABLE", + id: null, + state: "failed", + }, + }, + /* eslint-enable unicorn/no-null */ + ), ); }, ), http.post("*/api/v1/tbank-sync", async ({ request }) => { syncRequests.push(await request.json()); + return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); }), ); @@ -529,16 +559,15 @@ it("does not label incomplete empty operations as a confirmed empty period", asy server.use( http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ resource( - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - accountId: "account-1", - hasNext: false, - items: [], - nextCursor: null, - } - /* eslint-enable unicorn/no-null */), - { availability: "partial" }, + { + accountId: "account-1", + hasNext: false, + items: [], + nextCursor: null, + }, + /* eslint-enable unicorn/no-null */ { availability: "partial" }, ), ), ), @@ -562,6 +591,7 @@ it("recovers an invalid cursor at the first page without clearing applied filter { code: "INVALID_OPERATIONS_CURSOR" }, { status: 400 }, ); + return HttpResponse.json( resource({ hasNext: true, diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx index 1ed7045..06744d3 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx @@ -14,7 +14,13 @@ const AccountOperationsPage: FC = (props) => { return ; } - return viewModel.state === "error" ? : ; + return viewModel.state === "error" + ? ( + + ) + : ( + + ); }; export { AccountOperationsPage }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index dd7b799..3122734 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -1,7 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { useParams, useSearchParams } from "react-router"; -import { z } from "zod"; import { type BrokerageOperation, @@ -12,6 +11,7 @@ import { } from "@/entities/broker-operation"; import { useScreenSync } from "@/features/tbank-connection"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; +import { isValidPeriod } from "@/shared/lib"; type AccountOperationsPageViewModel = | { @@ -51,30 +51,44 @@ const useAccountOperationsPageVm = ( const urlFrom = searchParameters.get("from"); const urlTo = searchParameters.get("to"); const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo); - const appliedFrom = isValidUrlPeriod ? urlFrom : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; - const appliedTo = isValidUrlPeriod ? urlTo : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + + const appliedFrom = isValidUrlPeriod + ? urlFrom + : null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + + const appliedTo = isValidUrlPeriod + ? urlTo + : null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + const appliedCategories = operationCategories.filter(category => (searchParameters.get("categories") ?? "").split(",").includes(category), ); + const [draftFrom, setDraftFrom] = useState(appliedFrom); const [draftTo, setDraftTo] = useState(appliedTo); + const [draftCategories, setDraftCategories] = useState(appliedCategories); + const scopeKey = JSON.stringify([ accountId, urlFrom, urlTo, appliedCategories, ]); + const [pagination, setPagination] = useState<{ cursors: string[]; scopeKey: string; }>({ cursors: [], scopeKey }); + const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : []; const cursor = cursors.at(-1); + if (pagination.scopeKey !== scopeKey) { setPagination({ cursors: [], scopeKey }); } + useEffect(() => { if (isValidUrlPeriod) return; const normalized = new URLSearchParams(searchParameters); @@ -83,38 +97,52 @@ const useAccountOperationsPageVm = ( setSearchParameters(normalized, { replace: true }); }, [isValidUrlPeriod, searchParameters, setSearchParameters]); const [previousSearch, setPreviousSearch] = useState(searchParameters); + if (previousSearch !== searchParameters) { setPreviousSearch(searchParameters); setDraftFrom(appliedFrom); setDraftTo(appliedTo); setDraftCategories( operationCategories.filter(category => - (searchParameters.get("categories") ?? "").split(",").includes(category), + (searchParameters.get("categories") ?? "") + .split(",") + .includes(category), ), ); } + const firstPageQuery = { from: dateStart(appliedFrom), to: dateEnd(appliedTo), - ...((appliedCategories.length > 0) && { categories: appliedCategories }), + ...(appliedCategories.length > 0 && { categories: appliedCategories }), limit: 100, }; + const operations = useBrokerageOperations(accountId, { ...firstPageQuery, ...(cursor && { cursor }), }); + const sync = useScreenSync( { accountId, screen: "operations", ...firstPageQuery }, [operations], ); + if (operations.isLoading) return { state: "loading" }; + const resetQueryKey = brokerageOperationsQueryOptions(accountId, { limit: 100, }).queryKey; const resetCursors = () => setPagination({ cursors: [], scopeKey }); + const retry = () => { - if (isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor)) { + if ( + isApiErrorCode( + apiErrorCode(operations.error), + ApiErrorCode.InvalidOperationsCursor, + ) + ) { void queryClient.invalidateQueries({ exact: true, queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery) @@ -124,6 +152,7 @@ const useAccountOperationsPageVm = ( } else void sync.retry(); }; + const onReset = () => { setDraftFrom(null); setDraftTo(null); @@ -136,7 +165,10 @@ const useAccountOperationsPageVm = ( if ( (operations.isError || operations.meta?.sync.state === "failed") && (!operations.data - || isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor)) + || isApiErrorCode( + apiErrorCode(operations.error), + ApiErrorCode.InvalidOperationsCursor, + )) ) return { onReset, retry, state: "error" }; if (!operations.data) return { state: "loading" }; @@ -164,7 +196,9 @@ const useAccountOperationsPageVm = ( setSearchParameters({ ...(draftFrom && { from: draftFrom }), ...(draftTo && { to: draftTo }), - ...((draftCategories.length > 0) && { categories: draftCategories.join(",") }), + ...(draftCategories.length > 0 && { + categories: draftCategories.join(","), + }), }); resetCursors(); }, @@ -187,7 +221,6 @@ const useAccountOperationsPageVm = ( }; }; -const calendarDate = z.iso.date(); function dateEnd(value: null | string) { return value ? `${value}T23:59:59.999Z` : undefined; } @@ -200,13 +233,4 @@ function isSameCategories(a: OperationCategory[], b: OperationCategory[]) { return a.length === b.length && a.every(value => b.includes(value)); } -function isValidPeriod(from: null | string, to: null | string) { - return ( - [from, to].every( - date => date === null || calendarDate.safeParse(date).success, - ) - && (!from || !to || from <= to) - ); -} - export { type AccountOperationsPageViewModel, useAccountOperationsPageVm }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx index 3a8e4cb..fd1fdf6 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx @@ -22,6 +22,7 @@ const AccountOperationsPageError: FC = ( action={( + diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx index 84d7fa1..e808bea 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx @@ -1,4 +1,4 @@ -import type { FC, ReactNode } from "react"; +import type { FC } from "react"; import { Alert, @@ -14,120 +14,114 @@ import { DatePickerInput } from "@mantine/dates"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; -import { - LedgerEntryRow, - operationCategories, - type OperationCategory, -} from "@/entities/broker-operation"; +import { LedgerEntryRow } from "@/entities/broker-operation"; import { formatSnapshotTime } from "@/shared/lib"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; +import { useAccountOperationsPageLoadedVm } from "./AccountOperationsPageLoaded.vm"; + type AccountOperationsPageLoadedProps = Extract< AccountOperationsPageViewModel, { state: "loaded" } >; -const operationCategorySet = new Set(operationCategories); +const AccountOperationsPageLoadedOperations: FC< + AccountOperationsPageLoadedProps +> = (props) => { // eslint-disable-line sonarjs/function-return-type -- React renders elements and lists. + const { t } = useTranslation(); -function fromMultiSelectValue(value: string[]) { - return value.filter((item): item is OperationCategory => - operationCategorySet.has(item), + const { items } = props; + + if (items.length > 0) { + return items.map(operation => ( + + )); + } + + if (props.partial) { + return; + } + + return ( + ); -} +}; const AccountOperationsPageLoaded: FC = ( props, ) => { const { canApply, + categoryData, draftCategories, draftFrom, draftTo, hasNext, hasPrevious, - items, onApply, - onDraftCategoriesChange, - onDraftFromChange, - onDraftToChange, + onCategoriesChange, onNext, + onPeriodChange, onPrevious, onReset, - } = props; - const { t } = useTranslation(); + t, + } = useAccountOperationsPageLoadedVm(props); - const categoryData = operationCategories.map(category => ({ - label: t(`ledger.category.${category}`), - value: category, - })); - - // eslint-disable-next-line sonarjs/function-return-type -- ReactNode deliberately includes lists, elements and empty output. - function renderOperations(): ReactNode { - if (items.length > 0) { - return items.map(operation => ( - - )); - } - if (props.partial) { - return; - } - return ( - - ); - } return ( - {!!(props.lastSuccessfulAt) && (( + {!!props.lastSuccessfulAt && ( {t("investment.sourceTime", { date: formatSnapshotTime(props.lastSuccessfulAt), })} - ))} + )} + {t("ledger.title")} + - {!!(props.partial) && (( + + {!!props.partial && ( {t("investment.partial")} - ))} - {!!(props.historyLoading) && ({t("investment.history")})} - {!!(props.refreshing) && (( + )} + + {!!props.historyLoading && {t("investment.history")}} + + {!!props.refreshing && ( - ))} - {!!(props.refreshError) && (( + )} + + {!!props.refreshError && ( {t( props.sourceError ? "investment.syncFailed" : "accounts.refreshError", )} + - ))} + )} + { - onDraftFromChange(from); - onDraftToChange(to); - }} + onChange={onPeriodChange} popoverProps={{ middlewares: { shift: { crossAxis: true } }, returnFocus: true, @@ -137,35 +131,40 @@ const AccountOperationsPageLoaded: FC = ( valueFormat="DD.MM.YYYY" /> + - onDraftCategoriesChange(fromMultiSelectValue(value))} + onChange={onCategoriesChange} placeholder={t("ledger.categoriesPlaceholder")} value={draftCategories} /> + + - {renderOperations()} - {!!(hasNext || hasPrevious) && (( + + + + {!!(hasNext || hasPrevious) && ( + - ))} + )} ); }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts new file mode 100644 index 0000000..5ee01cc --- /dev/null +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.vm.ts @@ -0,0 +1,78 @@ +import { useTranslation } from "react-i18next"; + +import { + operationCategories, + type OperationCategory, +} from "@/entities/broker-operation"; + +import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; + +type AccountOperationsPageLoadedProps = Extract< + AccountOperationsPageViewModel, + { state: "loaded" } +>; + +const operationCategorySet = new Set(operationCategories); + +function fromMultiSelectValue(value: string[]) { + return value.filter((item): item is OperationCategory => + operationCategorySet.has(item), + ); +} + +function useAccountOperationsPageLoadedVm( + props: AccountOperationsPageLoadedProps, +) { + const { + canApply, + draftCategories, + draftFrom, + draftTo, + hasNext, + hasPrevious, + onApply, + onDraftCategoriesChange, + onDraftFromChange, + onDraftToChange, + onNext, + onPrevious, + onReset, + } = props; + + const { t } = useTranslation(); + + const categoryData = operationCategories.map(category => ({ + label: t(`ledger.category.${category}`), + value: category, + })); + + const onPeriodChange: (value: [null | string, null | string]) => void = ([ + from, + to, + ]) => { + onDraftFromChange(from); + onDraftToChange(to); + }; + + const onCategoriesChange: (value: string[]) => void = value => + onDraftCategoriesChange(fromMultiSelectValue(value)); + + return { + canApply, + categoryData, + draftCategories, + draftFrom, + draftTo, + hasNext, + hasPrevious, + onApply, + onCategoriesChange, + onNext, + onPeriodChange, + onPrevious, + onReset, + t, + }; +} + +export { useAccountOperationsPageLoadedVm }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoading.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoading.view.tsx index fbbcc98..239229a 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoading.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoading.view.tsx @@ -12,15 +12,21 @@ const AccountOperationsPageLoading: FC = () => { return ( + + + + + + {skeletonRowIds.map(id => ( diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index ab19dbf..e6a3e37 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -41,6 +41,7 @@ function configureTestAuth() { function renderAccountRoute(queryClient: QueryClient) { configureTestAuth(); + return render( @@ -56,6 +57,7 @@ function renderAccountRoute(queryClient: QueryClient) { function renderPage(ui: React.ReactNode) { const queryClient = createTestQueryClient(); + return render( @@ -65,8 +67,10 @@ function renderPage(ui: React.ReactNode) { ); } -const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +const portfolio: Portfolio + = { cashBalances: [{ currency: "USD", value: "0.5" }], isStale: true, lastSuccessfulAt: "2026-08-13T10:00:00Z", @@ -98,8 +102,8 @@ const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture prese ], structure: { currencies: [], unvaluedCategories: [] }, totalsByCurrency: [{ currency: "RUB", value: "1234.5" }], - } -/* eslint-enable unicorn/no-null */); + }; +/* eslint-enable unicorn/no-null */ const emptyRecentOperations: AccountPageRecentOperations = { state: "empty", @@ -141,16 +145,20 @@ describe("AccountPage states", () => { const slug = type === "STOCK" ? "SBERP" : "SU26238RMFS4"; const uid = "c190ff1f-1447-4227-b543-316332699ca5"; const requests = vi.fn(); - const position = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + + const position + = { ...portfolio.positions[0], displayName: hasMetadata ? "Сбербанк привилегированные" : "FIGI-2", instrumentUid: uid, logoName: hasMetadata ? "vtoraya.png" : null, ticker: hasMetadata ? slug : null, type, - } - /* eslint-enable unicorn/no-null */); + }; + + /* eslint-enable unicorn/no-null */ server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json( @@ -168,6 +176,7 @@ describe("AccountPage states", () => { ), http.get(`*/api/v1/${collection}/:uid`, () => { requests(); + return HttpResponse.json( resource({ instrumentUid: uid, @@ -186,6 +195,7 @@ describe("AccountPage states", () => { const links = screen.getAllByRole("link", { name: position.displayName, }); + expect(links).toHaveLength(2); for (const link of links) expect(link).toHaveAttribute( @@ -234,6 +244,7 @@ describe("AccountPage states", () => { ), http.get("*/api/v1/stocks/:uid", () => { requests(); + return scenario === "unavailable" ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json(resource({ instrumentUid: uid, slug: uid })); @@ -286,15 +297,12 @@ describe("AccountPage states", () => { }, ); - it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])( - "allows retry for %s", - (code) => { - const retry = vi.fn(); - renderPage(); - fireEvent.click(screen.getByRole("button", { name: "Повторить" })); - expect(retry).toHaveBeenCalledOnce(); - }, - ); + it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])("allows retry for %s", (code) => { + const retry = vi.fn(); + renderPage(); + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); + expect(retry).toHaveBeenCalledOnce(); + }); it("shows supplied cash separately without adding currency instruments or recalculating totals", () => { renderPage( @@ -320,9 +328,11 @@ describe("AccountPage states", () => { })} />, ); + const balances = within( screen.getByRole("region", { name: "Денежные остатки" }), ); + expect( balances.getAllByRole("definition").map(node => node.textContent), ).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]); @@ -376,6 +386,7 @@ describe("AccountPage states", () => { structure: { currencies: [], unvaluedCategories: [] }, totalsByCurrency, }; + server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(resource(snapshot)), @@ -447,9 +458,11 @@ describe("AccountPage states", () => { expect( screen.getByRole("button", { name: "Облигации" }), ).toBeInTheDocument(); + const positions = screen .getAllByText(/Вторая|Первая/) .map(element => element.textContent); + expect(positions).toEqual(["Вторая", "Первая"]); expect(screen.getByText("10 ₽")).toBeInTheDocument(); expect(screen.getByText("-1 ₽")).toBeInTheDocument(); @@ -470,7 +483,8 @@ describe("AccountPage states", () => { it("renders stock cards with logo fallback and signed unlabeled return", () => { renderPage( { type: "STOCK", }, ], - } - /* eslint-enable unicorn/no-null */))} + }, + /* eslint-enable unicorn/no-null */ + )} />, ); expect(screen.getByText("А")).toBeInTheDocument(); @@ -513,7 +528,8 @@ describe("AccountPage states", () => { it("groups stock supporting metrics into quiet single lines", () => { renderPage( { type: "STOCK", }, ], - } - /* eslint-enable unicorn/no-null */))} + }, + /* eslint-enable unicorn/no-null */ + )} />, ); @@ -546,7 +563,8 @@ describe("AccountPage states", () => { it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => { renderPage( { type: "BOND", }, ], - } - /* eslint-enable unicorn/no-null */))} + }, + /* eslint-enable unicorn/no-null */ + )} />, ); @@ -604,7 +623,8 @@ describe("AccountPage states", () => { it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => { renderPage( { type: "BOND", }, ], - } - /* eslint-enable unicorn/no-null */))} + }, + /* eslint-enable unicorn/no-null */ + )} />, ); expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute( @@ -653,7 +674,8 @@ describe("AccountPage states", () => { it("renders bond cards in expanded layout with bond-specific classes", () => { renderPage( { type: "BOND", }, ], - } - /* eslint-enable unicorn/no-null */))} + }, + /* eslint-enable unicorn/no-null */ + )} />, ); expect(screen.getByText("О")).toBeInTheDocument(); @@ -684,7 +707,8 @@ describe("AccountPage states", () => { it("keeps other-position cards compact with labeled return and no logo", () => { renderPage( { type: "FUND", }, ], - } - /* eslint-enable unicorn/no-null */))} + }, + /* eslint-enable unicorn/no-null */ + )} />, ); expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument(); @@ -713,7 +738,8 @@ describe("AccountPage states", () => { renderPage( { portfolioSummary: null, type: "BROKERAGE", } - /* eslint-enable unicorn/no-null */)} + /* eslint-enable unicorn/no-null */ + } />, ); expect( @@ -750,7 +777,8 @@ describe("AccountPage states", () => { renderPage( { state: "loaded", to: "/accounts/account-1/operations", } - /* eslint-enable unicorn/no-null */)} + /* eslint-enable unicorn/no-null */ + } />, ); @@ -783,8 +812,9 @@ describe("AccountPage states", () => { }); it("keeps future events ordered with calendar dates and projected rather than posted amounts", () => { - const event: BrokerageEvent = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + const event: BrokerageEvent + = { date: "2026-09-10", dateKind: "event", displayName: "Облигация", @@ -795,12 +825,14 @@ describe("AccountPage states", () => { projectedAmount: { currency: "RUB", value: "850.50" }, ticker: "DEMO", type: "coupon", - } - /* eslint-enable unicorn/no-null */); + }; + + /* eslint-enable unicorn/no-null */ renderPage( { state: "loaded", to: "/accounts/account-1/events", } - /* eslint-enable unicorn/no-null */)} + /* eslint-enable unicorn/no-null */ + } />, ); const section = screen.getByRole("region", { name: "Будущие события" }); const content = within(section); const text = section.textContent ?? ""; + const titles = [ "Купон · Облигация", "Дивиденд · Акция", "Погашение · Облигация", "Оферта · Облигация", ]; + for (const title of titles) expect(content.getByText(title)).toBeVisible(); expect(titles.map(title => text.indexOf(title))).toEqual( titles.map(title => text.indexOf(title)).toSorted((a, b) => a - b), @@ -870,13 +905,16 @@ describe("AccountPage states", () => { recentEvents={{ state, to: "/accounts/account-1/events" }} />, ); + const content = within( screen.getByRole("region", { name: "Будущие события" }), ); + if (state === "loading") { const skeleton = content.getByRole("status", { name: "Загрузка будущих событий", }); + // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(skeleton).toHaveAttribute("aria-busy", "true"); // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. @@ -888,6 +926,7 @@ describe("AccountPage states", () => { content.getByText("На ближайшие 30 дней событий не найдено."), ).toBeVisible(); } + expect( content.getByRole("link", { name: "Открыть будущие события" }), ).toHaveAttribute("href", "/accounts/account-1/events"); @@ -914,8 +953,9 @@ describe("AccountPage states", () => { it.each([503])( "preserves totals and UTC snapshot through refresh error %i, retry and success", async (status) => { - const initialPortfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + const initialPortfolio: Portfolio + = { ...portfolio, structure: { currencies: [ @@ -928,8 +968,9 @@ describe("AccountPage states", () => { ], unvaluedCategories: [{ count: 1, type: "STOCK" }], }, - } - /* eslint-enable unicorn/no-null */); + }; + + /* eslint-enable unicorn/no-null */ let requestCount = 0; let resolveRetry: ((response: Response) => void) | undefined; server.use( @@ -944,7 +985,8 @@ describe("AccountPage states", () => { ), ), http.get("*/api/v1/tbank-sync/:id", () => - HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + HttpResponse.json( { errorCode: null, id: "overview", @@ -956,8 +998,9 @@ describe("AccountPage states", () => { ], scope: { accountId: "account-1", screen: "account" }, state: "succeeded", - } - /* eslint-enable unicorn/no-null */)), + }, + /* eslint-enable unicorn/no-null */ + ), ), http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { requestCount += 1; @@ -965,11 +1008,14 @@ describe("AccountPage states", () => { return HttpResponse.json( resource(initialPortfolio, { resourceKey: "portfolio" }), ); + if (requestCount === 3) { const deferred = Promise.withResolvers(); resolveRetry = deferred.resolve; + return deferred.promise; } + return HttpResponse.json( { code: status === 404 ? "NOT_FOUND" : "TBANK_UNAVAILABLE" }, { status }, @@ -980,9 +1026,11 @@ describe("AccountPage states", () => { renderAccountRoute(queryClient); await screen.findByText("Вторая"); + const structure = screen.getByRole("region", { name: "Структура портфеля", }); + expect(structure).toHaveTextContent("10 ₽"); const snapshotText = screen.getByText(/Данные получены/).textContent; expect(snapshotText).toContain("10:00"); @@ -1057,9 +1105,11 @@ describe("AccountPage states", () => { http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { requestCount += 1; if (requestCount === 1) return HttpResponse.json(resource(portfolio)); + { const deferred = Promise.withResolvers(); resolveRefresh = deferred.resolve; + return deferred.promise; } }), diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index 6a9f1b4..4512daf 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -26,6 +26,7 @@ const AccountPage: FC = (props) => { sourceError={viewModel.sourceError} /> ); + return ( = props => ( @@ -87,6 +90,7 @@ function createWrapper() { ); + return Wrapper; } @@ -97,7 +101,8 @@ function mockLoadedAccountPage() { ), http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json( - resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + resource( { accountId: "account-1", hasNext: false, @@ -121,8 +126,9 @@ function mockLoadedAccountPage() { ], nextCursor: null, observedAt: "2026-08-13T10:00:00Z", - } - /* eslint-enable unicorn/no-null */)), + }, + /* eslint-enable unicorn/no-null */ + ), ), ), http.get("*/api/v1/brokerage-accounts/:accountId/events", () => @@ -130,18 +136,22 @@ function mockLoadedAccountPage() { resource({ accountId: "account-1", calculatedAt: "2026-08-13T10:00:00Z", - events: Array.from({ length: 6 }, (_, index) => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - date: `2026-09-${String(index + 1).padStart(2, "0")}`, - dateKind: "event", - displayName: `Купон ${index}`, - figi: `FIGI-EVENT-${index}`, - instrumentUid: null, - projectedAmount: { currency: "RUB", value: String(index + 1) }, - ticker: `BND${index}`, - type: "coupon", - } - /* eslint-enable unicorn/no-null */))), + events: Array.from( + { length: 6 }, + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + (_, index) => + ({ + date: `2026-09-${String(index + 1).padStart(2, "0")}`, + dateKind: "event", + displayName: `Купон ${index}`, + figi: `FIGI-EVENT-${index}`, + instrumentUid: null, + projectedAmount: { currency: "RUB", value: String(index + 1) }, + ticker: `BND${index}`, + type: "coupon", + }), + /* eslint-enable unicorn/no-null */ + ), }), ), ), @@ -158,6 +168,7 @@ describe("useAccountPageViewModel", () => { "DIGITAL_FINANCIAL_ASSET", "FUND", ] as const; + const snapshot: Portfolio = { ...portfolio, cashBalances: [{ currency: "RUB", value: "1000" }], @@ -169,19 +180,23 @@ describe("useAccountPageViewModel", () => { figi: "FIGI-CURRENCY", type: "CURRENCY", }, - ...otherTypes.map(type => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - ...portfolio.positions[2], - currentValue: - type === "OPTION" ? { currency: "RUB", value: "50" } : null, - figi: `FIGI-${type}-NEXT`, - type, - } - /* eslint-enable unicorn/no-null */))), + ...otherTypes.map( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + type => + ({ + ...portfolio.positions[2], + currentValue: + type === "OPTION" ? { currency: "RUB", value: "50" } : null, + figi: `FIGI-${type}-NEXT`, + type, + }), + /* eslint-enable unicorn/no-null */ + ), ], structure: { currencies: [], unvaluedCategories: [] }, totalsByCurrency: [{ currency: "RUB", value: "1250" }], }; + const original = structuredClone(snapshot); const sections = toAccountPagePositionSections(snapshot); @@ -210,10 +225,12 @@ describe("useAccountPageViewModel", () => { }); await waitFor(() => expect(result.current.state).toBe("loaded")); + const loaded = result.current as Extract< typeof result.current, { state: "loaded" } >; + expect( loaded.positionSections.map(({ groups, kind }) => ({ kind, @@ -231,35 +248,32 @@ describe("useAccountPageViewModel", () => { shareText: "66,7 %", }, ], + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + }, + { + kind: "bonds", + positions: [ + { + displayName: "Облигация", + logoSrc: null, + returnText: "-5 ₽", + returnTone: "negative", + shareText: "33,3 %", + }, + ], + }, + /* eslint-enable unicorn/no-null */ { + kind: "other", + positions: [ + { + displayName: "Фонд", + logoSrc: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture. + returnText: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture. + returnTone: undefined, + shareText: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture. + }, + ], }, - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - kind: "bonds", - positions: [ - { - displayName: "Облигация", - logoSrc: null, - returnText: "-5 ₽", - returnTone: "negative", - shareText: "33,3 %", - }, - ], - } - /* eslint-enable unicorn/no-null */), - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - kind: "other", - positions: [ - { - displayName: "Фонд", - logoSrc: null, - returnText: null, - returnTone: undefined, - shareText: null, - }, - ], - } - /* eslint-enable unicorn/no-null */), ]); }); @@ -276,10 +290,12 @@ describe("useAccountPageViewModel", () => { (result.current as { recentEvents?: { state: string } }).recentEvents, )?.toMatchObject({ state: "loaded" }), ); + const loaded = result.current as Extract< typeof result.current, { state: "loaded" } >; + expect(loaded.recentOperations).toMatchObject({ items: [{ description: "Покупка акции", id: "operation-1" }], state: "loaded", @@ -335,11 +351,10 @@ it("keeps overview sections independent when their source data is incomplete", a ), http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ resource( - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { hasNext: false, items: [], nextCursor: null } - /* eslint-enable unicorn/no-null */), - { availability: "partial" }, + { hasNext: false, items: [], nextCursor: null }, + /* eslint-enable unicorn/no-null */ { availability: "partial" }, ), ), ), @@ -352,9 +367,11 @@ it("keeps overview sections independent when their source data is incomplete", a ), ), ); + const { result } = renderHook(() => useAccountPageViewModel(), { wrapper: createWrapper(), }); + await waitFor(() => expect(result.current.state).toBe("loaded")); await waitFor(() => expect(result.current).toMatchObject({ @@ -367,14 +384,18 @@ it("keeps overview sections independent when their source data is incomplete", a it("refreshes every overview section through one account descriptor and observational reads", async () => { configureTestAuth(); - const sections: Record = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + + const sections: Record + = { "events": { events: [], incomeTotalsByCurrency: [] }, "income-summary": { totalsByCurrency: [] }, "operations": { hasNext: false, items: [], nextCursor: null }, portfolio, - } - /* eslint-enable unicorn/no-null */); + }; + + /* eslint-enable unicorn/no-null */ const observed: string[] = []; const scopes: unknown[] = []; let version = "1"; @@ -385,6 +406,7 @@ it("refreshes every overview section through one account descriptor and observat ({ request }) => { if (new URL(request.url).searchParams.get("sync") === "observe") observed.push(path); + return HttpResponse.json( resource(data, { resourceKey: path, stateVersion: version }), ); @@ -394,6 +416,7 @@ it("refreshes every overview section through one account descriptor and observat http.post("*/api/v1/tbank-sync", async ({ request }) => { scopes.push(await request.json()); version = "2"; + return HttpResponse.json( { id: "overview", @@ -404,7 +427,8 @@ it("refreshes every overview section through one account descriptor and observat ); }), http.get("*/api/v1/tbank-sync/:id", () => - HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + HttpResponse.json( { errorCode: null, id: "overview", @@ -416,13 +440,16 @@ it("refreshes every overview section through one account descriptor and observat ), scope: null, state: "succeeded", - } - /* eslint-enable unicorn/no-null */)), + }, + /* eslint-enable unicorn/no-null */ + ), ), ); + const { result } = renderHook(() => useAccountPageViewModel(), { wrapper: createWrapper(), }); + await waitFor(() => expect(result.current).toMatchObject({ incomeSummary: { state: "empty" }, diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 8bfa236..bbc4641 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -33,6 +33,7 @@ type AccountPageContent = { retry: () => Promise | undefined; sourceError?: boolean; }; + type AccountPageEmptyViewModel = AccountPageContent & { state: "empty"; }; @@ -47,7 +48,9 @@ type AccountPageIncomeSummary to: string; } | { retry: () => Promise | undefined; state: "error"; to: string } - | { state: "empty" | "loading" | "partial"; to: string }; + | { state: "empty" + | "loading" + | "partial"; to: string; }; type AccountPageLoadedViewModel = AccountPageContent & { portfolio: Portfolio; @@ -85,7 +88,9 @@ type AccountPageRecentOperations to: string; } | { - state: "empty" | "error" | "loading"; + state: "empty" + | "error" + | "loading"; to: string; }; @@ -97,23 +102,31 @@ type AccountPageViewModel | { code?: string; retry: () => Promise | undefined; state: "error" } | { state: "loading" }; -type PositionSectionKind = "bonds" | "other" | "stocks"; +type PositionSectionKind = "bonds" + | "other" + | "stocks"; function groupPositions( positions: AccountPagePosition[], ): AccountPagePositionGroup[] { const groups = new Map(); + for (const [index, position] of positions.entries()) { const identity = positionIdentity(position); const rowKey = JSON.stringify(["row", position.account?.id, index]); + const key = identity && position.account ? JSON.stringify([position.type, ...identity]) : rowKey; + const group = groups.get(key); + if ( group - && group.positions.every(item => item.account?.id !== position.account?.id) + && group.positions.every( + item => item.account?.id !== position.account?.id, + ) ) { group.positions.push(position); } @@ -122,21 +135,26 @@ function groupPositions( groups.set(uniqueKey, { key: uniqueKey, positions: [position] }); } } + // eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline. return [...groups.values()]; } function positionIdentity(position: AccountPagePosition) { - if (position.instrumentUid?.trim()) return ["uid", position.instrumentUid.trim()]; + if (position.instrumentUid?.trim()) + return ["uid", position.instrumentUid.trim()]; if (position.figi?.trim()) return ["figi", position.figi.trim()]; + return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined; } function toAccountPagePositionSections( portfolio: Portfolio, - instrumentDetails: Map = new Map(), + instrumentDetails: Map = new Map(), ): AccountPagePositionSection[] { const denominators = new Map(); + for (const position of portfolio.positions) { if (!position.currentValue) continue; const currency = position.currentValue.currency; @@ -145,14 +163,17 @@ function toAccountPagePositionSections( (denominators.get(currency) ?? 0) + Number(position.currentValue.value), ); } + const stocks = toSection( "stocks", portfolio, denominators, instrumentDetails, ); + const bonds = toSection("bonds", portfolio, denominators, instrumentDetails); const other = toSection("other", portfolio, denominators, instrumentDetails); + return [stocks, bonds, other].filter(section => section.groups.length > 0); } @@ -167,6 +188,7 @@ function toIncomeSummary( if (!income.data && (income.isError || income.meta?.sync.state === "failed")) return { retry: () => income.refetch(), state: "error", to }; if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; + return { lastSuccessfulAt: income.meta?.lastSuccessfulAt, refreshError: income.isError || income.meta?.sync.state === "failed", @@ -181,32 +203,40 @@ function toPosition( position: Portfolio["positions"][number], denominators: Map, kind: PositionSectionKind, - instrumentSlug: string | undefined, + instrumentSlug?: string, ): AccountPagePosition { const isExpanded = kind !== "other"; + const returnValue = position.absoluteReturn ? Number(position.absoluteReturn.value) - : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + : null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + let returnTone: AccountPageReturnTone | undefined; - if (returnValue !== null && returnValue !== 0) returnTone = returnValue > 0 ? "positive" : "negative"; - const returnPrefix = isExpanded && returnValue !== null && returnValue > 0 ? "+" : ""; + if (returnValue !== null && returnValue !== 0) + returnTone = returnValue > 0 ? "positive" : "negative"; + + const returnPrefix + = isExpanded && returnValue !== null && returnValue > 0 ? "+" : ""; + return { ...position, instrumentTo: (kind === "stocks" || kind === "bonds") && position.instrumentUid ? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}` : undefined, - logoSrc: isExpanded ? instrumentLogoUrl(position.logoName) : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null, + logoSrc: isExpanded + ? instrumentLogoUrl(position.logoName) + : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. returnText: position.absoluteReturn ? `${returnPrefix}${formatMoney(position.absoluteReturn)}` - : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null, + : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. returnTone, shareText: position.currentValue?.currency ? formatShare(position.currentValue, { currency: position.currentValue.currency, value: String(denominators.get(position.currentValue.currency) ?? 0), }) - : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null, + : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. }; } @@ -227,6 +257,7 @@ function toRecentEvents( return { state: "error", to }; const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? []; if (items.length === 0) return { state: "empty", to }; + return { items, lastSuccessfulAt: events.meta?.lastSuccessfulAt, @@ -256,6 +287,7 @@ function toRecentOperations( ) return { state: "error", to }; if (!operations.data?.items.length) return { state: "empty", to }; + return { items: operations.data.items, lastSuccessfulAt: operations.meta?.lastSuccessfulAt, @@ -271,20 +303,20 @@ function toSection( kind: PositionSectionKind, portfolio: Portfolio, denominators: Map, - instrumentDetails: Map, + instrumentDetails: Map, ): AccountPagePositionSection { return { groups: groupPositions( portfolio.positions .filter((position) => { if (kind === "stocks") return position.type === "STOCK"; + return kind === "bonds" ? position.type === "BOND" - : ( - position.type !== "STOCK" - && position.type !== "BOND" - && position.type !== "CURRENCY" - ); + : position.type !== "STOCK" + && position.type !== "BOND" + && position.type !== "CURRENCY"; }) .map(position => toPosition( @@ -303,6 +335,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { const { accountId: routeAccountId } = useParams(); const accountId = scopeId ?? routeAccountId; const portfolio = usePortfolio(accountId); + const stocks = useStocks( portfolio.data?.positions.flatMap(position => position.type === "STOCK" && position.instrumentUid @@ -310,13 +343,17 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { : [], ) ?? [], ); + const instrumentDetails = stocks; + const operations = useBrokerageOperations(accountId, { limit: 5, }); + const events = useBrokerageEvents(accountId); const income = useIncomeSummary(accountId); const accounts = useBrokerageAccounts(); + const sync = useScreenSync({ accountId, screen: "account" }, [ accounts, portfolio, @@ -332,12 +369,15 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { retry: sync.retry, state: "error" as const, }; + return { state: "loading" as const }; } + const account = accounts.data?.find(item => item.id === accountId); const recentOperations = toRecentOperations(accountId, operations); const recentEvents = toRecentEvents(accountId, events); const incomeSummary = toIncomeSummary(accountId, income); + const refresh = { partial: portfolio.meta?.availability === "partial", refreshError: portfolio.isError || sync.transportError || sync.sourceError, @@ -345,6 +385,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { retry: sync.retry, sourceError: sync.sourceError, }; + if ( portfolio.meta?.availability === "complete" && portfolio.data.positions.length === 0 @@ -359,6 +400,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { state: "empty" as const, ...refresh, }; + return { account, accountId, diff --git a/src/pages/account/ui/AccountPage/components/AccountHeading.view.tsx b/src/pages/account/ui/AccountPage/components/AccountHeading.view.tsx index 3cc11ea..b2f42b6 100644 --- a/src/pages/account/ui/AccountPage/components/AccountHeading.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountHeading.view.tsx @@ -27,7 +27,12 @@ const AccountHeading: FC = (props) => { ? t("accounts.all") : (account?.name ?? t("portfolio.title"))} - {description && {description}} + + {description && ( + + {description} + + )} ); }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx index c3c83a2..bcea919 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx @@ -34,29 +34,35 @@ type AccountPageEmptyProps = { const AccountPageEmpty: FC = (props) => { const { t } = useTranslation(); + + const text = t( + props.accountId === "all" + ? "portfolio.allEmptyText" + : "portfolio.emptyText", + ); + return ( + - + + + + + - {!!(props.incomeSummary) && ()} + + {!!props.incomeSummary && } ); }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx index b216637..9cda789 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx @@ -25,7 +25,10 @@ const AccountPageError: FC = (props) => { title={t("portfolio.accountUnavailableTitle")} /> ); - if (isApiErrorCode(code, ApiErrorCode.TbankNotConnected) || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)) + if ( + isApiErrorCode(code, ApiErrorCode.TbankNotConnected) + || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) + ) return ( = (props) => { title={t("accounts.unavailableTitle")} /> ); + return ( {t("accounts.retry")}} diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx index f085fca..b330118 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -42,18 +42,30 @@ type AccountPageLoadedProps = { const AccountPageLoaded: FC = (props) => { const { t } = useTranslation(); + + const defaultValue = [ + ...props.positionSections.map(section => section.kind), + "cash", + "recentOperations", + "recentEvents", + "incomeSummary", + ]; + return ( + - {!!(props.portfolio.isStale) && (( + + {!!props.portfolio.isStale && ( {t("portfolio.staleWarning")} - ))} + )} + {t( props.accountId === "all" @@ -64,6 +76,7 @@ const AccountPageLoaded: FC = (props) => { }, )} + {props.partial ? ( {t("investment.partial")} @@ -71,12 +84,14 @@ const AccountPageLoaded: FC = (props) => { : ( )} - {!(props.partial) && (( + + {!props.partial && ( - ))} + )} + = (props) => { label: classes.accordionLabel, panel: classes.accordionPanel, }} - defaultValue={[ - ...props.positionSections.map(section => section.kind), - "cash", - "recentOperations", - "recentEvents", - "incomeSummary", - ]} + defaultValue={defaultValue} multiple > {props.positionSections.map(section => ( @@ -98,35 +107,44 @@ const AccountPageLoaded: FC = (props) => { {t(positionSectionTitleKey(section.kind))} + ))} + {t("portfolio.cashBalances")} + + {t("ledger.recentTitle")} + + {t("events.recentTitle")} + + {t("income.summaryTitle")} + - {!!(props.incomeSummary) && (( + {!!props.incomeSummary && ( - ))} + )} @@ -136,6 +154,7 @@ const AccountPageLoaded: FC = (props) => { function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) { if (kind === "stocks") return "portfolio.stocks"; + return kind === "bonds" ? "portfolio.bonds" : "portfolio.otherPositions"; } diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.stories.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.stories.tsx index ab07983..fd03792 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.stories.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.stories.tsx @@ -19,4 +19,5 @@ const meta = { export default meta; type Story = StoryObj; + export const InitialLoading: Story = { name: "Начальная загрузка счёта" }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx index 8f96f4d..6c8ee75 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx @@ -8,9 +8,11 @@ import classes from "../../AccountPage.module.css"; const AccountPageLoading: FC = () => { const { t } = useTranslation(); + return ( + + + {["first", "second", "third"].map(placeholder => ( ))} diff --git a/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx b/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx index 39482af..2f08143 100644 --- a/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx +++ b/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx @@ -15,11 +15,13 @@ describe("CurrencyCards currency presentation", () => { { currency: "CAD", value: "4.00" }, { currency: "XYZ", value: "5.00" }, ].slice(0, count); + const { container } = render( , ); + expect( screen.queryAllByRole("definition").map(node => node.textContent), ).toEqual( diff --git a/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.view.tsx b/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.view.tsx index 2e2536d..ccf0f00 100644 --- a/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.view.tsx +++ b/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.view.tsx @@ -16,8 +16,9 @@ type CurrencyCardsProps = { }; const CurrencyCards: FC = (props) => { - const { amounts } = props; const { t } = useTranslation(); + const { amounts } = props; + const currencies: Record = { EUR: { name: t("portfolio.currencies.EUR"), symbol: "€" }, RUB: { name: t("portfolio.currencies.RUB"), symbol: "₽" }, @@ -28,6 +29,7 @@ const CurrencyCards: FC = (props) => { {amounts.map((money) => { const amount = formatDecimal(money.value); + return ( = (props) => { + {currencies[money.currency]?.name ?? money.currency} + {amount.startsWith("-") ? ( @@ -51,13 +55,13 @@ const CurrencyCards: FC = (props) => { {amount.slice(0, SIGN_PREFIX_LENGTH)} + {amount.slice(SIGN_PREFIX_LENGTH)} ) : ( amount - )} - {" "} + )}{" "} {currencies[money.currency]?.symbol ?? money.currency} diff --git a/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx index c73cf1c..fcc85ef 100644 --- a/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx +++ b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx @@ -50,42 +50,40 @@ const IncomeSummary: FC = (props) => { return ( {t("income.summaryEmpty")} + ); + return ( - {!!(props.lastSuccessfulAt) && (( + {!!props.lastSuccessfulAt && ( {t("investment.sourceTime", { date: formatSnapshotTime(props.lastSuccessfulAt), })} - ))} - {!!(props.stale) && ({t("investment.stale")})} - {!!(props.refreshError) && (( + )} + + {!!props.stale && {t("investment.stale")}} + + {!!props.refreshError && ( {t("income.refreshError")} - ))} + )} + {props.summary.totalsByCurrency.map(total => ( {formatMoney(total.total)} + - {t("income.coupon")} - : - {" "} - {formatMoney(total.coupon)} - {" "} - · - {" "} - {t("income.dividend")} - : - {" "} - {formatMoney(total.dividend)} + {t("income.coupon")}: {formatMoney(total.coupon)} ·{" "} + {t("income.dividend")}: {formatMoney(total.dividend)} ))} + diff --git a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx index 95af73c..212a8e8 100644 --- a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx +++ b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx @@ -30,6 +30,7 @@ const meta = { { currency: "USD", value: "320" }, ]} /> + ), @@ -40,6 +41,7 @@ const meta = { export default meta; type Story = StoryObj; + export const Idle: Story = { name: "Сохранённые данные" }; export const Refreshing: Story = { args: { refreshing: true }, diff --git a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx index 89eab6f..1507457 100644 --- a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx @@ -24,15 +24,18 @@ const PortfolioRefreshState: FC = (props) => { > {t(refreshError ? "accounts.retry" : "investment.refresh")} - {!!(refreshing) && (( + + {!!refreshing && ( + {t("portfolio.refreshing")} - ))} - {!!(refreshError) && (( + )} + + {!!refreshError && ( @@ -44,7 +47,7 @@ const PortfolioRefreshState: FC = (props) => { - ))} + )} ); }; diff --git a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.stories.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.stories.tsx index 6d83480..9cee485 100644 --- a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.stories.tsx +++ b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.stories.tsx @@ -15,6 +15,7 @@ const rub: Portfolio["structure"]["currencies"][number] = { hasNegativeValues: false, totalValue: "1250000", }; + const meta = { args: { structure: { currencies: [rub], unvaluedCategories: [] } }, component: PortfolioStructure, @@ -31,7 +32,9 @@ const meta = { } satisfies Meta; export default meta; + type Story = StoryObj; + export const Regular: Story = {}; export const MultipleCurrencies: Story = { args: { @@ -77,12 +80,15 @@ export const Incomplete: Story = { currencies: [ { ...rub, - categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - ...row, - sharePercent: null, - } - /* eslint-enable unicorn/no-null */))), + categories: rub.categories.map( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + row => + ({ + ...row, + sharePercent: null, + }), + /* eslint-enable unicorn/no-null */ + ), }, ], unvaluedCategories: [{ count: 2, type: "STOCK" }], @@ -103,13 +109,16 @@ export const Negative: Story = { currencies: [ { ...rub, - categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - ...row, - sharePercent: null, - value: row.type === "OTHER" ? "25000" : row.value, - } - /* eslint-enable unicorn/no-null */))), + categories: rub.categories.map( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + row => + ({ + ...row, + sharePercent: null, + value: row.type === "OTHER" ? "25000" : row.value, + }), + /* eslint-enable unicorn/no-null */ + ), hasNegativeValues: true, totalValue: "1150000", }, diff --git a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx index 6551b9f..7d2f28c 100644 --- a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx +++ b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx @@ -17,6 +17,7 @@ const rub: Portfolio["structure"]["currencies"][number] = { hasNegativeValues: false, totalValue: "100", }; + const ui = (structure: null | Portfolio["structure"] | undefined) => ( @@ -24,7 +25,11 @@ const ui = (structure: null | Portfolio["structure"] | undefined) => ( ); describe("portfolio structure", () => { - it.each([undefined, /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null])( + it.each([ + undefined, + // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. + null, + ])( "keeps an older snapshot without structure available (%s) and recovers on refresh", (structure) => { const view = render(ui(structure)); @@ -68,12 +73,15 @@ describe("portfolio structure", () => { currencies: [ { ...rub, - categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - ...row, - sharePercent: null, - } - /* eslint-enable unicorn/no-null */))), + categories: rub.categories.map( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + row => + ({ + ...row, + sharePercent: null, + }), + /* eslint-enable unicorn/no-null */ + ), }, ], unvaluedCategories: [ @@ -97,13 +105,16 @@ describe("portfolio structure", () => { currencies: [ { ...rub, - categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - ...row, - sharePercent: null, - value: row.type === "STOCK" ? "-60" : row.value, - } - /* eslint-enable unicorn/no-null */))), + categories: rub.categories.map( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + row => + ({ + ...row, + sharePercent: null, + value: row.type === "STOCK" ? "-60" : row.value, + }), + /* eslint-enable unicorn/no-null */ + ), hasNegativeValues: true, }, ], @@ -123,6 +134,7 @@ describe("portfolio structure", () => { const view = render( ui({ currencies: [], unvaluedCategories: [{ count: 1, type: "BOND" }] }), ); + expect(screen.getByText("Облигации: без оценки — 1")).toBeInTheDocument(); expect(screen.queryByRole("list")).not.toBeInTheDocument(); view.rerender(ui({ currencies: [], unvaluedCategories: [] })); diff --git a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.tsx index bcd3372..1f175a5 100644 --- a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.tsx +++ b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.tsx @@ -1,80 +1,15 @@ -import { type FC, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import type { Portfolio } from "@/entities/portfolio"; - -import { formatMoney } from "@/shared/lib"; +import type { FC } from "react"; import { PortfolioStructureView } from "./PortfolioStructure.view"; - -const MIN_VISIBLE_SHARE_PERCENT = 0.01; - -// A cached snapshot or an older running server can predate this response field. -type PortfolioStructureProps = { structure?: null | Portfolio["structure"] }; - -const categoryPresentation = { - BOND: { color: "grape.6", label: "portfolio.bonds" }, - CASH: { color: "green.6", label: "portfolio.structure.cash" }, - OTHER: { color: "orange.6", label: "portfolio.structure.other" }, - STOCK: { color: "blue.6", label: "portfolio.stocks" }, -} as const; +import { + type PortfolioStructureProps, + usePortfolioStructureVm, +} from "./PortfolioStructure.vm"; const PortfolioStructure: FC = (props) => { - const { structure } = props; - const { t } = useTranslation(); - const [currency, setCurrency] = useState(null); - const selected - = structure?.currencies.find(item => item.currency === currency) - ?? structure?.currencies[0]; - const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0; - const rows = selected - ? selected.categories.map((category) => { - const presentation = categoryPresentation[category.type]; - const percentage - = category.sharePercent === null ? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null : Number(category.sharePercent); - const unknown = structure?.unvaluedCategories.some( - item => item.type === category.type, - ); - const amount = formatMoney({ - currency: selected.currency, - value: category.value, - }); - let displayedAmount = amount; - if (unknown) displayedAmount = category.value === "0" ? t("portfolio.structure.unvalued") : t("portfolio.structure.knownAmount", { amount }); - let share = t("portfolio.structure.shareUnavailable"); - if (percentage !== null) share = percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT - ? t("portfolio.structure.smallShare") - : `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`; - return { - amount: displayedAmount, - color: presentation.color, - name: t(presentation.label), - share, - value: percentage ?? 0, - }; - }) - : []; - const chartData = rows.filter(row => row.value > 0); - return ( - item.currency) ?? []} - currency={selected?.currency ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null} - incomplete={isIncomplete} - negative={selected?.hasNegativeValues ?? false} - onCurrencyChange={setCurrency} - rows={rows} - unavailable={!structure} - unvalued={ - structure?.unvaluedCategories.map(category => - t("portfolio.structure.unvaluedCategory", { - category: t(categoryPresentation[category.type].label), - count: category.count, - }), - ) ?? [] - } - /> - ); + const vm = usePortfolioStructureVm(props); + + return ; }; export { PortfolioStructure }; diff --git a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.view.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.view.tsx index 468791e..f55036d 100644 --- a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.view.tsx @@ -16,6 +16,13 @@ type PortfolioStructureViewProps = { unavailable: boolean; unvalued: string[]; }; + +type PortfolioTooltipProps = { + active?: boolean; + payload?: { name?: number | string }[]; + rows: StructureRow[]; +}; + type StructureRow = { amount: string; color: string; @@ -24,9 +31,28 @@ type StructureRow = { value: number; }; +const PortfolioTooltip: FC = (props) => { + const row = props.rows.find(item => item.name === props.payload?.[0]?.name); + if (!row || !props.active) return; + + return ( + + {row.name} + + {row.amount} · {row.share} + + ); +}; + const PortfolioStructureView: FC = (props) => { const { t } = useTranslation(); const titleId = useId(); + const appearanceClass = `${classes.content} ${props.chartData.length > 0 ? classes.layout : ""}`; + + const tooltipProps = { + content: , + }; + return ( = (props) => { {t("portfolio.structure.title")} - {(props.currencies.length > 1) && (( + + {props.currencies.length > 1 && ( = (props) => { w={180} /> + = (props) => { gap="lg" key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`} > - {renderHistory()} + + {vm.history.isError && ( {t(vm.rows ? "bank.refreshError" : "bank.error")} - + + )} + {vm.history.data && vm.pageSize !== "all" && ( = (props) => { > ({ - "aria-label": t( - control === "previous" ? "bank.previous" : "bank.next", - ), - })} - getItemProps={page => ({ - "aria-label": t("bank.page", { page }), - })} - onChange={(page) => { - if (paginationRef.current) - pendingPageRef.current = { - page, - scope, - top: paginationRef.current.getBoundingClientRect().top, - }; - vm.setPage(page); - }} + getControlProps={getControlProps} + getItemProps={getItemProps} + onChange={onPageChange} siblings={0} total={vm.totalPages} value={vm.page} diff --git a/src/pages/card/ui/CardPage/CardPage.vm.ts b/src/pages/card/ui/CardPage/CardPage.vm.ts index 9b9a0db..92643a3 100644 --- a/src/pages/card/ui/CardPage/CardPage.vm.ts +++ b/src/pages/card/ui/CardPage/CardPage.vm.ts @@ -17,6 +17,7 @@ import { formatDate, formatMoney } from "@/shared/lib"; const PAGE_SIZES = new Set(["5", "10", "20", "50", "100", "all"]); type CardPageVm = ReturnType; + function useCardPageVm() { const { cardId } = useParams(); const [parameters, setParameters] = useSearchParams(); @@ -25,32 +26,46 @@ function useCardPageVm() { const requestedSize = parameters.get("limit") ?? "10"; const pageSize = PAGE_SIZES.has(requestedSize) ? requestedSize : "10"; const rawPage = Number(parameters.get("page") ?? 1); + const page = pageSize !== "all" && Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1; + const card = useBankCard(cardId); const { updateCard } = useBankMutations(); const [archiveError, setArchiveError] = useState(); const cards = useBankCards(true); + const history = useBankOperations(cardId, { ...period, limit: pageSize === "all" ? "all" : Number(pageSize), page, }); + const summary = useBankSummary(period, cardId); const [editingCard, setEditingCard] = useState(false); - const [operation, setOperation] = useState<"new" | BankOperation | null>( + + const [operation, setOperation] = useState<"new" + | BankOperation + | null>( null, ); + const [deleting, setDeleting] = useState(null); + const rows = history.data?.items.map((item) => { const isOutgoing = item.kind === "expense" || (item.kind === "transfer" && item.cardId === cardId); + const otherCardId = isOutgoing ? item.targetCardId : item.cardId; let title = t(`bank.kinds.${item.kind}`); - if (item.kind === "transfer") title = t(isOutgoing ? "bank.transferTo" : "bank.transferFrom", { name: isOutgoing ? item.targetCardName : item.cardName }); + if (item.kind === "transfer") + title = t(isOutgoing ? "bank.transferTo" : "bank.transferFrom", { + name: isOutgoing ? item.targetCardName : item.cardName, + }); + return { color: isOutgoing ? "red" : "green", editable: @@ -64,24 +79,34 @@ function useCardPageVm() { metadata: [formatDate(item.date), item.categoryName, item.description] .filter(Boolean) .join(" · "), + onDelete: () => setDeleting(item), + onEdit: () => setOperation(item), otherCard: item.kind === "transfer" ? otherCardId - : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null, + : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. title, tone: isOutgoing ? ("negative" as const) : ("positive" as const), value: `${isOutgoing ? "−" : "+"}${formatMoney(item.amount)}`, }; }); + return { archiveError, archivePending: updateCard.isPending, card, cards, + closeDeletion: () => setDeleting(null), + closeEditing: () => setEditingCard(false), + closeOperation: () => setOperation(null), deleting, + editableOperation: operation === "new" ? undefined : (operation ?? undefined), editingCard, history, - notFound: isApiErrorCode(apiErrorCode(card.error), ApiErrorCode.BankNotFound), + notFound: isApiErrorCode( + apiErrorCode(card.error), + ApiErrorCode.BankNotFound, + ), operation, page, pageSize, @@ -104,6 +129,7 @@ function useCardPageVm() { toggleArchive: async () => { if (!card.data) return; setArchiveError(undefined); + try { await updateCard.mutateAsync({ body: { archived: !card.data.archivedAt, version: card.data.version }, diff --git a/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts b/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts new file mode 100644 index 0000000..da43fdc --- /dev/null +++ b/src/pages/card/ui/CardPage/CardPagePresentation.vm.ts @@ -0,0 +1,98 @@ +import type { ComponentProps } from "react"; + +import { Pagination } from "@mantine/core"; +import { useLayoutEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; + +import type { CardPageVm } from "./CardPage.vm"; + +function useCardPagePresentationVm(vm: CardPageVm) { + const { t } = useTranslation(); + const card = vm.card.data; + const paginationRef = useRef(null); + + const pendingPageRef = useRef(null); + + const scope = `${card?.id}-${vm.period.from}-${vm.period.to}-${vm.pageSize}`; + useLayoutEffect(() => { + const pending = pendingPageRef.current; + if (!pending) return; + + if (pending.scope !== scope || vm.history.isError) { + pendingPageRef.current + // eslint-disable-next-line unicorn/no-null -- Reset the pending-pagination ref to its initial null sentinel. + = null; + + return; + } + + if (vm.history.isPlaceholderData || vm.history.data?.page !== pending.page) + return; + const pagination = paginationRef.current; + + if (pagination) { + const delta = pagination.getBoundingClientRect().top - pending.top; + if (delta) window.scrollBy({ behavior: "instant", top: delta }); + } + + // eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract. + pendingPageRef.current = null; + }, [ + scope, + vm.history.data, + vm.history.isPlaceholderData, + vm.history.isError, + ]); + const onEditCard = () => vm.setEditingCard(true); + const archiveActionLabel = t(card?.archivedAt ? "bank.restore" : "bank.archive"); + const onToggleArchive = () => void vm.toggleArchive(); + const retryCard = () => void vm.card.refetch(); + const retryCards = () => void vm.cards.refetch(); + const retrySummary = () => void vm.summary.refetch(); + const onAddOperation = () => vm.setOperation("new"); + const retryHistory = () => void vm.history.refetch(); + + const getControlProps: ComponentProps< + typeof Pagination + >["getControlProps"] = control => ({ + "aria-label": t(control === "previous" ? "bank.previous" : "bank.next"), + }); + + const getItemProps: ComponentProps["getItemProps"] = page => ({ + "aria-label": t("bank.page", { page }), + }); + + const onPageChange: ComponentProps["onChange"] = (page) => { + if (paginationRef.current) + pendingPageRef.current = { + page, + scope, + top: paginationRef.current.getBoundingClientRect().top, + }; + vm.setPage(page); + }; + + return { + archiveActionLabel, + card, + getControlProps, + getItemProps, + onAddOperation, + onEditCard, + onPageChange, + onToggleArchive, + paginationRef, + retryCard, + retryCards, + retryHistory, + retrySummary, + t, + }; +} + +export { useCardPagePresentationVm }; diff --git a/src/pages/card/ui/DeleteOperation/DeleteOperation.test.tsx b/src/pages/card/ui/DeleteOperation/DeleteOperation.test.tsx index dcc2f1e..a0ee863 100644 --- a/src/pages/card/ui/DeleteOperation/DeleteOperation.test.tsx +++ b/src/pages/card/ui/DeleteOperation/DeleteOperation.test.tsx @@ -12,8 +12,10 @@ import { server } from "@/test/server"; import { DeleteOperation } from "./DeleteOperation"; -const transfer: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +const transfer: BankOperation + = { amount: { currency: "RUB", value: "100" }, availableRefund: null, cardId: "a", @@ -29,8 +31,9 @@ const transfer: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture pr targetCardId: "b", targetCardName: "Запасная", version: 1, - } -/* eslint-enable unicorn/no-null */); + }; + +/* eslint-enable unicorn/no-null */ describe("DeleteOperation", () => { it("names both sides and requires a deliberate fresh version after conflict", async () => { configureAuthClient({ @@ -46,6 +49,7 @@ describe("DeleteOperation", () => { http.delete("*/api/v1/bank-cards/operations/transfer", ({ request }) => { const version = new URL(request.url).searchParams.get("version") ?? ""; versions.push(version); + return version === "1" ? HttpResponse.json( { code: "BANK_VERSION_CONFLICT" }, diff --git a/src/pages/card/ui/DeleteOperation/DeleteOperation.tsx b/src/pages/card/ui/DeleteOperation/DeleteOperation.tsx index ab9c8d4..8350214 100644 --- a/src/pages/card/ui/DeleteOperation/DeleteOperation.tsx +++ b/src/pages/card/ui/DeleteOperation/DeleteOperation.tsx @@ -1,36 +1,34 @@ import { Button, Group, Modal, Stack, Text } from "@mantine/core"; -import { type FC, useState } from "react"; -import { useTranslation } from "react-i18next"; +import { type FC } from "react"; -import { - BankFeedback, - type BankOperation, - useBankFormFocus, - useBankMutations, - useBankOperation, -} from "@/entities/bank-card"; +import { BankFeedback, type BankOperation } from "@/entities/bank-card"; import { formatDate, formatMoney } from "@/shared/lib"; +import { useDeleteOperationVm } from "./DeleteOperation.vm"; + type DeleteOperationProps = { onClose: () => void; operation: BankOperation }; + const DeleteOperation: FC = (props) => { - useBankFormFocus(); - const [opened, setOpened] = useState(true); - const { t } = useTranslation(); - const [record, setRecord] = useState(props.operation); - const [latest, setLatest] = useState(); - const [error, setError] = useState(); - const query = useBankOperation(record.id); - const { deleteOperation: operationDeletion } = useBankMutations(); - const isPending - = !opened - || operationDeletion.isPending - || (query.isFetching && Boolean(error)); + const { + error, + isPending, + latest, + onAdoptVersion, + onCancel, + onDelete, + onReloadRecord, + onRequestClose, + opened, + record, + t, + } = useDeleteOperationVm(props); + return ( !isPending && setOpened(false)} + onClose={onRequestClose} onExitTransitionEnd={props.onClose} opened={opened} title={t("bank.deleteTitle")} @@ -49,22 +47,21 @@ const DeleteOperation: FC = (props) => { kind: t(`bank.kinds.${record.kind}`), })} - {!!(error) && (( + + {!!error && ( <> + - ))} + )} + {latest && ( <> @@ -72,44 +69,23 @@ const DeleteOperation: FC = (props) => { details: `${formatDate(latest.date)} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`, })} + )} + - - diff --git a/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts b/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts new file mode 100644 index 0000000..ff76f02 --- /dev/null +++ b/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { + type BankOperation, + useBankFormFocus, + useBankMutations, + useBankOperation, +} from "@/entities/bank-card"; + +type DeleteOperationProps = { onClose: () => void; operation: BankOperation }; + +function useDeleteOperationVm(props: DeleteOperationProps) { + useBankFormFocus(); + const [opened, setOpened] = useState(true); + const { t } = useTranslation(); + const [record, setRecord] = useState(props.operation); + const [latest, setLatest] = useState(); + const [error, setError] = useState(); + const query = useBankOperation(record.id); + const { deleteOperation: operationDeletion } = useBankMutations(); + + const isPending + = !opened + || operationDeletion.isPending + || (query.isFetching && Boolean(error)); + + const onRequestClose = () => !isPending && setOpened(false); + + const onReloadRecord = async () => { + const result = await query.refetch(); + if (result.data && !result.error) setLatest(result.data); + else setError(result.error); + }; + + const onAdoptVersion = () => { + if (!latest) return; + setRecord(latest); + setLatest(undefined); + setError(undefined); + }; + + const onDelete = async () => { + setError(undefined); + + try { + await operationDeletion.mutateAsync({ + id: record.id, + version: record.version, + }); + setOpened(false); + } + catch (error_) { + setError(error_); + } + }; + + const onCancel = () => setOpened(false); + + return { + error, + isPending, + latest, + onAdoptVersion, + onCancel, + onDelete, + onReloadRecord, + onRequestClose, + opened, + record, + t, + }; +} + +export { useDeleteOperationVm }; diff --git a/src/pages/card/ui/OperationForm/OperationFields.vm.ts b/src/pages/card/ui/OperationForm/OperationFields.vm.ts new file mode 100644 index 0000000..4194599 --- /dev/null +++ b/src/pages/card/ui/OperationForm/OperationFields.vm.ts @@ -0,0 +1,99 @@ +import type { ComponentProps } from "react"; + +import { DateInput } from "@mantine/dates"; +import { useController, useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import type { OperationValues } from "./OperationForm.types"; + +type Choice = { label: string; value: string }; + +type OperationFieldsProps = { + cards: Choice[]; + categories: Choice[]; + dateInputValue: (value: string) => string; + defaultCategory: string; + editing: boolean; + kind: OperationValues["kind"]; + kinds: Choice[]; + maxDate: () => string; + refundCardId: string; + targets: Choice[]; + validDate: (value: string) => boolean; +}; + +function useOperationFieldsVm(props: OperationFieldsProps) { + const { t } = useTranslation(); + + const { + control, + formState: { errors, isSubmitting }, + register, + setValue, + } = useFormContext(); + + const { field: kindField } = useController({ control, name: "kind" }); + const { field: cardIdField } = useController({ control, name: "cardId" }); + + const { field: targetCardIdField } = useController({ + control, + name: "targetCardId", + }); + + const { field: dateField } = useController({ control, name: "date" }); + + const { field: categoryIdField } = useController({ + control, + name: "categoryId", + }); + + const onKindChange: (value: null | string) => void = (value) => { + kindField.onChange(value ?? "expense"); + if (value === "refund") setValue("cardId", props.refundCardId); + }; + + const sourceCardLabel = t( + props.kind === "transfer" ? "bank.source" : "bank.operationCard", + ); + + const dateParser: ComponentProps["dateParser"] = value => + props.validDate(props.dateInputValue(value)) + ? props.dateInputValue(value) + : null; // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled dateField as null. + + const onDateBlur: ComponentProps["onBlur"] = (event) => { + dateField.onChange(props.dateInputValue(event.currentTarget.value)); + dateField.onBlur(); + }; + + const onDateChange: (value: null | string) => void = value => + dateField.onChange(value ?? ""); + + const onDateInput: ComponentProps["onInput"] = event => + dateField.onChange(props.dateInputValue(event.currentTarget.value)); + + const dateValue = props.validDate(dateField.value) + ? dateField.value + : undefined; + + return { + cardIdField, + categoryIdField, + dateField, + dateParser, + dateValue, + errors, + isSubmitting, + kindField, + onDateBlur, + onDateChange, + onDateInput, + onKindChange, + register, + sourceCardLabel, + t, + targetCardIdField, + }; +} + +export { useOperationFieldsVm }; diff --git a/src/pages/card/ui/OperationForm/OperationForm.schema.ts b/src/pages/card/ui/OperationForm/OperationForm.schema.ts new file mode 100644 index 0000000..7173642 --- /dev/null +++ b/src/pages/card/ui/OperationForm/OperationForm.schema.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; + +import { + type BankCard, + bankDateSchema, + type BankOperation, + bankOperationKinds, + moneyCents, + positiveMoneySchema, +} from "@/entities/bank-card"; +import { validationMessage } from "@/shared/i18n"; + +import type { OperationValues } from "./OperationForm.types"; + +// ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod. +enum FormIssueCode { + Custom = "custom", +} + +type SchemaInputs = { + cards: BankCard[]; + categories?: { id: string; isDefault: boolean }[]; + expense?: BankOperation; + limit?: BankOperation["availableRefund"]; + submittedValues?: OperationValues; +}; + +const MAX_DESCRIPTION_LENGTH = 500; + +const operationSchema = z.object({ + amount: positiveMoneySchema, + cardId: z.string(), + categoryId: z.string(), + date: bankDateSchema, + description: z + .string() + .max( + MAX_DESCRIPTION_LENGTH, + validationMessage("bank.validation.description")(undefined), + ), + kind: z.enum(bankOperationKinds), + originalExpenseId: z.string(), + targetCardId: z.string(), +}); + +function createOperationSchema({ + cards, + categories, + expense, + limit, + submittedValues, +}: SchemaInputs) { + return operationSchema.superRefine((values, context) => { + // Repeated intent reaches server idempotency even after its source changed. + if ( + submittedValues + && JSON.stringify(values) === JSON.stringify(submittedValues) + ) + return; + const source = cards.find(c => c.id === values.cardId); + const target = cards.find(c => c.id === values.targetCardId); + if (!source || source.archivedAt) + context.addIssue({ + code: FormIssueCode.Custom, + path: ["cardId"], + ...validationMessage("bank.validation.required")(undefined), + }); + if ( + values.kind === "transfer" + && (!target || target.archivedAt || target.id === values.cardId) + ) + context.addIssue({ + code: FormIssueCode.Custom, + path: ["targetCardId"], + ...validationMessage("bank.validation.destination")(undefined), + }); + if ( + (source && values.date < source.openingDate) + || (target && values.kind === "transfer" && values.date < target.openingDate) + ) + context.addIssue({ + code: FormIssueCode.Custom, + path: ["date"], + ...validationMessage("bank.validation.start")(undefined), + }); + if (values.kind === "refund") + validateRefund(values, context, expense, limit); + if ( + values.kind === "expense" + && !categories?.some( + c => + c.id + === (values.categoryId || categories?.find(c => c.isDefault)?.id), + ) + ) + context.addIssue({ + code: FormIssueCode.Custom, + path: ["categoryId"], + ...validationMessage("bank.validation.required")(undefined), + }); + }); +} + +function validateRefund( + values: OperationValues, + context: z.RefinementCtx, + expense: BankOperation | undefined, + limit?: BankOperation["availableRefund"], +) { + if (!expense || expense.cardId !== values.cardId) + context.addIssue({ + code: FormIssueCode.Custom, + path: ["originalExpenseId"], + ...validationMessage("bank.validation.required")(undefined), + }); + if (expense && values.date < expense.date) + context.addIssue({ + code: FormIssueCode.Custom, + path: ["date"], + ...validationMessage("bank.validation.start")(undefined), + }); + const amount = moneyCents(values.amount); + if (limit && amount !== null && amount > (moneyCents(limit.value) ?? 0n)) + context.addIssue({ + code: FormIssueCode.Custom, + path: ["amount"], + ...validationMessage("bank.validation.refund")(undefined), + }); +} + +export { createOperationSchema }; diff --git a/src/pages/card/ui/OperationForm/OperationForm.stories.tsx b/src/pages/card/ui/OperationForm/OperationForm.stories.tsx index 8ecbfea..78d57d9 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.stories.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.stories.tsx @@ -9,9 +9,11 @@ import type { OperationValues } from "./OperationForm.types"; import { OperationFormView } from "./OperationForm.view"; type ExampleProps = { conflict?: boolean; pending?: boolean }; + const OperationExample: FC = (props) => { const [opened, setOpened] = useState(true); const [action, setAction] = useState(""); + const methods = useForm({ defaultValues: { amount: "500", @@ -24,7 +26,9 @@ const OperationExample: FC = (props) => { targetCardId: "", }, }); + const kind = useWatch({ control: methods.control, name: "kind" }); + return ( = (props) => { validDate: value => value === "2026-08-01", }} latest={ - (props.conflict) - ? ({ + props.conflict + ? { details: "Учебная версия", onAdopt: () => setAction("Локальное принятие версии"), version: 2, - }) + } : undefined } loadingExpenses={false} @@ -69,7 +73,7 @@ const OperationExample: FC = (props) => { onNext={() => {}} onPrevious={() => {}} onReload={ - (props.conflict) ? () => setAction("Локальный reload") : undefined + props.conflict ? () => setAction("Локальный reload") : undefined } onSubmit={(event) => { event.preventDefault(); @@ -92,10 +96,12 @@ const OperationExample: FC = (props) => { saveDisabled={false} sourcePage={1} /> + {action} ); }; + const meta = { component: OperationExample, parameters: { @@ -109,8 +115,11 @@ const meta = { tags: ["autodocs"], title: "Product/OperationForm", } satisfies Meta; + export default meta; + type Story = StoryObj; + export const Refund: Story = {}; export const Pending: Story = { args: { pending: true } }; export const Conflict: Story = { args: { conflict: true } }; diff --git a/src/pages/card/ui/OperationForm/OperationForm.test.tsx b/src/pages/card/ui/OperationForm/OperationForm.test.tsx index 332ac9f..f2868c9 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.test.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.test.tsx @@ -19,8 +19,11 @@ import { server } from "@/test/server"; import { OperationForm } from "./OperationForm"; const money = (value: string) => ({ currency: "RUB", value }); -const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +const card: BankCard + = { archivedAt: null, balance: money("0"), bankName: null, @@ -31,12 +34,14 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t openingDate: "2026-01-01", paymentSystem: null, version: 1, - } -/* eslint-enable unicorn/no-null */); + }; + +/* eslint-enable unicorn/no-null */ async function select(label: RegExp, value: RegExp | string) { fireEvent.click(screen.getByRole("combobox", { name: label })); fireEvent.click(await screen.findByRole("option", { name: value })); } + function setup(operation?: BankOperation) { configureAuthClient({ accessToken: () => "test", @@ -72,16 +77,21 @@ function setup(operation?: BankOperation) { , ); + return close; } + describe("OperationForm", () => { it("blocks closing and editing while save is pending, then closes after success", async () => { - const { promise: response, resolve: finish } = Promise.withResolvers(); + const { promise: response, resolve: finish } + = Promise.withResolvers(); + const received = vi.fn(); server.use( http.post("*/api/v1/bank-cards/operations", async () => { received(); await response; + return HttpResponse.json({}, { status: 201 }); }), ); @@ -96,6 +106,7 @@ describe("OperationForm", () => { }); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(received).toHaveBeenCalledOnce()); + try { expect(screen.getByLabelText(/Сумма/)).toBeDisabled(); expect(screen.getByRole("button", { name: "Закрыть" })).toBeDisabled(); @@ -107,6 +118,7 @@ describe("OperationForm", () => { finally { finish(); } + await waitFor(() => expect(close).toHaveBeenCalledOnce()); }); @@ -126,6 +138,7 @@ describe("OperationForm", () => { server.use( http.post("*/api/v1/bank-cards/operations", async ({ request }) => { body = await request.json(); + return HttpResponse.json({}, { status: 201 }); }), ); @@ -155,6 +168,7 @@ describe("OperationForm", () => { server.use( http.post("*/api/v1/bank-cards/operations", async ({ request }) => { bodies.push(await request.json()); + return bodies.length === 1 ? HttpResponse.error() : HttpResponse.json({}, { status: 201 }); @@ -195,6 +209,7 @@ describe("OperationForm", () => { server.use( http.post("*/api/v1/bank-cards/operations", async ({ request }) => { body = await request.json(); + return HttpResponse.json({}, { status: 201 }); }), ); @@ -214,8 +229,9 @@ describe("OperationForm", () => { expect(body).not.toHaveProperty("categoryId"); }); it("finds a previous-month purchase across pages, checks remaining refund and retains the complete-refund intent", async () => { - const purchase: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + const purchase: BankOperation + = { amount: money("500"), availableRefund: money("500"), cardId: "a", @@ -231,8 +247,9 @@ describe("OperationForm", () => { targetCardId: null, targetCardName: null, version: 1, - } - /* eslint-enable unicorn/no-null */); + }; + + /* eslint-enable unicorn/no-null */ server.use( http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => { return HttpResponse.json({ @@ -270,6 +287,7 @@ describe("OperationForm", () => { http.post("*/api/v1/bank-cards/operations", async ({ request }) => { bodies.push(await request.json()); purchase.availableRefund = money("0"); + return bodies.length === 1 ? HttpResponse.error() : HttpResponse.json({}, { status: 201 }); @@ -295,8 +313,9 @@ describe("OperationForm", () => { }); }); it("edits a full refund with its own amount excluded from the limit and immutable purchase", async () => { - const refund: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + const refund: BankOperation + = { amount: money("500"), availableRefund: money("500"), cardId: "a", @@ -312,8 +331,9 @@ describe("OperationForm", () => { targetCardId: null, targetCardName: null, version: 2, - } - /* eslint-enable unicorn/no-null */); + }; + + /* eslint-enable unicorn/no-null */ const purchase = { ...refund, availableRefund: money("0"), @@ -321,6 +341,7 @@ describe("OperationForm", () => { id: "purchase", kind: "expense", }; + server.use( http.get("*/api/v1/bank-cards/a/expenses", () => HttpResponse.json({ @@ -354,6 +375,7 @@ describe("OperationForm", () => { "*/api/v1/bank-cards/operations/refund", async ({ request }) => { body = await request.json(); + return HttpResponse.json(refund); }, ), @@ -372,8 +394,9 @@ describe("OperationForm", () => { }); it("keeps edit draft and stale version until explicit adoption after conflict", async () => { - const operation: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + const operation: BankOperation + = { amount: money("100"), availableRefund: money("100"), cardId: "a", @@ -389,8 +412,9 @@ describe("OperationForm", () => { targetCardId: null, targetCardName: null, version: 1, - } - /* eslint-enable unicorn/no-null */); + }; + + /* eslint-enable unicorn/no-null */ let readVersion = 1; const bodies: Record[] = []; server.use( @@ -407,6 +431,7 @@ describe("OperationForm", () => { const body = (await request.json()) as Record; bodies.push(body); readVersion = 2; + return body.version === 1 ? HttpResponse.json( { code: "BANK_VERSION_CONFLICT" }, @@ -465,6 +490,7 @@ describe("OperationForm", () => { server.use( http.post("*/api/v1/bank-cards/categories", () => { isCreated = true; + return HttpResponse.json( { id: "new", isDefault: false, name: "кафе", version: 1 }, { status: 201 }, @@ -498,6 +524,7 @@ describe("OperationForm", () => { server.use( http.post("*/api/v1/bank-cards/operations", async ({ request }) => { body = await request.json(); + return HttpResponse.json({}, { status: 201 }); }), ); diff --git a/src/pages/card/ui/OperationForm/OperationForm.tsx b/src/pages/card/ui/OperationForm/OperationForm.tsx index 251e6c2..dd68158 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.tsx @@ -11,6 +11,8 @@ import { useOperationFormVm } from "./OperationForm.vm"; const OperationForm: FC = (props) => { const vm = useOperationFormVm(props); + const feedback = vm.error ? : undefined; + return ( <> @@ -25,7 +27,7 @@ const OperationForm: FC = (props) => { /> ) } - feedback={(vm.error) ? () : undefined} + feedback={feedback} refundFeedback={( <> {vm.expensesError && ( @@ -34,8 +36,7 @@ const OperationForm: FC = (props) => { loadingError retry={vm.retryExpenses} /> - )} - {" "} + )}{" "} {vm.sourceError && ( = (props) => { )} /> + {vm.categoriesOpen && ( boolean; }; + const OperationFields: FC = (props) => { - const { t } = useTranslation(); const { - control, - formState: { errors, isSubmitting }, + cardIdField, + categoryIdField, + dateField, + dateParser, + dateValue, + errors, + isSubmitting, + kindField, + onDateBlur, + onDateChange, + onDateInput, + onKindChange, register, - setValue, - } = useFormContext(); + sourceCardLabel, + t, + targetCardIdField, + } = useOperationFieldsVm(props); + return ( - ( - - ( - + {props.kind === "transfer" && ( - ( - )} + = (props) => { {...register("amount")} error={errors.amount?.message} /> - ( - - props.validDate(props.dateInputValue(value)) - ? props.dateInputValue(value) - : /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null} - error={errors.date?.message} - fixOnBlur={false} - label={t("bank.date")} - maxDate={props.maxDate()} - onBlur={(event) => { - field.onChange(props.dateInputValue(event.currentTarget.value)); - field.onBlur(); - }} - onChange={value => field.onChange(value ?? "")} - onInput={event => - field.onChange(props.dateInputValue(event.currentTarget.value))} - popoverProps={{ - middlewares: { shift: { crossAxis: true } }, - withinPortal: false, - }} - required - value={(props.validDate(field.value)) ? (field.value) : undefined} - valueFormat="DD.MM.YYYY" - /> - )} + + + {props.kind === "expense" && ( - ( - )} +