Compare commits
42
Commits
a4046dbe87
..
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7d8d2c8f9 | ||
|
|
9d900b331a | ||
|
|
b357743c10 | ||
|
|
0d747cc93e | ||
|
|
c315f80b1b | ||
|
|
617edfd489 | ||
|
|
9ad3245efe | ||
|
|
cac4351bc4 | ||
|
|
17f5bae69b | ||
|
|
c8821200ce | ||
|
|
cec9c6348a | ||
|
|
a84411df39 | ||
|
|
3c226f8c29 | ||
|
|
20b2cdfd43 | ||
|
|
dcd1e3c278 | ||
|
|
432cca299f | ||
|
|
54deae57b3 | ||
|
|
74f8a72ed5 | ||
|
|
fcb938b5a2 | ||
|
|
f873db7b02 | ||
|
|
0ea8d7b62d | ||
|
|
c25f6bd85b | ||
|
|
5b30bb802a | ||
|
|
c096fc70ef | ||
|
|
69997075e5 | ||
|
|
6a89b08ee5 | ||
|
|
2844b22dba | ||
|
|
ff67a768ad | ||
|
|
10baefda79 | ||
|
|
f5f4447d60 | ||
|
|
0bc8bc36b7 | ||
|
|
f9f0f6921e | ||
|
|
7c28a423dd | ||
|
|
b404c1802a | ||
|
|
32a77860c8 | ||
|
|
0865b35f93 | ||
|
|
7e976c330c | ||
|
|
320ea5922a | ||
|
|
64fe902192 | ||
|
|
fb3a5a6eaa | ||
|
|
94adbafa34 | ||
|
|
a7537da344 |
+8
-6
@@ -8,7 +8,7 @@ Vitest — отдельную JSDOM конфигурацию. Точные versi
|
|||||||
[package.json](../package.json), реализация — source of truth.
|
[package.json](../package.json), реализация — source of truth.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
app → pages → features → entities → shared
|
app → pages → widgets → features → entities → shared
|
||||||
page container → page VM → entity/feature query → shared/api → backend /api/v1
|
page container → page VM → entity/feature query → shared/api → backend /api/v1
|
||||||
↓
|
↓
|
||||||
props → state/section views
|
props → state/section views
|
||||||
@@ -30,13 +30,15 @@ contract; frontend не читает его checkout для генерации
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Bootstrap, глобальные providers, routes | `app` |
|
| Bootstrap, глобальные providers, routes | `app` |
|
||||||
| Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` |
|
| Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` |
|
||||||
|
| Крупная переиспользуемая композиция экрана | `widgets/<widget>` |
|
||||||
| Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` |
|
| Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` |
|
||||||
| Представление/поведение бизнес-сущности | `entities/<entity>` |
|
| Представление/поведение бизнес-сущности | `entities/<entity>` |
|
||||||
| Переиспользуемый код без WealthLedger domain meaning | `shared` |
|
| Переиспользуемый код без WealthLedger domain meaning | `shared` |
|
||||||
|
|
||||||
Локальный FSD profile: `app → pages → features → entities → shared`, без `widgets`.
|
Локальный FSD profile: `app → pages → widgets → features → entities → shared`.
|
||||||
Крупный уникальный блок остаётся в своей page. Form, mutation или click handler
|
Widget собирает несколько нижних слоёв в самостоятельный UI-блок; компонент для
|
||||||
сами по себе не требуют feature. Два callers не делают domain helper generic shared.
|
одной page остаётся у page. Form, mutation или click handler сами по себе не
|
||||||
|
требуют feature. Два callers не делают domain helper generic shared.
|
||||||
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
|
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
|
||||||
|
|
||||||
**Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner.
|
**Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner.
|
||||||
@@ -47,12 +49,12 @@ Slice именуется по ответственности, а не `helpers`,
|
|||||||
|
|
||||||
Cross-slice consumer использует `@/` и `index.ts` владельца, например
|
Cross-slice consumer использует `@/` и `index.ts` владельца, например
|
||||||
`@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не
|
`@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не
|
||||||
импортирует внутренний файл напрямую. Slices `pages`, `features`, `entities` не зависят от соседнего slice своего слоя;
|
импортирует внутренний файл напрямую. Slices `pages`, `widgets`, `features`, `entities` не зависят от соседнего slice своего слоя;
|
||||||
композиция принадлежит вышележащему owner. Existing меж-slice исключения
|
композиция принадлежит вышележащему owner. Existing меж-slice исключения
|
||||||
не являются разрешением добавлять новые.
|
не являются разрешением добавлять новые.
|
||||||
|
|
||||||
Направление зависимостей, public imports и запрет соседних slices в
|
Направление зависимостей, public imports и запрет соседних slices в
|
||||||
`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
|
`pages`, `widgets`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
|
||||||
одного slice и связи shared segments этот запрет не ограничивает.
|
одного slice и связи shared segments этот запрет не ограничивает.
|
||||||
Knip проверяет использование exports, а не смысл placement.
|
Knip проверяет использование exports, а не смысл placement.
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R
|
|||||||
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
|
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
|
||||||
Не добавляй собственный refresh/retry loop в caller.
|
Не добавляй собственный refresh/retry loop в caller.
|
||||||
|
|
||||||
|
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
|
||||||
|
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
|
||||||
|
token stores не добавляются.
|
||||||
|
|
||||||
Protected queries используют existing session-aware boundary; logout очищает
|
Protected queries используют existing session-aware boundary; logout очищает
|
||||||
session-scoped cache. Access token хранится только в памяти, refresh передаётся
|
session-scoped cache. Access token хранится только в памяти, refresh передаётся
|
||||||
httpOnly cookie с `credentials: include`. Не сохраняй tokens в localStorage,
|
httpOnly cookie с `credentials: include`. Не сохраняй tokens в localStorage,
|
||||||
@@ -50,6 +54,15 @@ GET subtree; ошибка mutation не меняет generation/cache. Это о
|
|||||||
от обычного фонового refetch. Даже повторное сохранение того же token начинает
|
от обычного фонового refetch. Даже повторное сохранение того же token начинает
|
||||||
новую generation; поздний ответ прежней generation не попадает в новый кэш.
|
новую generation; поздний ответ прежней generation не попадает в новый кэш.
|
||||||
|
|
||||||
|
Отмена/reset принадлежат TanStack Query и generated queryFn с AbortSignal,
|
||||||
|
без HTTP request registry, URL-wide latest-read counters и access epochs.
|
||||||
|
Успешный HTTP с `data: null` остаётся query success; readiness выбирается по
|
||||||
|
data/metadata, без подмены query status и отдельного transport state.
|
||||||
|
Status синхронизации содержит только состояние работы. Resources читаются
|
||||||
|
через `sync=observe` во время работы и после завершения, без hash comparison.
|
||||||
|
Согласованность income entries/groups обеспечивает backend; frontend не
|
||||||
|
дублирует её runtime validator.
|
||||||
|
|
||||||
Mutations используют существующий generated client и query-key owner;
|
Mutations используют существующий generated client и query-key owner;
|
||||||
после успеха обновляй/invalidируй только связанные данные. Optimistic update
|
после успеха обновляй/invalidируй только связанные данные. Optimistic update
|
||||||
нужен конкретному UX и обязан иметь rollback/error behavior.
|
нужен конкретному UX и обязан иметь rollback/error behavior.
|
||||||
|
|||||||
@@ -7,11 +7,19 @@
|
|||||||
checkout. Внешний API задаётся предоставленным публичным контрактом;
|
checkout. Внешний API задаётся предоставленным публичным контрактом;
|
||||||
исходники или инструкции его реализации не являются зависимостью разработки.
|
исходники или инструкции его реализации не являются зависимостью разработки.
|
||||||
При нехватке требования сообщи, какое решение не определено, до его реализации.
|
При нехватке требования сообщи, какое решение не определено, до его реализации.
|
||||||
|
При конфликте с общим шаблоном следуй местным правилам и явному запросу
|
||||||
|
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
|
||||||
|
логики, публичного контракта или утверждённой семантики возвращай владельцу
|
||||||
|
на согласование до зависимой работы.
|
||||||
|
|
||||||
Начни с результата и affected areas. До edits проследи flow/invariants, найди
|
Начни с результата и affected areas. До edits проследи flow/invariants, найди
|
||||||
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
|
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
|
||||||
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
|
Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md);
|
||||||
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
|
OpenSpec lifecycle начинай после явного решения Change Owner.
|
||||||
|
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
|
||||||
|
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
|
||||||
|
выполни `nvm use --silent` из frontend. Если `nvm.sh` недоступен, остановись,
|
||||||
|
не используй системный Node.
|
||||||
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
|
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
|
||||||
|
|
||||||
## Change scope
|
## Change scope
|
||||||
@@ -57,21 +65,27 @@ Peer-конфликт решай совместимыми версиями бе
|
|||||||
|
|
||||||
## Проверки и завершение
|
## Проверки и завершение
|
||||||
|
|
||||||
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
|
| Когда | Что проверить |
|
||||||
risk checks из местной testing convention. Targeted tests ускоряют итерацию,
|
| --- | --- |
|
||||||
но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
|
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
|
||||||
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
|
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
|
||||||
чтения и `git diff --check`; application build ради текста не нужен.
|
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
|
||||||
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
|
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
|
||||||
и ограничения. Зелёный lint не заменяет semantic/security review.
|
|
||||||
|
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
|
||||||
|
проверенные входы, команду, exit code, результат и ограничения. Повторяй
|
||||||
|
проверку при изменении значимых входов (включая config, dependencies и
|
||||||
|
generated artifacts), сомнении в evidence или явном запросе пользователя.
|
||||||
|
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
|
||||||
|
|
||||||
## Git и релизы
|
## Git и релизы
|
||||||
|
|
||||||
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
|
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
|
||||||
|
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
|
||||||
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
|
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
|
||||||
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
|
Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)`
|
||||||
содержит одну связанную задачу. При связанной issue добавь её номер в конец
|
или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit
|
||||||
заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout
|
содержит одну связанную задачу. Исключения release и правила ссылки — в
|
||||||
не управляет этим repo.
|
Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
|
||||||
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
|
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
|
||||||
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
|
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ throwing route, безопасная ссылка и reset после navigation
|
|||||||
|
|
||||||
Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx),
|
Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx),
|
||||||
[client test](../../src/shared/api/client.test.ts),
|
[client test](../../src/shared/api/client.test.ts),
|
||||||
[usePortfolio test](../../src/entities/portfolio/api/usePortfolio.test.tsx).
|
[resource lifecycle test](../../src/shared/api/resource-query.test.tsx).
|
||||||
Не копируй fixtures/сценарии целиком, если задача затрагивает один исход.
|
Не копируй fixtures/сценарии целиком, если задача затрагивает один исход.
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
CSF сохраняет default meta и прямые story exports; configuration exceptions
|
CSF сохраняет default meta и прямые story exports; configuration exceptions
|
||||||
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
|
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
|
||||||
|
|
||||||
|
Тип значений формы выводится из её schema, когда input/output совпадают.
|
||||||
|
Общие props определяются у одного owner; DTO и presentation model сохраняют
|
||||||
|
собственные типы при смысловом отличии. Type re-export сохраняет public imports.
|
||||||
|
|
||||||
## Читаемость: ESLint и review
|
## Читаемость: ESLint и review
|
||||||
|
|
||||||
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
|
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
|
||||||
@@ -33,6 +37,10 @@ CSS и Markdown также держи в 80 колонках там, где эт
|
|||||||
|
|
||||||
Review проверяет то, чего синтаксис не доказывает:
|
Review проверяет то, чего синтаксис не доказывает:
|
||||||
|
|
||||||
|
Обоснованное config exception сопровождается причиной и явным ожиданием в
|
||||||
|
existing lint tests, включая разрешённый и запрещённый примеры. Это позволяет
|
||||||
|
настраивать профиль без случайного ослабления остальных правил.
|
||||||
|
|
||||||
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
|
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
|
||||||
- Имена derived values описывают значение, например `projectedAmount`.
|
- Имена derived values описывают значение, например `projectedAmount`.
|
||||||
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
|
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
|
||||||
|
|||||||
+11
-5
@@ -63,6 +63,12 @@ Presentation VM для form-context/UI-only state может использов
|
|||||||
в каталоге: её граф зависимостей не достигает application data/API.
|
в каталоге: её граф зависимостей не достигает application data/API.
|
||||||
`useTranslation` и декларативный вывод коллекции допустимы во view.
|
`useTranslation` и декларативный вывод коллекции допустимы во view.
|
||||||
|
|
||||||
|
ESLint ограничивает прямые data/auth/router hook imports в `*.view.tsx`;
|
||||||
|
type imports, error helpers, router components и form context разрешены.
|
||||||
|
Транзитивный fetching через local presentation hook проверяется review;
|
||||||
|
Storybook дополнительно проверяет reachable graph. Новое исключение должно
|
||||||
|
сохранять presentation-only responsibility.
|
||||||
|
|
||||||
JSX props получают ссылки на функции, включая `onClick`, render props,
|
JSX props получают ссылки на функции, включая `onClick`, render props,
|
||||||
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
|
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
|
||||||
`useController` в VM вместо inline `Controller.render`. Значение prop с условием
|
`useController` в VM вместо inline `Controller.render`. Значение prop с условием
|
||||||
@@ -76,9 +82,9 @@ JSX props получают ссылки на функции, включая `onC
|
|||||||
Closure над состоянием, mutations и refs является действием VM, а не shared helper.
|
Closure над состоянием, mutations и refs является действием VM, а не shared helper.
|
||||||
|
|
||||||
VM собирает связные предметные hooks, когда внутри смешиваются независимые
|
VM собирает связные предметные hooks, когда внутри смешиваются независимые
|
||||||
сценарии. Например, `useOpeningDateField` владеет draft/date parsing и событиями
|
сценарии. Например, `useRefundPurchase` владеет выбором исходной покупки,
|
||||||
даты, `usePaymentSystemField` — выбором платёжной системы. Каждый такой hook
|
её загрузкой и пагинацией. Общий адаптер `bankDateField` обслуживает поля даты;
|
||||||
возвращает данные и действия своей ответственности, VM объединяет их для view.
|
простое подключение одного controller остаётся в VM формы.
|
||||||
Не группируй несвязанные функции только по префиксу `on*` и не создавай
|
Не группируй несвязанные функции только по префиксу `on*` и не создавай
|
||||||
passthrough hook над одним вызовом ради формального дробления.
|
passthrough hook над одним вызовом ради формального дробления.
|
||||||
|
|
||||||
@@ -110,8 +116,8 @@ Render-helper замени самостоятельным presentation component
|
|||||||
При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
|
При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
|
||||||
|
|
||||||
Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
|
Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
|
||||||
лежит рядом со своим view, тестом и story; общий `BankForms.module.css`
|
лежит рядом со своим view, тестом и story. Формы используют Mantine
|
||||||
остаётся в `bank-card/ui`.
|
`Fieldset variant="unstyled"` для групповой блокировки полей без локального CSS.
|
||||||
|
|
||||||
## Формы
|
## Формы
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Неверное поведение
|
||||||
|
---
|
||||||
|
## Проблема и шаги воспроизведения
|
||||||
|
|
||||||
|
## Факт / ожидание
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Окружение / ссылки (если применимо)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
name: Engineering Task
|
||||||
|
about: CI, тесты, документация, процесс и прочее
|
||||||
|
---
|
||||||
|
## Контекст и результат
|
||||||
|
|
||||||
|
## Scope / связанные Issues (если есть)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Новое наблюдаемое поведение
|
||||||
|
---
|
||||||
|
## Зачем и какой результат
|
||||||
|
|
||||||
|
## Ожидаемое поведение
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Зависимости / связанные Issues (если есть)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: Refactoring / Technical Debt
|
||||||
|
about: Улучшение без нового поведения
|
||||||
|
---
|
||||||
|
## Проблема и результат
|
||||||
|
|
||||||
|
## Сохраняемые инварианты / границы
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Зависимости / ссылки (если есть)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
## Linked Issue
|
||||||
|
Refs
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Команда / результат / ограничения:
|
||||||
|
|
||||||
|
## Breaking Changes (если есть)
|
||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [develop, main]
|
branches: [develop, main]
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened, edited]
|
types: [opened, synchronize, reopened]
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '17 5 * * *'
|
- cron: '17 5 * * *'
|
||||||
|
|
||||||
@@ -34,7 +34,11 @@ jobs:
|
|||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
run: |
|
run: |
|
||||||
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
||||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
|
||||||
|
else
|
||||||
|
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||||
|
fi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
needs: commits
|
needs: commits
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
только его файлы и предоставленные публичные контракты. Перед работой прочитай
|
только его файлы и предоставленные публичные контракты. Перед работой прочитай
|
||||||
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
|
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
|
||||||
объединяй triggers задачи и путей; читай активированные темы до решения.
|
объединяй triggers задачи и путей; читай активированные темы до решения.
|
||||||
|
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
|
||||||
|
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
|
||||||
|
(main#28; после merge — актуальная версия `develop`).
|
||||||
|
|
||||||
| Решение / сигнал | Читать |
|
| Решение / сигнал | Читать |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
@@ -1,5 +1,99 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.1.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.0.0...v1.1.0) (2026-10-12)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add stock screener ([a84411d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a84411df39dc131290e9f5b5d9313fed17cc629a))
|
||||||
|
* align investment client with hourly sync ([432cca2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/432cca299f3a9a9e45b2697527369ee95f2d0adc))
|
||||||
|
* render hourly investment states ([3c226f8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3c226f8c29efceccb75af0d6424639a757534f39))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* align frontend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([c315f80](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c315f80b1b07ae0888366512ec157b8e29c81d62))
|
||||||
|
* preserve latest stock screener result ([cec9c63](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cec9c6348a389f16f272dac5533aebc9d12ceae8))
|
||||||
|
* show bank summary totals ([20b2cdf](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/20b2cdfd432f8b01e447f68657cf5c685a297f1e))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* clarify frontend verification policy ([cac4351](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cac4351bc4abf2cec7c5b6611a74d87bf4ec2f70))
|
||||||
|
* pin frontend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([0d747cc](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0d747cc93ee08c337cf300c0367612708f45dfb9))
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
* skip checks on PR metadata edits ([c882120](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c8821200ce699dab1ce65360d1968d5877a2e78b))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* align frontend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([617edfd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/617edfd489a6a84bb33b57386eca4eb54661f4f0))
|
||||||
|
|
||||||
|
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.4.0...v1.0.0) (2026-10-07)
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* drop investment stateVersion/progress/resources
|
||||||
|
from the client contract and use data/metadata readiness instead of
|
||||||
|
rewritten query status. Cross-tab auth may require re-login.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add quiet reading with rare confirmations ([5b30bb8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5b30bb802a4ec43c036066f9a7e178f37e747a0b))
|
||||||
|
* default journal period to last 7 days ([0ea8d7b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0ea8d7b62d50a4b7c92e9a9934aead6246ac97f9))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **deps:** remove npm dependency on Nexus ([c096fc7](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c096fc70ef442474c9b7766fdf5772b645166b92))
|
||||||
|
* **deps:** resolve npm audit advisories ([f873db7](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f873db7b027b6dee0c7382b4bd3df5198c7a4198))
|
||||||
|
* **deps:** resolve npm audit advisories ([10baefd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/10baefda790e7fb491c893724ddc4da7992103f0))
|
||||||
|
* resolve frontend audit findings ([f6db28d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f6db28defa51bd9d8fe932ceae4c3c720aba7eb3))
|
||||||
|
* sort stock and bond events by payment date ([f5f4447](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f5f4447d60661aa92c80b50a16e8cb194dfa161f))
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
* **tbank-connection:** poll screen sync every five seconds ([74f8a72](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/74f8a72ed55af2348fa3668f57312f4fdfc1e8a8)), references [WealthLedger/specs#21](https://git.personal.ksv741.ru/WealthLedger/specs/issues/21)
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* address frontend review feedback ([a4046db](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a4046dbe87bd7dc949df32da3b6aaf174e6f5e8f))
|
||||||
|
* clarify frontend model ownership ([0865b35](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0865b35f93865b4052181d918cee80c995312930))
|
||||||
|
* clarify frontend module ownership ([f9f0f69](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f9f0f6921e86f6c81510373c680b136e4580f386))
|
||||||
|
* encapsulate instrument chart data ([64fe902](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/64fe902192b60a72440635f5610ebe02cf8543f8))
|
||||||
|
* enforce enum machine codes [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([5b69003](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5b6900351612b6ba34c694fc99612733e7901c79))
|
||||||
|
* enforce frontend view model boundaries ([ca0ad4f](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ca0ad4f824a47fa47e32d4717e27b15c8a985f7d))
|
||||||
|
* move PortfolioStructure to widgets layer ([c25f6bd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c25f6bd85b88c4c7acb2f5ae6fc013aec77005b2))
|
||||||
|
* remove redundant page view models ([fb3a5a6](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/fb3a5a6eaa0223a15ea2c9f585a06459371f0a25))
|
||||||
|
* share instrument search presentation ([32a7786](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32a77860c8eaea4a59a26e55b2d66976fd8f9484))
|
||||||
|
* share source sync status presentation ([0bc8bc3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0bc8bc36b7263b7f0120a08df840b290b40d8116))
|
||||||
|
* simplify auth and instrument pages ([b404c18](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b404c1802a74f5f369173fdfcb086a3d5760e35a))
|
||||||
|
* simplify calendar and form state ([94adbaf](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/94adbafa344b23d7f95c31db975d99d990bfcb8b))
|
||||||
|
* simplify errors and focus tests ([7e976c3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7e976c330c54715018eda4bff0764c5d4de9c8b0))
|
||||||
|
* simplify finance view models ([2844b22](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2844b22dbace41a02c1b5a07b3801e84c66b3528))
|
||||||
|
* simplify frontend state and mutation boundaries ([a7537da](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a7537da34452ab1a15138ab0c1cd93d53d06c517))
|
||||||
|
* simplify query and session ownership ([6999707](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/69997075e569da3edd43e242ef36f09311262412))
|
||||||
|
* simplify resource queries and layout tests ([c1b0873](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c1b08735e3a45d565c3836ae37ad76186bf48f26))
|
||||||
|
* tighten types and reuse instrument UI ([7c28a42](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7c28a423dd8a3cc4a5bdcb49b8de7018979df413))
|
||||||
|
* unify bank form types and actions ([ff67a76](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ff67a768addc0c4ba55247f5e2d9d5d5d5aec734))
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
* reduce duplicate actions and serialize checks ([95fdc87](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/95fdc8759e20f9baa4ac3d00e703752c79010e01))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* remove redundant frontend checks ([320ea59](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/320ea5922a70ab81ce4f05c3c90eb7b6a664fa39))
|
||||||
|
|
||||||
|
### Styles
|
||||||
|
|
||||||
|
* pin ternary operator formatting [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([12c3b72](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/12c3b72bfb32dff5f1ffdb36519c90f7ea852488))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* add typed ESLint checks [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([5763010](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/57630102e4bcc6b555dd546ba373a14a88256eed))
|
||||||
|
* finish frontend ESLint migration [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([72d7835](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/72d7835227340e349d99f57db134a635f276f6e9))
|
||||||
|
* guard presentation data boundaries ([6a89b08](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6a89b08ee571562fea349801169c40f1f993e9b9))
|
||||||
|
* guard unsafe DOM HTML with ESLint [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([061ec70](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/061ec708c651029cd7d61d22319d11e7ac9f074e))
|
||||||
|
* remove unused exports ([fcb938b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/fcb938b5a29fc82f00bbaa8b2ee776ca57dad010))
|
||||||
|
|
||||||
## [0.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.3.0...v0.4.0) (2026-09-26)
|
## [0.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.3.0...v0.4.0) (2026-09-26)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -203,7 +203,8 @@ Checkout frontend содержит все входы сборки и прове
|
|||||||
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
|
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
|
||||||
схемы — явное обновление snapshot с review и `npm run openapi:generate`.
|
схемы — явное обновление snapshot с review и `npm run openapi:generate`.
|
||||||
Runtime URL API задаётся окружением, а не расположением другого репозитория.
|
Runtime URL API задаётся окружением, а не расположением другого репозитория.
|
||||||
Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу.
|
Разработка начинается с Issue; lightweight или SDD выбирается по критериям
|
||||||
|
Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner.
|
||||||
|
|
||||||
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
|
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
|
||||||
preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
|
preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
|
||||||
@@ -211,9 +212,11 @@ preset по имени из `.release-it.json`. Версия preset закреп
|
|||||||
## Релизы
|
## Релизы
|
||||||
|
|
||||||
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
|
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
|
||||||
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
|
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
|
||||||
|
общие правила — в Engineering Workflow по ссылке из AGENTS.
|
||||||
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
|
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
|
||||||
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
|
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
|
||||||
|
включая `spec:`. Версии вручную не повышай.
|
||||||
|
|
||||||
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
|
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
|
||||||
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
|
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
|
defaultIgnores: false,
|
||||||
extends: ["@commitlint/config-conventional"],
|
extends: ["@commitlint/config-conventional"],
|
||||||
|
ignores: [
|
||||||
|
message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
|
||||||
|
message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
|
||||||
|
],
|
||||||
rules: {
|
rules: {
|
||||||
|
"references-empty": [2, "never"],
|
||||||
"type-enum": [
|
"type-enum": [
|
||||||
2,
|
2,
|
||||||
"always",
|
"always",
|
||||||
|
|||||||
+20
-5
@@ -1,5 +1,14 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
forbidden: [
|
forbidden: [
|
||||||
|
{
|
||||||
|
from: {
|
||||||
|
path: "^(src|packages/[^/]+/src)/",
|
||||||
|
pathNot: "[.](test|stories|fixtures)[.]|^src/test/",
|
||||||
|
},
|
||||||
|
name: "production-must-not-depend-on-test-code",
|
||||||
|
severity: "error",
|
||||||
|
to: { path: "[.](test|stories|fixtures)[.]|^src/test/" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
from: {},
|
from: {},
|
||||||
name: "no-unresolved",
|
name: "no-unresolved",
|
||||||
@@ -20,17 +29,17 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: {
|
from: {
|
||||||
path: "^src/(app|pages|features|entities|shared)/([^/]+)/",
|
path: "^src/(app|pages|widgets|features|entities|shared)/([^/]+)/",
|
||||||
},
|
},
|
||||||
name: "slices-must-use-public-api",
|
name: "slices-must-use-public-api",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
to: {
|
to: {
|
||||||
path: String.raw`^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
|
path: String.raw`^src/(app|pages|widgets|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
|
||||||
pathNot: "^src/$1/$2/",
|
pathNot: "^src/$1/$2/",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: { path: "^src/(pages|features|entities)/([^/]+)/" },
|
from: { path: "^src/(pages|widgets|features|entities)/([^/]+)/" },
|
||||||
name: "no-same-layer-cross-slice-imports",
|
name: "no-same-layer-cross-slice-imports",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
|
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
|
||||||
@@ -39,18 +48,24 @@ module.exports = {
|
|||||||
from: { path: "^src/shared/" },
|
from: { path: "^src/shared/" },
|
||||||
name: "shared-must-not-depend-on-layers",
|
name: "shared-must-not-depend-on-layers",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
to: { path: "^src/(entities|features|pages|app)/" },
|
to: { path: "^src/(entities|features|widgets|pages|app)/" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: { path: "^src/entities/" },
|
from: { path: "^src/entities/" },
|
||||||
name: "entities-must-not-depend-on-higher-layers",
|
name: "entities-must-not-depend-on-higher-layers",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
to: { path: "^src/(features|pages|app)/" },
|
to: { path: "^src/(features|widgets|pages|app)/" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: { path: "^src/features/" },
|
from: { path: "^src/features/" },
|
||||||
name: "features-must-not-depend-on-higher-layers",
|
name: "features-must-not-depend-on-higher-layers",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
|
to: { path: "^src/(widgets|pages|app)/" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: { path: "^src/widgets/" },
|
||||||
|
name: "widgets-must-not-depend-on-higher-layers",
|
||||||
|
severity: "error",
|
||||||
to: { path: "^src/(pages|app)/" },
|
to: { path: "^src/(pages|app)/" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -153,11 +153,13 @@ export default defineConfig(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
"@typescript-eslint/consistent-type-imports": "error",
|
||||||
// React ignores event handler return values; floating promises are still checked in bodies.
|
// React ignores event handler return values; floating promises are still checked in bodies.
|
||||||
"@typescript-eslint/no-misused-promises": [
|
"@typescript-eslint/no-misused-promises": [
|
||||||
"error",
|
"error",
|
||||||
{ checksVoidReturn: { attributes: false } },
|
{ checksVoidReturn: { attributes: false } },
|
||||||
],
|
],
|
||||||
|
"@typescript-eslint/no-non-null-assertion": "error",
|
||||||
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -183,6 +185,44 @@ export default defineConfig(
|
|||||||
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
|
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
|
||||||
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
|
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ["src/**/*.view.tsx"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-imports": ["error", {
|
||||||
|
paths: [
|
||||||
|
{
|
||||||
|
allowTypeImports: true,
|
||||||
|
message: "Query lifecycle belongs to the data VM.",
|
||||||
|
name: "@tanstack/react-query",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
allowTypeImports: true,
|
||||||
|
message: "Transport belongs to shared/api and the data VM.",
|
||||||
|
name: "openapi-fetch",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
allowTypeImports: true,
|
||||||
|
message: "Transport belongs to shared/api and the data VM.",
|
||||||
|
name: "openapi-react-query",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
allowTypeImports: true,
|
||||||
|
group: ["react-router", "@/entities/*", "@/features/*"],
|
||||||
|
importNamePattern: "^use",
|
||||||
|
message: "Pass route, auth and entity data from the data VM.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
allowTypeImports: true,
|
||||||
|
group: ["@/shared/api", "@/shared/api/*"],
|
||||||
|
importNamePattern: "^use|^api(?:Query)?Client$",
|
||||||
|
message: "Fetching belongs to the data VM; error helpers are allowed.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||||
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
||||||
@@ -197,6 +237,7 @@ export default defineConfig(
|
|||||||
ignoreTypeIndexes: true,
|
ignoreTypeIndexes: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+112
-1305
File diff suppressed because it is too large
Load Diff
+10
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-frontend",
|
"name": "wealth-ledger-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.0",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "24.21.0"
|
"node": "24.21.0"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"openapi:check": "node scripts/check-openapi.mjs",
|
"openapi:check": "node scripts/check-openapi.mjs",
|
||||||
"knip": "npm run build:packages && knip",
|
"knip": "npm run build:packages && knip",
|
||||||
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
|
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
|
||||||
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
|
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
|
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0"
|
"lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0"
|
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "5.9.1",
|
"@hookform/resolvers": "5.9.1",
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"@mantine/core": "9.6.1",
|
"@mantine/core": "9.6.1",
|
||||||
"@mantine/dates": "9.6.1",
|
"@mantine/dates": "9.6.1",
|
||||||
"@mantine/hooks": "9.6.1",
|
"@mantine/hooks": "9.6.1",
|
||||||
|
"@mantine/notifications": "9.6.1",
|
||||||
"@tanstack/react-query": "5.102.8",
|
"@tanstack/react-query": "5.102.8",
|
||||||
"@wealthledger/icons": "file:packages/icons",
|
"@wealthledger/icons": "file:packages/icons",
|
||||||
"@wealthledger/tokens": "file:packages/tokens",
|
"@wealthledger/tokens": "file:packages/tokens",
|
||||||
@@ -114,6 +115,12 @@
|
|||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
"vitest": "5.0.0"
|
"vitest": "5.0.0"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"release-it": {
|
||||||
|
"basic-ftp": "6.2.1",
|
||||||
|
"undici": "7.29.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"esbuild@0.28.2": true,
|
"esbuild@0.28.2": true,
|
||||||
"fsevents@2.3.3": true
|
"fsevents@2.3.3": true
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { AddIcon, DeleteIcon, EditIcon } from "./index";
|
import { AddIcon, DeleteIcon, EditIcon, OperationIcon } from "./index";
|
||||||
|
|
||||||
describe("icon accessibility", () => {
|
describe("icon accessibility", () => {
|
||||||
it.each([AddIcon, EditIcon, DeleteIcon])(
|
it.each([AddIcon, EditIcon, DeleteIcon])(
|
||||||
@@ -34,4 +34,16 @@ describe("icon accessibility", () => {
|
|||||||
expect(icon).toHaveAttribute("stroke", "currentColor");
|
expect(icon).toHaveAttribute("stroke", "currentColor");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
it("keeps operation icons decorative", () => {
|
||||||
|
render(
|
||||||
|
<button aria-label="Операция" type="button">
|
||||||
|
<OperationIcon color="orange" variant="home" />
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access -- Decorative SVG has no accessible role.
|
||||||
|
expect(screen.getByRole("button").querySelector("svg")).toHaveAttribute(
|
||||||
|
"aria-hidden", "true",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2023",
|
|
||||||
"lib": [
|
"lib": [
|
||||||
"ES2023",
|
"ES2023",
|
||||||
"DOM"
|
"DOM"
|
||||||
],
|
],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"emitDeclarationOnly": true,
|
"emitDeclarationOnly": true,
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import "@mantine/core/styles.css";
|
||||||
|
|
||||||
|
import "../styles.css";
|
||||||
|
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup.
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { cssVariablesResolver, theme } from "../theme";
|
||||||
|
import { EntryCard } from "./EntryCard";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it("shows the full wrapped title without horizontal overflow", () => {
|
||||||
|
const title = "Дивидендная выплата с очень длинным описанием, которое должно переноситься и оставаться доступным полностью";
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MantineProvider cssVariablesResolver={cssVariablesResolver} theme={theme}>
|
||||||
|
<EntryCard
|
||||||
|
initial="Д"
|
||||||
|
metadata="29.09.2026"
|
||||||
|
title={title}
|
||||||
|
tone="positive"
|
||||||
|
value="123 456 789,90 ₽"
|
||||||
|
valueLabel="Дивиденды"
|
||||||
|
wrap
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const description = screen.getByText(title);
|
||||||
|
expect(description.clientHeight).toBeGreaterThan(0);
|
||||||
|
expect(description.scrollHeight)
|
||||||
|
.toBeLessThanOrEqual(description.clientHeight);
|
||||||
|
expect(document.documentElement.scrollWidth)
|
||||||
|
.toBeLessThanOrEqual(window.innerWidth);
|
||||||
|
});
|
||||||
@@ -93,3 +93,13 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row[data-wrap] p {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
@media (min-width: 36em) {
|
||||||
|
.row[data-wrap] {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,3 +39,5 @@ export default meta;
|
|||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const LongContentAndFallback: Story = {};
|
export const LongContentAndFallback: Story = {};
|
||||||
|
|
||||||
|
export const WrappedOperation: Story = { args: { value: "−123 456 789 012 345 678,90 ₽", wrap: true } };
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type EntryCardProps = {
|
|||||||
| "positive";
|
| "positive";
|
||||||
value: string;
|
value: string;
|
||||||
valueLabel: string;
|
valueLabel: string;
|
||||||
|
wrap?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EntryCard: FC<EntryCardProps> = (props) => {
|
const EntryCard: FC<EntryCardProps> = (props) => {
|
||||||
@@ -27,11 +28,18 @@ const EntryCard: FC<EntryCardProps> = (props) => {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const titleLineClamp = props.wrap ? undefined : 1;
|
||||||
|
|
||||||
const valueContainerClass = props.actions ? classes.valueFooter : undefined;
|
const valueContainerClass = props.actions ? classes.valueFooter : undefined;
|
||||||
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
|
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className={classes.row} style={surfaceStyle} withBorder>
|
<Paper
|
||||||
|
className={classes.row}
|
||||||
|
data-wrap={props.wrap || undefined}
|
||||||
|
style={surfaceStyle}
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -43,7 +51,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
<Stack className={classes.identity} gap={2}>
|
<Stack className={classes.identity} gap={2}>
|
||||||
<Text fw={700} lineClamp={1}>
|
<Text fw={700} lineClamp={titleLineClamp}>
|
||||||
{props.title}
|
{props.title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2023",
|
|
||||||
"lib": ["ES2023", "DOM"],
|
"lib": ["ES2023", "DOM"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"emitDeclarationOnly": true,
|
"emitDeclarationOnly": true,
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
|
|||||||
@@ -6,12 +6,6 @@ const { version } = require("../package.json");
|
|||||||
const git = (...arguments_) =>
|
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.
|
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(
|
const tag_ = git(
|
||||||
"tag",
|
"tag",
|
||||||
"--merged",
|
"--merged",
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
|
|||||||
const eslint = new ESLint();
|
const eslint = new ESLint();
|
||||||
|
|
||||||
for (const [filePath, code, rule] of [
|
for (const [filePath, code, rule] of [
|
||||||
|
[
|
||||||
|
"src/shared/api/problem-details.ts",
|
||||||
|
"export function read(value: string) { return value?.trim(); }",
|
||||||
|
"@typescript-eslint/no-unnecessary-condition",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/shared/api/problem-details.ts",
|
||||||
|
"export function read(value?: string) { return value!.trim(); }",
|
||||||
|
"@typescript-eslint/no-non-null-assertion",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/shared/api/problem-details.ts",
|
||||||
|
"import { ReactNode } from \"react\"; export type Content = ReactNode;",
|
||||||
|
"@typescript-eslint/consistent-type-imports",
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"src/shared/api/problem-details.ts",
|
"src/shared/api/problem-details.ts",
|
||||||
"export function run() { Promise.resolve(); }",
|
"export function run() { Promise.resolve(); }",
|
||||||
@@ -323,6 +338,46 @@ test("JSX spacing fixes preserve text interpolation and are stable", async () =>
|
|||||||
assert.equal(again.errorCount, 0);
|
assert.equal(again.errorCount, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("views accept presentation inputs but reject direct data hooks", async () => {
|
||||||
|
const eslint = new ESLint();
|
||||||
|
const filePath = "src/features/instrument-details/ui/InstrumentEvents.view.tsx";
|
||||||
|
|
||||||
|
for (const code of [
|
||||||
|
"import { useStock } from \"@/entities/stock\";",
|
||||||
|
"import { useAuth } from \"@/features/auth\";",
|
||||||
|
"import { useNavigate } from \"react-router\";",
|
||||||
|
"import { apiClient } from \"@/shared/api\";",
|
||||||
|
"import { useQuery } from \"@tanstack/react-query\";",
|
||||||
|
]) {
|
||||||
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
|
assert.equal(result.fatalErrorCount, 0);
|
||||||
|
assert.ok(result.messages.some(message =>
|
||||||
|
message.ruleId === "no-restricted-imports"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const code of [
|
||||||
|
"import { Link } from \"react-router\";",
|
||||||
|
"import { apiErrorCode } from \"@/shared/api\";",
|
||||||
|
"import type { StockPosition } from \"@/entities/stock\";",
|
||||||
|
"import type { QueryKey } from \"@tanstack/react-query\";",
|
||||||
|
"import { useTranslation } from \"react-i18next\";",
|
||||||
|
"import { useFormContext } from \"react-hook-form\";",
|
||||||
|
]) {
|
||||||
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
|
assert.equal(result.fatalErrorCount, 0);
|
||||||
|
assert.ok(result.messages.every(message =>
|
||||||
|
message.ruleId !== "no-restricted-imports"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [vm] = await eslint.lintText(
|
||||||
|
"import { useStock } from \"@/entities/stock\";",
|
||||||
|
{ filePath: "src/pages/stock/ui/StockPage/StockPage.vm.ts" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(vm.messages.every(message =>
|
||||||
|
message.ruleId !== "no-restricted-imports"));
|
||||||
|
});
|
||||||
|
|
||||||
test("compact JSX and contract nulls need no local suppressions", async () => {
|
test("compact JSX and contract nulls need no local suppressions", async () => {
|
||||||
const eslint = new ESLint({ fix: true });
|
const eslint = new ESLint({ fix: true });
|
||||||
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { useCurrentRoute } from "./useCurrentRoute";
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useMatch } from "react-router";
|
||||||
|
|
||||||
|
function useCurrentRoute() {
|
||||||
|
const account = useMatch("/accounts/:accountId/:section?");
|
||||||
|
|
||||||
|
const section = (["events", "income", "operations"] as const)
|
||||||
|
.find(value => value === account?.params.section?.toLowerCase());
|
||||||
|
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
accounts: useMatch("/accounts"),
|
||||||
|
bond: useMatch("/bonds/:bondId"),
|
||||||
|
bonds: useMatch("/bonds"),
|
||||||
|
card: useMatch("/cards/:cardId"),
|
||||||
|
cards: useMatch("/cards"),
|
||||||
|
section,
|
||||||
|
settings: useMatch("/settings"),
|
||||||
|
stock: useMatch("/stocks/:stockId"),
|
||||||
|
stocks: useMatch("/stocks"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useCurrentRoute };
|
||||||
@@ -238,6 +238,34 @@ describe("All accounts routes", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["operations", "events", "income"])(
|
||||||
|
"restores applied dates when switching accounts in %s",
|
||||||
|
async (section) => {
|
||||||
|
const search = "?from=2026-08-01&to=2026-08-31";
|
||||||
|
renderPage(`/accounts/all/${section}${search}`);
|
||||||
|
fireEvent.click(await screen.findByLabelText("Период"));
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /10 August 2026/i }),
|
||||||
|
);
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /31 August 2026/i }),
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||||
|
"10.08.2026 – 31.08.2026",
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||||
|
"01.08.2026 – 31.08.2026",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
|
||||||
|
`/accounts/b/${section}${search}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("groups the same security and links its accounts and overview sections", async () => {
|
it("groups the same security and links its accounts and overview sections", async () => {
|
||||||
renderPage("/accounts/all");
|
renderPage("/accounts/all");
|
||||||
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
|
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
|
||||||
@@ -312,6 +340,11 @@ describe("All accounts routes", () => {
|
|||||||
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
|
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
|
||||||
"/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income",
|
"/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income",
|
||||||
);
|
);
|
||||||
|
await screen.findByText("Первая страница");
|
||||||
|
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "Все счета" }));
|
||||||
|
await waitFor(() => expect(screen.getByText("Первая страница")).toBeVisible());
|
||||||
|
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restarts an expired aggregate cursor without losing filters", async () => {
|
it("restarts an expired aggregate cursor without losing filters", async () => {
|
||||||
@@ -336,7 +369,9 @@ describe("All accounts routes", () => {
|
|||||||
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
|
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
|
||||||
await screen.findAllByText("Выплата купона");
|
await screen.findAllByText("Выплата купона");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
expect(await screen.findAllByText("Выплата купона")).toHaveLength(2);
|
expect(await screen.findAllByText("Выплата купона")).toHaveLength(2);
|
||||||
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
|
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
|
||||||
"from=2026-08-01&to=2026-08-31",
|
"from=2026-08-01&to=2026-08-31",
|
||||||
@@ -357,10 +392,12 @@ describe("All accounts routes", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
|
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
|
||||||
expect(
|
await waitFor(() => {
|
||||||
await screen.findByRole("button", {
|
expect(
|
||||||
name: "Повторить",
|
screen.queryByRole("button", {
|
||||||
}),
|
name: "Повторить",
|
||||||
).toBeInTheDocument();
|
}),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { MemoryRouter } from "react-router";
|
import { type InitialEntry, Link, MemoryRouter } from "react-router";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { AuthProvider } from "@/features/auth";
|
import { AuthProvider } from "@/features/auth";
|
||||||
@@ -12,10 +14,11 @@ import { server } from "@/test/server";
|
|||||||
|
|
||||||
import { AppRoutes } from "./AppRoutes";
|
import { AppRoutes } from "./AppRoutes";
|
||||||
|
|
||||||
function renderRoutes(path: string) {
|
function renderRoutes(path: InitialEntry, controls?: ReactNode) {
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={createTestQueryClient()}>
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
{controls}
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
cssVariablesResolver={cssVariablesResolver}
|
cssVariablesResolver={cssVariablesResolver}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
@@ -57,10 +60,20 @@ describe("AppRoutes", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs in through the public form and reaches the protected landing", async () => {
|
it.each([
|
||||||
|
{ endpoint: "login", path: "/accounts", title: "Вход" },
|
||||||
|
{ endpoint: "register", path: "/register", title: "Регистрация" },
|
||||||
|
{
|
||||||
|
endpoint: "login",
|
||||||
|
path: { pathname: "/login", state: { returnTo: "//example.com" } },
|
||||||
|
title: "Вход",
|
||||||
|
},
|
||||||
|
{ endpoint: "login", path: { pathname: "/login", state: { returnTo: 42 } }, title: "Вход" },
|
||||||
|
{ endpoint: "login", path: { pathname: "/login", state: 42 }, title: "Вход" },
|
||||||
|
])("authenticates via $endpoint and reaches the protected landing from $path", async ({ endpoint, path, title }) => {
|
||||||
let credentials: unknown;
|
let credentials: unknown;
|
||||||
server.use(
|
server.use(
|
||||||
http.post("*/api/v1/auth/login", async ({ request }) => {
|
http.post(`*/api/v1/auth/${endpoint}`, async ({ request }) => {
|
||||||
credentials = await request.json();
|
credentials = await request.json();
|
||||||
|
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
@@ -69,16 +82,23 @@ describe("AppRoutes", () => {
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
renderRoutes("/accounts");
|
renderRoutes(path);
|
||||||
|
|
||||||
await screen.findByRole("heading", { level: 1, name: "Вход" });
|
await screen.findByRole("heading", { level: 1, name: title });
|
||||||
fireEvent.change(screen.getByLabelText("Email"), {
|
fireEvent.change(screen.getByLabelText("Email"), {
|
||||||
target: { value: " User@Example.com " },
|
target: { value: " User@Example.com " },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText("Пароль"), {
|
fireEvent.change(screen.getByLabelText("Пароль"), {
|
||||||
target: { value: "a secure password" },
|
target: { value: "a secure password" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Вход" }));
|
|
||||||
|
if (endpoint === "register") {
|
||||||
|
fireEvent.change(screen.getByLabelText("Подтвердите пароль"), {
|
||||||
|
target: { value: "a secure password" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: title }));
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", {
|
await screen.findByRole("heading", {
|
||||||
@@ -93,6 +113,43 @@ describe("AppRoutes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ isFailure: false, isNewDestination: false },
|
||||||
|
{ isFailure: true, isNewDestination: false },
|
||||||
|
{ isFailure: true, isNewDestination: true },
|
||||||
|
])("finishes logout safely: failure=$isFailure, new destination=$isNewDestination", async ({ isFailure, isNewDestination }) => {
|
||||||
|
const { promise: pendingLogout, resolve: finishLogout }
|
||||||
|
= Promise.withResolvers<void>();
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post("*/api/v1/auth/refresh", () => HttpResponse.json({
|
||||||
|
accessToken: "test-token", user: { email: "user@example.com", id: "user-1" },
|
||||||
|
})),
|
||||||
|
http.post("*/api/v1/auth/logout", async () => {
|
||||||
|
await pendingLogout;
|
||||||
|
|
||||||
|
return isFailure
|
||||||
|
? HttpResponse.error()
|
||||||
|
: new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderRoutes("/settings", <Link to="/settings?source=manual">Открыть настройки</Link>);
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Выйти" }));
|
||||||
|
await screen.findByRole("heading", { level: 1, name: "Вход" });
|
||||||
|
if (isNewDestination) fireEvent.click(screen.getByRole("link", { name: "Открыть настройки" }));
|
||||||
|
fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Пароль"), { target: { value: "a secure password" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Вход" }));
|
||||||
|
finishLogout();
|
||||||
|
|
||||||
|
const destination = isNewDestination
|
||||||
|
? { level: 1, name: "Настройки" }
|
||||||
|
: { level: 2, name: "Нет инвестиционных счетов" };
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", destination)).toBeInTheDocument();
|
||||||
|
expect(document.title).toBe(isNewDestination ? "Настройки — WealthLedger" : "Счета — WealthLedger");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns after login to cards with the selected month without T-Bank", async () => {
|
it("returns after login to cards with the selected month without T-Bank", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])),
|
http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])),
|
||||||
|
|||||||
@@ -200,42 +200,6 @@ const AppRoutes: FC = () => (
|
|||||||
path="/accounts"
|
path="/accounts"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
|
||||||
element={(
|
|
||||||
<LazyPage variant="content">
|
|
||||||
<AccountPage accountId="all" />
|
|
||||||
</LazyPage>
|
|
||||||
)}
|
|
||||||
path="/accounts/all"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
element={(
|
|
||||||
<LazyPage variant="content">
|
|
||||||
<AccountOperationsPage accountId="all" />
|
|
||||||
</LazyPage>
|
|
||||||
)}
|
|
||||||
path="/accounts/all/operations"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
element={(
|
|
||||||
<LazyPage variant="content">
|
|
||||||
<AccountIncomePage accountId="all" />
|
|
||||||
</LazyPage>
|
|
||||||
)}
|
|
||||||
path="/accounts/all/income"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
element={(
|
|
||||||
<LazyPage variant="content">
|
|
||||||
<AccountEventsPage accountId="all" />
|
|
||||||
</LazyPage>
|
|
||||||
)}
|
|
||||||
path="/accounts/all/events"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
element={(
|
element={(
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { FC } from "react";
|
|||||||
|
|
||||||
import { useDocumentTitle } from "@mantine/hooks";
|
import { useDocumentTitle } from "@mantine/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMatch } from "react-router";
|
|
||||||
|
|
||||||
|
import { useCurrentRoute } from "@/app/model";
|
||||||
import { useBankCard } from "@/entities/bank-card";
|
import { useBankCard } from "@/entities/bank-card";
|
||||||
import { useBond } from "@/entities/bond";
|
import { useBond } from "@/entities/bond";
|
||||||
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||||
@@ -82,14 +82,18 @@ const CardDocumentTitle: FC<InstrumentDocumentTitleProps> = (props) => {
|
|||||||
|
|
||||||
const ProtectedDocumentTitle: FC = () => {
|
const ProtectedDocumentTitle: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const account = useMatch("/accounts/:accountId/:section?");
|
|
||||||
const bond = useMatch("/bonds/:bondId");
|
const {
|
||||||
const stock = useMatch("/stocks/:stockId");
|
account,
|
||||||
const card = useMatch("/cards/:cardId");
|
bond,
|
||||||
const cards = useMatch("/cards");
|
bonds,
|
||||||
const bonds = useMatch("/bonds");
|
card,
|
||||||
const stocks = useMatch("/stocks");
|
cards,
|
||||||
const settings = useMatch("/settings");
|
settings,
|
||||||
|
stock,
|
||||||
|
stocks,
|
||||||
|
} = useCurrentRoute();
|
||||||
|
|
||||||
if (card) return <CardDocumentTitle identifier={card.params.cardId ?? ""} />;
|
if (card) return <CardDocumentTitle identifier={card.params.cardId ?? ""} />;
|
||||||
if (account)
|
if (account)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -24,15 +24,15 @@ class Boundary extends Component<BoundaryProps, State> {
|
|||||||
return { hasError: true };
|
return { hasError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
state: State = { hasError: false };
|
override state: State = { hasError: false };
|
||||||
|
|
||||||
componentDidUpdate(previousProps: BoundaryProps) {
|
override componentDidUpdate(previousProps: BoundaryProps) {
|
||||||
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
|
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
|
||||||
this.setState({ hasError: false });
|
this.setState({ hasError: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
override render() {
|
||||||
return this.state.hasError
|
return this.state.hasError
|
||||||
? <RouteErrorFallback authenticated={this.props.authenticated} />
|
? <RouteErrorFallback authenticated={this.props.authenticated} />
|
||||||
: (
|
: (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FC, PropsWithChildren } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
|
|
||||||
import { Container, Group, Skeleton, Stack } from "@mantine/core";
|
import { Container, Group, Skeleton, Stack } from "@mantine/core";
|
||||||
|
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -19,11 +20,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
|||||||
if (variant === "auth")
|
if (variant === "auth")
|
||||||
return (
|
return (
|
||||||
<Container py="xl" size={420}>
|
<Container py="xl" size={420}>
|
||||||
<Stack
|
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||||
aria-busy="true"
|
|
||||||
aria-label={t("states.loadingTitle")}
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
<Skeleton height={32} width="45%" />
|
<Skeleton height={32} width="45%" />
|
||||||
|
|
||||||
<Skeleton height={48} radius="sm" />
|
<Skeleton height={48} radius="sm" />
|
||||||
@@ -31,7 +28,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
|||||||
<Skeleton height={48} radius="sm" />
|
<Skeleton height={48} radius="sm" />
|
||||||
|
|
||||||
<Skeleton height={36} radius="sm" />
|
<Skeleton height={36} radius="sm" />
|
||||||
</Stack>
|
</LoadingSkeleton>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -62,11 +59,11 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status">
|
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||||
<Skeleton height={32} width="30%" />
|
<Skeleton height={32} width="30%" />
|
||||||
|
|
||||||
<Skeleton height={160} radius="md" />
|
<Skeleton height={160} radius="md" />
|
||||||
</Stack>
|
</LoadingSkeleton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { FC, PropsWithChildren } from "react";
|
import { type FC, type PropsWithChildren, useState } from "react";
|
||||||
|
|
||||||
import { Navigate, useLocation } from "react-router";
|
import { Navigate, useLocation } from "react-router";
|
||||||
|
|
||||||
import { useAuth } from "@/features/auth";
|
import { useAuth } from "@/features/auth";
|
||||||
@@ -8,16 +7,22 @@ import { RouteSkeleton } from "./RouteFallback";
|
|||||||
|
|
||||||
const Protected: FC<PropsWithChildren> = (props) => {
|
const Protected: FC<PropsWithChildren> = (props) => {
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
const { ready, user } = useAuth();
|
const { ready, signedOut, user } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [wasAuthenticated, setWasAuthenticated] = useState(false);
|
||||||
|
if (user && !wasAuthenticated) setWasAuthenticated(true);
|
||||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
if (!ready) return <RouteSkeleton variant="shell" />;
|
||||||
|
|
||||||
|
const loginState = signedOut && wasAuthenticated
|
||||||
|
? undefined
|
||||||
|
: { returnTo: `${location.pathname}${location.search}` };
|
||||||
|
|
||||||
return user
|
return user
|
||||||
? <>{children}</>
|
? <>{children}</>
|
||||||
: (
|
: (
|
||||||
<Navigate
|
<Navigate
|
||||||
replace
|
replace
|
||||||
state={{ returnTo: `${location.pathname}${location.search}` }}
|
state={loginState}
|
||||||
to="/login"
|
to="/login"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -30,8 +35,11 @@ const Public: FC<PropsWithChildren> = (props) => {
|
|||||||
|
|
||||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
if (!ready) return <RouteSkeleton variant="shell" />;
|
||||||
|
|
||||||
const requested = (location.state as null
|
const state: unknown = location.state;
|
||||||
| { returnTo?: string })?.returnTo;
|
|
||||||
|
const requested = state && typeof state === "object" && "returnTo" in state
|
||||||
|
? state.returnTo
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const returnTo
|
const returnTo
|
||||||
= typeof requested === "string"
|
= typeof requested === "string"
|
||||||
|
|||||||
+8
-1
@@ -2,6 +2,7 @@ import type { FC } from "react";
|
|||||||
|
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { DatesProvider } from "@mantine/dates";
|
import { DatesProvider } from "@mantine/dates";
|
||||||
|
import { Notifications } from "@mantine/notifications";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||||||
import { BrowserRouter } from "react-router";
|
import { BrowserRouter } from "react-router";
|
||||||
@@ -11,7 +12,11 @@ import { AuthProvider } from "@/features/auth";
|
|||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: { refetchOnWindowFocus: false, retry: false },
|
queries: {
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,6 +36,8 @@ const App: FC = () => {
|
|||||||
</RouteErrorBoundary>
|
</RouteErrorBoundary>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|
||||||
|
<Notifications autoClose={4000} limit={2} position="top-right" zIndex={1000} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</DatesProvider>
|
</DatesProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
|
|||||||
@@ -54,8 +54,13 @@ describe("AppBreadcrumbs", () => {
|
|||||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("links to the account hierarchy from operations", () => {
|
it.each([
|
||||||
renderBreadcrumbs("/accounts/account-1/operations", [
|
["operations", "Операции"],
|
||||||
|
["OPERATIONS", "Операции"],
|
||||||
|
["events", "Будущие события"],
|
||||||
|
["income", "Пассивный доход"],
|
||||||
|
])("links to the account hierarchy from %s", (section, title) => {
|
||||||
|
renderBreadcrumbs(`/accounts/account-1/${section}`, [
|
||||||
{ id: "account-1", name: "ИИС" },
|
{ id: "account-1", name: "ИИС" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -66,7 +71,7 @@ describe("AppBreadcrumbs", () => {
|
|||||||
"href",
|
"href",
|
||||||
"/accounts/account-1",
|
"/accounts/account-1",
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Операции")).toHaveAttribute(
|
expect(screen.getByText(title)).toHaveAttribute(
|
||||||
"aria-current",
|
"aria-current",
|
||||||
"page",
|
"page",
|
||||||
);
|
);
|
||||||
@@ -86,7 +91,11 @@ describe("AppBreadcrumbs", () => {
|
|||||||
expect(screen.queryByText("account-1")).not.toBeInTheDocument();
|
expect(screen.queryByText("account-1")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["/login", "/unknown"])("does not render on %s", (path) => {
|
it.each([
|
||||||
|
"/login",
|
||||||
|
"/unknown",
|
||||||
|
"/accounts/account-1/unknown",
|
||||||
|
])("does not render on %s", (path) => {
|
||||||
renderBreadcrumbs(path);
|
renderBreadcrumbs(path);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { FC } from "react";
|
|||||||
|
|
||||||
import { Stack } from "@mantine/core";
|
import { Stack } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMatch } from "react-router";
|
|
||||||
|
import { useCurrentRoute } from "@/app/model";
|
||||||
|
|
||||||
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
||||||
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
|
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
|
||||||
@@ -14,74 +15,34 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
|
|||||||
const AppBreadcrumbs: FC = () => {
|
const AppBreadcrumbs: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const operations = useMatch({
|
const {
|
||||||
end: true,
|
account,
|
||||||
path: "/accounts/:accountId/operations",
|
accounts,
|
||||||
});
|
bond,
|
||||||
|
card,
|
||||||
|
cards,
|
||||||
|
section,
|
||||||
|
settings,
|
||||||
|
stock,
|
||||||
|
} = useCurrentRoute();
|
||||||
|
|
||||||
const events = useMatch({ end: true, path: "/accounts/:accountId/events" });
|
|
||||||
const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
|
|
||||||
const account = useMatch({ end: true, path: "/accounts/:accountId" });
|
|
||||||
const card = useMatch("/cards/:cardId");
|
|
||||||
const cards = useMatch("/cards");
|
|
||||||
const accounts = useMatch({ end: true, path: "/accounts" });
|
|
||||||
const settings = useMatch({ end: true, path: "/settings" });
|
|
||||||
|
|
||||||
const stock = useMatch({ end: true, path: "/stocks/:stockId" });
|
|
||||||
const bond = useMatch({ end: true, path: "/bonds/:bondId" });
|
|
||||||
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
|
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
|
||||||
if (stock)
|
if (stock)
|
||||||
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
|
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
|
||||||
if (operations)
|
if (account && (section || !account.params.section))
|
||||||
return (
|
return (
|
||||||
<Stack gap={0} mb="md">
|
<Stack gap={0} mb="md">
|
||||||
<AccountBreadcrumbs
|
<AccountBreadcrumbs
|
||||||
accountId={operations.params.accountId ?? ""}
|
accountId={account.params.accountId ?? ""}
|
||||||
section="operations"
|
section={section}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AccountScopeSwitch
|
<AccountScopeSwitch
|
||||||
accountId={operations.params.accountId ?? ""}
|
accountId={account.params.accountId ?? ""}
|
||||||
section="operations"
|
section={section}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
if (events)
|
|
||||||
return (
|
|
||||||
<Stack gap={0} mb="md">
|
|
||||||
<AccountBreadcrumbs
|
|
||||||
accountId={events.params.accountId ?? ""}
|
|
||||||
section="events"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AccountScopeSwitch
|
|
||||||
accountId={events.params.accountId ?? ""}
|
|
||||||
section="events"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
if (income)
|
|
||||||
return (
|
|
||||||
<Stack gap={0} mb="md">
|
|
||||||
<AccountBreadcrumbs
|
|
||||||
accountId={income.params.accountId ?? ""}
|
|
||||||
section="income"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AccountScopeSwitch
|
|
||||||
accountId={income.params.accountId ?? ""}
|
|
||||||
section="income"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
if (account)
|
|
||||||
return (
|
|
||||||
<Stack gap={0} mb="md">
|
|
||||||
<AccountBreadcrumbs accountId={account.params.accountId ?? ""} />
|
|
||||||
|
|
||||||
<AccountScopeSwitch accountId={account.params.accountId ?? ""} />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
|
if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
|
||||||
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
||||||
if (accounts)
|
if (accounts)
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ import { apiClient, configureAuthClient } from "@/shared/api";
|
|||||||
import { createQueryWrapper } from "@/test/query";
|
import { createQueryWrapper } from "@/test/query";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
import { useBankMutations } from "./mutations";
|
import type { BankCard, CreateBankOperation } from "./types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCreateBankOperation,
|
||||||
|
useDeleteBankOperation,
|
||||||
|
useUpdateBankCard,
|
||||||
|
useUpdateBankOperation,
|
||||||
|
useUpdateExpenseCategory,
|
||||||
|
} from "./mutations";
|
||||||
import {
|
import {
|
||||||
type BankCard,
|
|
||||||
type CreateBankOperation,
|
|
||||||
useBankCard,
|
useBankCard,
|
||||||
useBankCards,
|
useBankCards,
|
||||||
useBankCategories,
|
useBankCategories,
|
||||||
@@ -100,18 +106,18 @@ function useReadModels() {
|
|||||||
const categories = useBankCategories();
|
const categories = useBankCategories();
|
||||||
|
|
||||||
const history = useBankOperations("a", {
|
const history = useBankOperations("a", {
|
||||||
limit: 20,
|
from: "2026-09-01",
|
||||||
month: "2026-09",
|
limit: 20, page: 2,
|
||||||
page: 2,
|
to: "2026-09-30",
|
||||||
});
|
});
|
||||||
|
|
||||||
const expenses = useBankExpenses("a", { page: 1 });
|
const expenses = useBankExpenses("a", { page: 1 });
|
||||||
const september = useBankSummary("2026-09");
|
const september = useBankSummary({ from: "2026-09-01", to: "2026-09-30" });
|
||||||
const august = useBankSummary("2026-08", "b");
|
const august = useBankSummary({ from: "2026-08-01", to: "2026-08-31" }, "b");
|
||||||
const original = useBankOperation("original");
|
const original = useBankOperation("original");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutations: useBankMutations(),
|
createOperation: useCreateBankOperation(),
|
||||||
reads: [
|
reads: [
|
||||||
cards,
|
cards,
|
||||||
a,
|
a,
|
||||||
@@ -138,9 +144,10 @@ describe("banking API", () => {
|
|||||||
const page = Number(url.searchParams.get("page"));
|
const page = Number(url.searchParams.get("page"));
|
||||||
pages.push(page);
|
pages.push(page);
|
||||||
expect(url.searchParams.get("limit")).toBe("100");
|
expect(url.searchParams.get("limit")).toBe("100");
|
||||||
expect(url.searchParams.get("month")).toBe("2026-09");
|
expect(url.searchParams.get("from")).toBe("2026-09-01");
|
||||||
|
expect(url.searchParams.get("to")).toBe("2026-09-30");
|
||||||
if (isFail && page === 2)
|
if (isFail && page === 2)
|
||||||
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
|
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
|
||||||
|
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
hasNext: page === 1,
|
hasNext: page === 1,
|
||||||
@@ -152,7 +159,7 @@ describe("banking API", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
|
() => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -165,7 +172,7 @@ describe("banking API", () => {
|
|||||||
isFail = true;
|
isFail = true;
|
||||||
|
|
||||||
const { result: failedResult } = renderHook(
|
const { result: failedResult } = renderHook(
|
||||||
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
|
() => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -173,7 +180,7 @@ describe("banking API", () => {
|
|||||||
expect(failedResult.current.data).toBeUndefined();
|
expect(failedResult.current.data).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
|
it("uses session-aware banking endpoints and explicit period/page without T-Bank", async () => {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
server.use(...readHandlers(counts));
|
server.use(...readHandlers(counts));
|
||||||
|
|
||||||
@@ -185,12 +192,15 @@ describe("banking API", () => {
|
|||||||
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
|
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(
|
const urls = Array.from(counts.keys(), path => new URL(path, "http://localhost"));
|
||||||
"/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2",
|
const history = urls.find(url => url.pathname === "/api/v1/bank-cards/a/operations");
|
||||||
);
|
const summary = urls.find(url => url.pathname === "/api/v1/bank-cards/summary" && url.searchParams.get("cardId") === "b");
|
||||||
expect(counts.keys()).toContain(
|
expect(Object.fromEntries(history?.searchParams ?? [])).toEqual({
|
||||||
"/api/v1/bank-cards/summary?cardId=b&month=2026-08",
|
from: "2026-09-01", limit: "20", page: "2", to: "2026-09-30",
|
||||||
);
|
});
|
||||||
|
expect(Object.fromEntries(summary?.searchParams ?? [])).toEqual({
|
||||||
|
cardId: "b", from: "2026-08-01", to: "2026-08-31",
|
||||||
|
});
|
||||||
expect(counts.size).toBe(9);
|
expect(counts.size).toBe(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,11 +227,11 @@ describe("banking API", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(useBankMutations, {
|
const { result } = renderHook(useCreateBankOperation, {
|
||||||
wrapper: createQueryWrapper().wrapper,
|
wrapper: createQueryWrapper().wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(() => result.current.createOperation.mutateAsync(transfer));
|
await act(() => result.current.mutateAsync(transfer));
|
||||||
expect(bodies).toEqual([transfer, transfer]);
|
expect(bodies).toEqual([transfer, transfer]);
|
||||||
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
|
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
|
||||||
expect(refresh).toHaveBeenCalledTimes(1);
|
expect(refresh).toHaveBeenCalledTimes(1);
|
||||||
@@ -239,17 +249,17 @@ describe("banking API", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(useBankMutations, {
|
const { result } = renderHook(useCreateBankOperation, {
|
||||||
wrapper: createQueryWrapper().wrapper,
|
wrapper: createQueryWrapper().wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await expect(
|
await expect(
|
||||||
result.current.createOperation.mutateAsync(transfer),
|
result.current.mutateAsync(transfer),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
expect(bodies).toEqual([transfer]);
|
expect(bodies).toEqual([transfer]);
|
||||||
await act(() => result.current.createOperation.mutateAsync(transfer));
|
await act(() => result.current.mutateAsync(transfer));
|
||||||
expect(bodies).toEqual([transfer, transfer]);
|
expect(bodies).toEqual([transfer, transfer]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -266,20 +276,20 @@ describe("banking API", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => ({ mutations: useBankMutations(), query: useBankCard("a") }),
|
() => ({ createOperation: useCreateBankOperation(), query: useBankCard("a") }),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
|
||||||
let saving: Promise<unknown>;
|
let saving: Promise<unknown>;
|
||||||
act(() => {
|
act(() => {
|
||||||
saving = result.current.mutations.createOperation
|
saving = result.current.createOperation
|
||||||
.mutateAsync(transfer)
|
.mutateAsync(transfer)
|
||||||
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.mutations.createOperation.isPending).toBe(true),
|
expect(result.current.createOperation.isPending).toBe(true),
|
||||||
);
|
);
|
||||||
expect(result.current.query.data?.balance.value).toBe("100");
|
expect(result.current.query.data?.balance.value).toBe("100");
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -289,7 +299,7 @@ describe("banking API", () => {
|
|||||||
expect(result.current.query.data?.balance.value).toBe("100");
|
expect(result.current.query.data?.balance.value).toBe("100");
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/bank-cards/a", () =>
|
http.get("*/api/v1/bank-cards/a", () =>
|
||||||
HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 }),
|
HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await act(() => result.current.query.refetch());
|
await act(() => result.current.query.refetch());
|
||||||
@@ -297,7 +307,7 @@ describe("banking API", () => {
|
|||||||
expect(result.current.query.data?.balance.value).toBe("100");
|
expect(result.current.query.data?.balance.value).toBe("100");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refetches both cards, every month, categories, journal and source expenses after a transfer without invalidating brokerage", async () => {
|
it("refetches both cards, every period, categories, journal and source expenses after a transfer without invalidating brokerage", async () => {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
server.use(
|
server.use(
|
||||||
...readHandlers(counts),
|
...readHandlers(counts),
|
||||||
@@ -313,7 +323,7 @@ describe("banking API", () => {
|
|||||||
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
|
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
|
||||||
);
|
);
|
||||||
await act(() =>
|
await act(() =>
|
||||||
result.current.mutations.createOperation.mutateAsync(transfer),
|
result.current.createOperation.mutateAsync(transfer),
|
||||||
);
|
);
|
||||||
expect(counts.size).toBe(9);
|
expect(counts.size).toBe(9);
|
||||||
for (const count of counts.values()) expect(count).toBe(2);
|
for (const count of counts.values()) expect(count).toBe(2);
|
||||||
@@ -336,14 +346,14 @@ describe("banking API", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => ({ card: useBankCard("a"), mutations: useBankMutations() }),
|
() => ({ card: useBankCard("a"), updateCard: useUpdateBankCard() }),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.card.data?.version).toBe(1));
|
await waitFor(() => expect(result.current.card.data?.version).toBe(1));
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await expect(
|
await expect(
|
||||||
result.current.mutations.updateCard.mutateAsync({
|
result.current.updateCard.mutateAsync({
|
||||||
body: { name: "changed", version: 1 },
|
body: { name: "changed", version: 1 },
|
||||||
id: "a",
|
id: "a",
|
||||||
}),
|
}),
|
||||||
@@ -389,31 +399,33 @@ describe("banking API", () => {
|
|||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => ({
|
() => ({
|
||||||
categories: useBankCategories(),
|
categories: useBankCategories(),
|
||||||
mutations: useBankMutations(),
|
deleteOperation: useDeleteBankOperation(),
|
||||||
|
updateCategory: useUpdateExpenseCategory(),
|
||||||
|
updateOperation: useUpdateBankOperation(),
|
||||||
}),
|
}),
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.categories.data?.[0].name).toBe("Food"),
|
expect(result.current.categories.data?.[0]?.name).toBe("Food"),
|
||||||
);
|
);
|
||||||
await act(() =>
|
await act(() =>
|
||||||
result.current.mutations.updateCategory.mutateAsync({
|
result.current.updateCategory.mutateAsync({
|
||||||
body: { name: "Groceries", version: 1 },
|
body: { name: "Groceries", version: 1 },
|
||||||
id: "food",
|
id: "food",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.categories.data?.[0].name).toBe("Groceries"),
|
expect(result.current.categories.data?.[0]?.name).toBe("Groceries"),
|
||||||
);
|
);
|
||||||
await act(() =>
|
await act(() =>
|
||||||
result.current.mutations.updateOperation.mutateAsync({
|
result.current.updateOperation.mutateAsync({
|
||||||
body: { description: "corrected", version: 1 },
|
body: { description: "corrected", version: 1 },
|
||||||
id: "original",
|
id: "original",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await act(() =>
|
await act(() =>
|
||||||
result.current.mutations.deleteOperation.mutateAsync({
|
result.current.deleteOperation.mutateAsync({
|
||||||
id: "original",
|
id: "original",
|
||||||
version: 2,
|
version: 2,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
UpdateBankCard,
|
UpdateBankCard,
|
||||||
UpdateBankOperation,
|
UpdateBankOperation,
|
||||||
UpdateExpenseCategory,
|
UpdateExpenseCategory,
|
||||||
} from "./queries";
|
} from "./types";
|
||||||
|
|
||||||
async function mutationData<T>(
|
async function mutationData<T>(
|
||||||
request: Promise<{ data?: T; error?: unknown }>,
|
request: Promise<{ data?: T; error?: unknown }>,
|
||||||
@@ -26,7 +26,7 @@ async function mutationData<T>(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useBankMutations() {
|
function useBankMutationOptions() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidate = () =>
|
const invalidate = () =>
|
||||||
@@ -43,31 +43,73 @@ function useBankMutations() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const options = {
|
return {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
const errorCode = apiErrorCode(error);
|
const errorCode = apiErrorCode(error);
|
||||||
if (
|
|
||||||
[
|
return [
|
||||||
ApiErrorCode.BankDependentRefunds,
|
ApiErrorCode.BankDependentRefunds,
|
||||||
ApiErrorCode.BankIntentConflict,
|
ApiErrorCode.BankIntentConflict,
|
||||||
ApiErrorCode.BankRefundLimit,
|
ApiErrorCode.BankRefundLimit,
|
||||||
ApiErrorCode.BankVersionConflict,
|
ApiErrorCode.BankVersionConflict,
|
||||||
ApiErrorCode.BankWriteConflict,
|
ApiErrorCode.BankWriteConflict,
|
||||||
].some(code => isApiErrorCode(errorCode, code))
|
].some(code => isApiErrorCode(errorCode, code))
|
||||||
)
|
? invalidate()
|
||||||
return invalidate();
|
: undefined;
|
||||||
},
|
},
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
retry: false,
|
retry: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
const cardCreation = useMutation({
|
function useCreateBankCard() {
|
||||||
|
const options = useBankMutationOptions();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
...options,
|
...options,
|
||||||
mutationFn: (body: CreateBankCard) =>
|
mutationFn: (body: CreateBankCard) =>
|
||||||
mutationData(apiClient.POST("/api/v1/bank-cards", { body })),
|
mutationData(apiClient.POST("/api/v1/bank-cards", { body })),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const updateCard = useMutation({
|
function useCreateBankOperation() {
|
||||||
|
const options = useBankMutationOptions();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
...options,
|
||||||
|
mutationFn: (body: CreateBankOperation) =>
|
||||||
|
mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCreateExpenseCategory() {
|
||||||
|
const options = useBankMutationOptions();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
...options,
|
||||||
|
mutationFn: (body: CreateExpenseCategory) =>
|
||||||
|
mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDeleteBankOperation() {
|
||||||
|
const options = useBankMutationOptions();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
...options,
|
||||||
|
mutationFn: ({ id, version }: { id: string; version: number }) =>
|
||||||
|
mutationData(
|
||||||
|
apiClient.DELETE("/api/v1/bank-cards/operations/{id}", {
|
||||||
|
params: { path: { id }, query: { version } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useUpdateBankCard() {
|
||||||
|
const options = useBankMutationOptions();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
...options,
|
...options,
|
||||||
mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) =>
|
mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) =>
|
||||||
mutationData(
|
mutationData(
|
||||||
@@ -77,14 +119,12 @@ function useBankMutations() {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const operationCreation = useMutation({
|
function useUpdateBankOperation() {
|
||||||
...options,
|
const options = useBankMutationOptions();
|
||||||
mutationFn: (body: CreateBankOperation) =>
|
|
||||||
mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateOperation = useMutation({
|
return useMutation({
|
||||||
...options,
|
...options,
|
||||||
mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) =>
|
mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) =>
|
||||||
mutationData(
|
mutationData(
|
||||||
@@ -94,24 +134,12 @@ function useBankMutations() {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const operationDeletion = useMutation({
|
function useUpdateExpenseCategory() {
|
||||||
...options,
|
const options = useBankMutationOptions();
|
||||||
mutationFn: ({ id, version }: { id: string; version: number }) =>
|
|
||||||
mutationData(
|
|
||||||
apiClient.DELETE("/api/v1/bank-cards/operations/{id}", {
|
|
||||||
params: { path: { id }, query: { version } },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const categoryCreation = useMutation({
|
return useMutation({
|
||||||
...options,
|
|
||||||
mutationFn: (body: CreateExpenseCategory) =>
|
|
||||||
mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateCategory = useMutation({
|
|
||||||
...options,
|
...options,
|
||||||
mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) =>
|
mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) =>
|
||||||
mutationData(
|
mutationData(
|
||||||
@@ -121,16 +149,14 @@ function useBankMutations() {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
|
||||||
createCard: cardCreation,
|
|
||||||
createCategory: categoryCreation,
|
|
||||||
createOperation: operationCreation,
|
|
||||||
deleteOperation: operationDeletion,
|
|
||||||
updateCard,
|
|
||||||
updateCategory,
|
|
||||||
updateOperation,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useBankMutations };
|
export {
|
||||||
|
useCreateBankCard,
|
||||||
|
useCreateBankOperation,
|
||||||
|
useCreateExpenseCategory,
|
||||||
|
useDeleteBankOperation,
|
||||||
|
useUpdateBankCard,
|
||||||
|
useUpdateBankOperation,
|
||||||
|
useUpdateExpenseCategory,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,44 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
apiClient,
|
apiClient,
|
||||||
apiQueryClient,
|
apiQueryClient,
|
||||||
type paths,
|
|
||||||
useAuthenticatedQuery,
|
useAuthenticatedQuery,
|
||||||
} from "@/shared/api";
|
} from "@/shared/api";
|
||||||
|
|
||||||
import { moneyCents } from "../model/form-validation";
|
import type { BankOperation, BankOperations, BankSummary } from "./types";
|
||||||
|
|
||||||
type BankCard
|
import { moneyCents } from "../model/money";
|
||||||
= 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"];
|
|
||||||
|
|
||||||
const OPERATIONS_PAGE_SIZE = 100;
|
const OPERATIONS_PAGE_SIZE = 100;
|
||||||
|
|
||||||
@@ -110,7 +78,6 @@ function useBankOperations(
|
|||||||
query: {
|
query: {
|
||||||
from?: string;
|
from?: string;
|
||||||
limit?: "all" | number;
|
limit?: "all" | number;
|
||||||
month?: string;
|
|
||||||
page: number;
|
page: number;
|
||||||
to?: string;
|
to?: string;
|
||||||
},
|
},
|
||||||
@@ -134,7 +101,7 @@ function useBankOperations(
|
|||||||
|
|
||||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
|
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
|
||||||
return useAuthenticatedQuery({
|
return useAuthenticatedQuery({
|
||||||
enabled: Boolean(id && (query.month || (query.from && query.to))),
|
enabled: Boolean(id && query.from && query.to),
|
||||||
placeholderData: (
|
placeholderData: (
|
||||||
previousData: BankOperations | undefined,
|
previousData: BankOperations | undefined,
|
||||||
previousQuery,
|
previousQuery,
|
||||||
@@ -143,8 +110,7 @@ function useBankOperations(
|
|||||||
|
|
||||||
return !isAll
|
return !isAll
|
||||||
&& previousQuery?.queryKey.length === options.queryKey.length
|
&& previousQuery?.queryKey.length === options.queryKey.length
|
||||||
&& previous?.path?.id === id
|
&& previous?.path.id === id
|
||||||
&& previous?.query?.month === query.month
|
|
||||||
&& previous?.query?.from === query.from
|
&& previous?.query?.from === query.from
|
||||||
&& previous?.query?.to === query.to
|
&& previous?.query?.to === query.to
|
||||||
&& previous?.query?.limit === query.limit
|
&& previous?.query?.limit === query.limit
|
||||||
@@ -165,6 +131,7 @@ function useBankOperations(
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
|
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- An empty HTTP body can violate the generated response contract.
|
||||||
if (!data) throw new Error("Missing bank operations response");
|
if (!data) throw new Error("Missing bank operations response");
|
||||||
if (!isAll) return data;
|
if (!isAll) return data;
|
||||||
items.push(...data.items);
|
items.push(...data.items);
|
||||||
@@ -178,36 +145,19 @@ function useBankOperations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useBankSummary(
|
function useBankSummary(
|
||||||
period: string
|
period: { from: string; to: string },
|
||||||
| { from: string; to: string },
|
|
||||||
cardId?: string,
|
cardId?: string,
|
||||||
) {
|
) {
|
||||||
const query
|
|
||||||
= typeof period === "string"
|
|
||||||
? { cardId, month: period }
|
|
||||||
: { ...period, cardId };
|
|
||||||
|
|
||||||
return useAuthenticatedQuery({
|
return useAuthenticatedQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
|
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
|
||||||
params: { query },
|
params: { query: { ...period, cardId } },
|
||||||
}),
|
}),
|
||||||
enabled: Boolean(period),
|
|
||||||
select: sortBankSummary,
|
select: sortBankSummary,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type BankCard,
|
|
||||||
type BankOperation,
|
|
||||||
type BankSummary,
|
|
||||||
type CreateBankCard,
|
|
||||||
type CreateBankOperation,
|
|
||||||
type CreateExpenseCategory,
|
|
||||||
type ExpenseCategory,
|
|
||||||
sortBankSummary,
|
sortBankSummary,
|
||||||
type UpdateBankCard,
|
|
||||||
type UpdateBankOperation,
|
|
||||||
type UpdateExpenseCategory,
|
|
||||||
useBankCard,
|
useBankCard,
|
||||||
useBankCards,
|
useBankCards,
|
||||||
useBankCategories,
|
useBankCategories,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { expect, it } from "vitest";
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
import { type BankSummary, sortBankSummary } from "./queries";
|
import type { BankSummary } from "./types";
|
||||||
|
|
||||||
|
import { sortBankSummary } from "./queries";
|
||||||
|
|
||||||
const money = (value: string) => ({ currency: "RUB", value });
|
const money = (value: string) => ({ currency: "RUB", value });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { paths } from "@/shared/api";
|
||||||
|
|
||||||
|
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"];
|
||||||
|
|
||||||
|
export type {
|
||||||
|
BankCard,
|
||||||
|
BankOperation,
|
||||||
|
BankOperations,
|
||||||
|
BankSummary,
|
||||||
|
CreateBankCard,
|
||||||
|
CreateBankOperation,
|
||||||
|
CreateExpenseCategory,
|
||||||
|
ExpenseCategory,
|
||||||
|
UpdateBankCard,
|
||||||
|
UpdateBankOperation,
|
||||||
|
UpdateExpenseCategory,
|
||||||
|
};
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
export { useBankMutations } from "./api/mutations";
|
|
||||||
export {
|
export {
|
||||||
type BankCard,
|
useCreateBankOperation,
|
||||||
type BankOperation,
|
useDeleteBankOperation,
|
||||||
|
useUpdateBankCard,
|
||||||
|
useUpdateBankOperation,
|
||||||
|
} from "./api/mutations";
|
||||||
|
export {
|
||||||
useBankCard,
|
useBankCard,
|
||||||
useBankCards,
|
useBankCards,
|
||||||
useBankCategories,
|
useBankCategories,
|
||||||
@@ -10,14 +13,16 @@ export {
|
|||||||
useBankOperations,
|
useBankOperations,
|
||||||
useBankSummary,
|
useBankSummary,
|
||||||
} from "./api/queries";
|
} from "./api/queries";
|
||||||
export { bankDateInputValue, bankMoneyValue, bankToday, moneyCents } from "./model/form-validation";
|
export type { BankCard, BankOperation } from "./api/types";
|
||||||
|
export { bankToday } from "./model/form-validation";
|
||||||
export { bankDateSchema, positiveMoneySchema } from "./model/form.schema";
|
export { bankDateSchema, positiveMoneySchema } from "./model/form.schema";
|
||||||
|
export { bankMoneyValue, moneyCents } from "./model/money";
|
||||||
export { bankOperationAppearance } from "./model/operation-appearance";
|
export { bankOperationAppearance } from "./model/operation-appearance";
|
||||||
export { bankOperationKinds } from "./model/operation-kinds";
|
export { bankOperationKinds } from "./model/operation-kinds";
|
||||||
export { bankPeriod } from "./model/period";
|
export { bankPeriod } from "./model/period";
|
||||||
export { BankCardFace } from "./ui/BankCardFace/BankCardFace";
|
export { BankCardFace } from "./ui/BankCardFace/BankCardFace";
|
||||||
export { BankCardForm } from "./ui/BankCardForm/BankCardForm";
|
export { BankCardForm } from "./ui/BankCardForm/BankCardForm";
|
||||||
|
export { bankDateField } from "./ui/bankDateField";
|
||||||
export { BankFeedback } from "./ui/BankFeedback";
|
export { BankFeedback } from "./ui/BankFeedback";
|
||||||
export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker";
|
export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker";
|
||||||
export { BankSummaryView } from "./ui/BankSummary/BankSummary.view";
|
export { BankSummaryView } from "./ui/BankSummary/BankSummary.view";
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { validationMessage } from "@/shared/i18n";
|
import { validationMessage } from "@/shared/i18n";
|
||||||
|
|
||||||
import type { ExpenseCategory } from "../api/queries";
|
import type { ExpenseCategory } from "../api/types";
|
||||||
const MAX_CATEGORY_NAME_LENGTH = 80;
|
const MAX_CATEGORY_NAME_LENGTH = 80;
|
||||||
|
|
||||||
|
type CategoryValues = z.infer<ReturnType<typeof categorySchema>>;
|
||||||
|
|
||||||
function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
|
function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
|
||||||
return z.object({
|
return z.object({
|
||||||
name: z
|
name: z
|
||||||
@@ -27,4 +29,4 @@ function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { categorySchema };
|
export { categorySchema, type CategoryValues };
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { bankDateInputValue, bankMoneyValue, bankMonth, bankToday } from "./form-validation";
|
import { bankDateInputValue, bankMonth, bankToday } from "./form-validation";
|
||||||
import { bankCardSchema, bankDateSchema, positiveMoneySchema } from "./form.schema";
|
import {
|
||||||
|
bankCardSchema,
|
||||||
|
bankDateSchema,
|
||||||
|
positiveMoneySchema,
|
||||||
|
} from "./form.schema";
|
||||||
|
import { bankMoneyValue } from "./money";
|
||||||
|
|
||||||
afterEach(() => vi.useRealTimers());
|
afterEach(() => vi.useRealTimers());
|
||||||
describe("bank form input", () => {
|
describe("bank form input", () => {
|
||||||
@@ -51,6 +56,9 @@ describe("bank form input", () => {
|
|||||||
expect(bankDateSchema.safeParse("2026-09-01").success).toBe(true);
|
expect(bankDateSchema.safeParse("2026-09-01").success).toBe(true);
|
||||||
expect(bankDateSchema.safeParse("2026-09-02").success).toBe(false);
|
expect(bankDateSchema.safeParse("2026-09-02").success).toBe(false);
|
||||||
expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false);
|
expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false);
|
||||||
|
expect(bankDateSchema.safeParse("0000-01-01").success).toBe(false);
|
||||||
|
expect(bankDateSchema.safeParse("0001-01-01").success).toBe(true);
|
||||||
|
expect(bankDateSchema.safeParse("0099-12-31").success).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
const DECIMAL_PART_COUNT = 2;
|
import { moscowDate } from "@/shared/lib";
|
||||||
const CENTS_PRECISION = 2;
|
|
||||||
|
|
||||||
const ISO_MONTH_LENGTH = 7;
|
const ISO_MONTH_LENGTH = 7;
|
||||||
|
|
||||||
@@ -7,13 +6,6 @@ function bankDateInputValue(value: string) {
|
|||||||
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
|
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) {
|
function bankMonth(value: null | string) {
|
||||||
return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value)
|
return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value)
|
||||||
? value
|
? value
|
||||||
@@ -21,27 +13,7 @@ function bankMonth(value: null | string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bankToday() {
|
function bankToday() {
|
||||||
return new Intl.DateTimeFormat("en-CA", {
|
return moscowDate(new Date());
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
timeZone: "Europe/Moscow",
|
|
||||||
year: "numeric",
|
|
||||||
}).format(new Date());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function moneyCents(value: string): bigint | null {
|
export { bankDateInputValue, bankMonth, bankToday };
|
||||||
const normalized = value.trim().replace(",", ".");
|
|
||||||
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
const [whole, fraction = ""] = normalized
|
|
||||||
.replace("-", "")
|
|
||||||
.split(".", DECIMAL_PART_COUNT);
|
|
||||||
|
|
||||||
return (
|
|
||||||
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
|
|
||||||
* (normalized.startsWith("-") ? -1n : 1n)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { bankDateInputValue, bankMoneyValue, bankMonth, bankToday, moneyCents };
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { validationMessage } from "@/shared/i18n";
|
import { validationMessage } from "@/shared/i18n";
|
||||||
|
import { calendarDate } from "@/shared/lib";
|
||||||
|
|
||||||
import { bankToday, moneyCents } from "./form-validation";
|
import { bankToday } from "./form-validation";
|
||||||
const ISO_DATE_LENGTH = 10;
|
import { moneyCents } from "./money";
|
||||||
const MAX_CARD_NAME_LENGTH = 100;
|
const MAX_CARD_NAME_LENGTH = 100;
|
||||||
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
|
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
|
||||||
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
|
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
|
||||||
@@ -34,11 +35,8 @@ const bankDateSchema = z
|
|||||||
.string()
|
.string()
|
||||||
.refine(
|
.refine(
|
||||||
value =>
|
value =>
|
||||||
/^\d{4}-\d{2}-\d{2}$/.test(value)
|
calendarDate.safeParse(value).success
|
||||||
&& value >= "0001-01-01"
|
&& 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(),
|
&& value <= bankToday(),
|
||||||
{ error: validationMessage("bank.validation.date") },
|
{ error: validationMessage("bank.validation.date") },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const DECIMAL_PART_COUNT = 2;
|
||||||
|
const CENTS_PRECISION = 2;
|
||||||
|
|
||||||
|
function bankMoneyValue(value: string) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.replace(",", ".")
|
||||||
|
.replace(/^(-?)0+(?=\d)/, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function moneyCents(value: string): bigint | null {
|
||||||
|
const normalized = value.trim().replace(",", ".");
|
||||||
|
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const [whole, fraction = ""] = normalized
|
||||||
|
.replace("-", "")
|
||||||
|
.split(".", DECIMAL_PART_COUNT);
|
||||||
|
|
||||||
|
return (
|
||||||
|
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
|
||||||
|
* (normalized.startsWith("-") ? -1n : 1n)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { bankMoneyValue, moneyCents };
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { bankOperationAppearance } from "./operation-appearance";
|
||||||
|
|
||||||
|
it.each(["Жильё", " ЖИЛЬЕ "])("normalizes category %s", (name) => {
|
||||||
|
expect(bankOperationAppearance("expense", name)).toEqual({
|
||||||
|
color: "orange", variant: "home",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["Другое", "Моя категория", "constructor", null, undefined])(
|
||||||
|
"uses the fallback for category %s",
|
||||||
|
(name) => {
|
||||||
|
expect(bankOperationAppearance("expense", name)).toEqual({
|
||||||
|
color: "gray", variant: "grid",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["topup", "green", "topup"],
|
||||||
|
["transfer", "blue", "transfer"],
|
||||||
|
["refund", "teal", "refund"],
|
||||||
|
] as const)("ignores expense category for %s", (kind, color, variant) => {
|
||||||
|
expect(bankOperationAppearance(kind, "Жильё")).toEqual({ color, variant });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Алкоголь", "wine"],
|
||||||
|
["Вклады", "deposit"],
|
||||||
|
["Жильё", "home"],
|
||||||
|
["Здоровье", "medical"],
|
||||||
|
["Инвестиции и накопления", "growth"],
|
||||||
|
["Кафе", "coffee"],
|
||||||
|
["Образование", "education"],
|
||||||
|
["Переводы", "transfer"],
|
||||||
|
["Погашение кредита", "repayment"],
|
||||||
|
["Подписки", "subscriptions"],
|
||||||
|
["Покупки", "shopping"],
|
||||||
|
["Продукты", "basket"],
|
||||||
|
["Путешествия", "travel"],
|
||||||
|
["Развлечения", "music"],
|
||||||
|
["Связь и интернет", "wifi"],
|
||||||
|
["Снятие наличных", "cashWithdrawal"],
|
||||||
|
["Ставки", "dice"],
|
||||||
|
["Страхование", "shield"],
|
||||||
|
["Табак", "smoking"],
|
||||||
|
["Такси", "taxi"],
|
||||||
|
["Транспорт", "car"],
|
||||||
|
["Хостинг и ИИ-сервисы", "server"],
|
||||||
|
["Цифровые покупки", "download"],
|
||||||
|
])("maps category %s to its icon", (name, variant) => {
|
||||||
|
expect(bankOperationAppearance("expense", name).variant).toBe(variant);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { OperationIconProps } from "@wealthledger/icons";
|
import type { OperationIconProps } from "@wealthledger/icons";
|
||||||
|
|
||||||
import type { BankOperation } from "../api/queries";
|
import type { BankOperation } from "../api/types";
|
||||||
|
|
||||||
const categoryIcons: {
|
const categoryIcons: {
|
||||||
appearance: OperationIconProps;
|
appearance: OperationIconProps;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { BankOperation } from "../api/queries";
|
import type { BankOperation } from "../api/types";
|
||||||
|
|
||||||
const bankOperationKinds = [
|
const bankOperationKinds = [
|
||||||
"expense",
|
"expense",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
|
|
||||||
import type { BankCard } from "../../api/queries";
|
import type { BankCard } from "../../api/types";
|
||||||
|
|
||||||
import classes from "./BankCardFace.module.css";
|
import classes from "./BankCardFace.module.css";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useFormContext } from "react-hook-form";
|
import { useController, useFormContext } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { BankCardValues } from "../../model/form.schema";
|
import type { BankCardValues } from "../../model/form.schema";
|
||||||
|
|
||||||
import { useOpeningDateField } from "./useOpeningDateField";
|
import { bankDateField } from "../bankDateField";
|
||||||
import { usePaymentSystemField } from "./usePaymentSystemField";
|
|
||||||
|
|
||||||
function useBankCardFieldsVm() {
|
function useBankCardFieldsVm() {
|
||||||
const {
|
const {
|
||||||
@@ -14,10 +13,26 @@ function useBankCardFieldsVm() {
|
|||||||
} = useFormContext<BankCardValues>();
|
} = useFormContext<BankCardValues>();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const openingDate = useOpeningDateField(control);
|
const { field: openingDateField } = useController({ control, name: "openingDate" });
|
||||||
const paymentSystem = usePaymentSystemField(control);
|
const { field: paymentSystemField } = useController({ control, name: "paymentSystem" });
|
||||||
|
|
||||||
return { ...openingDate, ...paymentSystem, errors, register, t };
|
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
|
||||||
|
label: t(`bank.paymentSystems.${value}`),
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const onPaymentSystemChange = (value: null | string) =>
|
||||||
|
paymentSystemField.onChange(value ?? "");
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
onPaymentSystemChange,
|
||||||
|
openingDate: { ...openingDateField, ...bankDateField(openingDateField) },
|
||||||
|
paymentSystemField,
|
||||||
|
paymentSystems,
|
||||||
|
register,
|
||||||
|
t,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useBankCardFieldsVm };
|
export { useBankCardFieldsVm };
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { configureAuthClient } from "@/shared/api";
|
|||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
import type { BankCard } from "../../api/queries";
|
import type { BankCard } from "../../api/types";
|
||||||
|
|
||||||
import { BankCardForm } from "./BankCardForm";
|
import { BankCardForm } from "./BankCardForm";
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
|
import { Button, Fieldset, Group, Modal, Stack, Text } from "@mantine/core";
|
||||||
import { type FC } from "react";
|
import { type FC } from "react";
|
||||||
import { FormProvider } from "react-hook-form";
|
import { FormProvider } from "react-hook-form";
|
||||||
|
|
||||||
import { formatDate } from "@/shared/lib";
|
import { formatDate } from "@/shared/lib";
|
||||||
|
|
||||||
import { type BankCard } from "../../api/queries";
|
import type { BankCard } from "../../api/types";
|
||||||
|
|
||||||
import { BankFeedback } from "../BankFeedback";
|
import { BankFeedback } from "../BankFeedback";
|
||||||
import classes from "../BankForms.module.css";
|
|
||||||
import { BankCardFields } from "./BankCardForm.view";
|
import { BankCardFields } from "./BankCardForm.view";
|
||||||
import { useBankCardFormVm } from "./BankCardForm.vm";
|
import { useBankCardFormVm } from "./BankCardForm.vm";
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<fieldset className={classes.fieldset} disabled={isPending}>
|
<Fieldset disabled={isPending} variant="unstyled">
|
||||||
<Stack>
|
<Stack>
|
||||||
<BankCardFields archived={Boolean(props.card?.archivedAt)} />
|
<BankCardFields archived={Boolean(props.card?.archivedAt)} />
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</fieldset>
|
</Fieldset>
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -10,14 +10,9 @@ type BankCardFieldsProps = { archived: boolean };
|
|||||||
|
|
||||||
const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
||||||
const {
|
const {
|
||||||
dateParser,
|
|
||||||
errors,
|
errors,
|
||||||
onOpeningDateBlur,
|
|
||||||
onOpeningDateChange,
|
|
||||||
onOpeningDateInput,
|
|
||||||
onPaymentSystemChange,
|
onPaymentSystemChange,
|
||||||
openingDateField,
|
openingDate,
|
||||||
openingDateValue,
|
|
||||||
paymentSystemField,
|
paymentSystemField,
|
||||||
paymentSystems,
|
paymentSystems,
|
||||||
register,
|
register,
|
||||||
@@ -71,22 +66,17 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DateInput
|
<DateInput
|
||||||
{...openingDateField}
|
{...openingDate}
|
||||||
dateParser={dateParser}
|
|
||||||
error={errors.openingDate?.message}
|
error={errors.openingDate?.message}
|
||||||
fixOnBlur={false}
|
fixOnBlur={false}
|
||||||
label={t("bank.openingDate")}
|
label={t("bank.openingDate")}
|
||||||
maxDate={bankToday()}
|
maxDate={bankToday()}
|
||||||
onBlur={onOpeningDateBlur}
|
|
||||||
onChange={onOpeningDateChange}
|
|
||||||
onInput={onOpeningDateInput}
|
|
||||||
popoverProps={{
|
popoverProps={{
|
||||||
middlewares: { shift: { crossAxis: true } },
|
middlewares: { shift: { crossAxis: true } },
|
||||||
withinPortal: false,
|
withinPortal: false,
|
||||||
}}
|
}}
|
||||||
readOnly={props.archived}
|
readOnly={props.archived}
|
||||||
required
|
required
|
||||||
value={openingDateValue}
|
|
||||||
valueFormat="DD.MM.YYYY"
|
valueFormat="DD.MM.YYYY"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useBankMutations } from "../../api/mutations";
|
import type { BankCard } from "../../api/types";
|
||||||
import { type BankCard, useBankCard } from "../../api/queries";
|
|
||||||
import { bankMoneyValue, bankToday } from "../../model/form-validation";
|
import { useCreateBankCard, useUpdateBankCard } from "../../api/mutations";
|
||||||
|
import { useBankCard } from "../../api/queries";
|
||||||
|
import { bankToday } from "../../model/form-validation";
|
||||||
import { bankCardSchema, type BankCardValues } from "../../model/form.schema";
|
import { bankCardSchema, type BankCardValues } from "../../model/form.schema";
|
||||||
|
import { bankMoneyValue } from "../../model/money";
|
||||||
import { useBankFormFocus } from "../useBankFormFocus";
|
import { useBankFormFocus } from "../useBankFormFocus";
|
||||||
|
|
||||||
type BankCardFormProps = { card?: BankCard; onClose: () => void };
|
type BankCardFormProps = { card?: BankCard; onClose: () => void };
|
||||||
@@ -20,7 +24,8 @@ function useBankCardFormVm(props: BankCardFormProps) {
|
|||||||
const [error, setError] = useState<unknown>();
|
const [error, setError] = useState<unknown>();
|
||||||
const [latest, setLatest] = useState<BankCard>();
|
const [latest, setLatest] = useState<BankCard>();
|
||||||
const fresh = useBankCard(props.card?.id);
|
const fresh = useBankCard(props.card?.id);
|
||||||
const mutation = useBankMutations();
|
const cardCreation = useCreateBankCard();
|
||||||
|
const updateCard = useUpdateBankCard();
|
||||||
|
|
||||||
const methods = useForm<BankCardValues>({
|
const methods = useForm<BankCardValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -67,8 +72,8 @@ function useBankCardFormVm(props: BankCardFormProps) {
|
|||||||
openingDate: values.openingDate,
|
openingDate: values.openingDate,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (props.card)
|
if (props.card) {
|
||||||
await mutation.updateCard.mutateAsync({
|
await updateCard.mutateAsync({
|
||||||
body: {
|
body: {
|
||||||
...metadata,
|
...metadata,
|
||||||
...(!props.card.archivedAt && financial),
|
...(!props.card.archivedAt && financial),
|
||||||
@@ -76,12 +81,19 @@ function useBankCardFormVm(props: BankCardFormProps) {
|
|||||||
},
|
},
|
||||||
id: props.card.id,
|
id: props.card.id,
|
||||||
});
|
});
|
||||||
else
|
}
|
||||||
await mutation.createCard.mutateAsync({
|
else {
|
||||||
|
await cardCreation.mutateAsync({
|
||||||
id: intent,
|
id: intent,
|
||||||
...metadata,
|
...metadata,
|
||||||
...financial,
|
...financial,
|
||||||
});
|
});
|
||||||
|
notifications.show({
|
||||||
|
id: "bank-card-created-" + intent,
|
||||||
|
message: t("bank.cardSaved"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setOpened(false);
|
setOpened(false);
|
||||||
}
|
}
|
||||||
catch (error_) {
|
catch (error_) {
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { ComponentProps } from "react";
|
|
||||||
import type { Control } from "react-hook-form";
|
|
||||||
|
|
||||||
import { DateInput } from "@mantine/dates";
|
|
||||||
import { useController } from "react-hook-form";
|
|
||||||
|
|
||||||
import { bankDateInputValue } from "../../model/form-validation";
|
|
||||||
import { type BankCardValues, bankDateSchema } from "../../model/form.schema";
|
|
||||||
|
|
||||||
function useOpeningDateField(control: Control<BankCardValues>) {
|
|
||||||
const { field: openingDateField } = useController({
|
|
||||||
control,
|
|
||||||
name: "openingDate",
|
|
||||||
});
|
|
||||||
|
|
||||||
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
|
|
||||||
bankDateSchema.safeParse(bankDateInputValue(value)).success
|
|
||||||
? bankDateInputValue(value)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const onOpeningDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (
|
|
||||||
event,
|
|
||||||
) => {
|
|
||||||
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
|
|
||||||
openingDateField.onBlur();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onOpeningDateChange = (value: null | string) =>
|
|
||||||
openingDateField.onChange(value ?? "");
|
|
||||||
|
|
||||||
const onOpeningDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
|
|
||||||
openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
|
|
||||||
|
|
||||||
const openingDateValue = bankDateSchema.safeParse(openingDateField.value)
|
|
||||||
.success
|
|
||||||
? openingDateField.value
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
dateParser,
|
|
||||||
onOpeningDateBlur,
|
|
||||||
onOpeningDateChange,
|
|
||||||
onOpeningDateInput,
|
|
||||||
openingDateField,
|
|
||||||
openingDateValue,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useOpeningDateField };
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { Control } from "react-hook-form";
|
|
||||||
|
|
||||||
import { useController } from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import type { BankCardValues } from "../../model/form.schema";
|
|
||||||
|
|
||||||
function usePaymentSystemField(control: Control<BankCardValues>) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const { field: paymentSystemField } = useController({
|
|
||||||
control,
|
|
||||||
name: "paymentSystem",
|
|
||||||
});
|
|
||||||
|
|
||||||
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
|
|
||||||
label: t(`bank.paymentSystems.${value}`),
|
|
||||||
value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const onPaymentSystemChange = (value: null | string) =>
|
|
||||||
paymentSystemField.onChange(value ?? "");
|
|
||||||
|
|
||||||
return { onPaymentSystemChange, paymentSystemField, paymentSystems };
|
|
||||||
}
|
|
||||||
|
|
||||||
export { usePaymentSystemField };
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
.fieldset {
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { render } from "@testing-library/react";
|
|
||||||
import { OperationIcon } from "@wealthledger/icons";
|
|
||||||
import { expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { bankOperationAppearance } from "../../model/operation-appearance";
|
|
||||||
|
|
||||||
const BankOperationIcon = (props: {
|
|
||||||
categoryName?: null | string;
|
|
||||||
kind: "expense" | "refund" | "topup" | "transfer";
|
|
||||||
}) => (
|
|
||||||
<OperationIcon
|
|
||||||
{...bankOperationAppearance(props.kind, props.categoryName)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
it("matches normalized category names, falls back after renaming and distinguishes operation kinds", () => {
|
|
||||||
const { container, rerender } = render(
|
|
||||||
<BankOperationIcon categoryName="Жильё" kind="expense" />,
|
|
||||||
);
|
|
||||||
|
|
||||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
|
||||||
const path = () => container.querySelector("path")?.getAttribute("d");
|
|
||||||
const housing = path();
|
|
||||||
rerender(<BankOperationIcon categoryName=" ЖИЛЬЕ " kind="expense" />);
|
|
||||||
expect(path()).toBe(housing);
|
|
||||||
rerender(<BankOperationIcon categoryName="Другое" kind="expense" />);
|
|
||||||
const fallback = path();
|
|
||||||
expect(fallback).not.toBe(housing);
|
|
||||||
|
|
||||||
for (const categoryName of [
|
|
||||||
"Моя категория",
|
|
||||||
"constructor",
|
|
||||||
null,
|
|
||||||
]) {
|
|
||||||
rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
|
|
||||||
expect(path()).toBe(fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
const paths = [fallback, housing];
|
|
||||||
|
|
||||||
for (const kind of ["topup", "transfer", "refund"] as const) {
|
|
||||||
rerender(<BankOperationIcon categoryName="Жильё" kind={kind} />);
|
|
||||||
expect(path()).toBeTruthy();
|
|
||||||
paths.push(path());
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(new Set(paths).size).toBe(5);
|
|
||||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
|
||||||
expect(container.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("provides a distinct icon for every existing named category", () => {
|
|
||||||
const { container, rerender } = render(
|
|
||||||
<BankOperationIcon categoryName="Другое" kind="expense" />,
|
|
||||||
);
|
|
||||||
|
|
||||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
|
||||||
const paths = new Set([container.querySelector("path")?.getAttribute("d")]);
|
|
||||||
|
|
||||||
for (const categoryName of [
|
|
||||||
"Алкоголь",
|
|
||||||
"Вклады",
|
|
||||||
"Жильё",
|
|
||||||
"Здоровье",
|
|
||||||
"Инвестиции и накопления",
|
|
||||||
"Кафе",
|
|
||||||
"Образование",
|
|
||||||
"Переводы",
|
|
||||||
"Погашение кредита",
|
|
||||||
"Подписки",
|
|
||||||
"Покупки",
|
|
||||||
"Продукты",
|
|
||||||
"Путешествия",
|
|
||||||
"Развлечения",
|
|
||||||
"Связь и интернет",
|
|
||||||
"Снятие наличных",
|
|
||||||
"Ставки",
|
|
||||||
"Страхование",
|
|
||||||
"Табак",
|
|
||||||
"Такси",
|
|
||||||
"Транспорт",
|
|
||||||
"Хостинг и ИИ-сервисы",
|
|
||||||
"Цифровые покупки",
|
|
||||||
]) {
|
|
||||||
rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
|
|
||||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
|
||||||
const path = container.querySelector("path")?.getAttribute("d");
|
|
||||||
expect(path).toBeTruthy();
|
|
||||||
expect(paths.has(path), categoryName).toBe(false);
|
|
||||||
paths.add(path);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -24,7 +24,7 @@ function useBankPeriodPickerVm(props: BankPeriodPickerProps) {
|
|||||||
onChange(next);
|
onChange(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { onChange, onPeriodChange, t, value };
|
return { onPeriodChange, t, value };
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useBankPeriodPickerVm };
|
export { useBankPeriodPickerVm };
|
||||||
|
|||||||
@@ -15,6 +15,28 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.totals {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--mantine-spacing-xs) var(--mantine-spacing-lg);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals dt {
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals dd {
|
||||||
|
margin: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.categories {
|
.categories {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ const meta
|
|||||||
error: false,
|
error: false,
|
||||||
fetching: false,
|
fetching: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
retry: () => {},
|
|
||||||
},
|
},
|
||||||
component: BankSummaryView,
|
component: BankSummaryView,
|
||||||
parameters: {
|
parameters: {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { BankSummaryView } from "./BankSummary.view";
|
||||||
|
|
||||||
|
const money = (value: string) => ({ currency: "RUB", value });
|
||||||
|
|
||||||
|
describe("BankSummaryView", () => {
|
||||||
|
it("shows expenses, refunds, and net monthly totals", () => {
|
||||||
|
render(
|
||||||
|
<MantineProvider env="test">
|
||||||
|
<BankSummaryView
|
||||||
|
data={{
|
||||||
|
cardId: "card-1",
|
||||||
|
categories: [],
|
||||||
|
expenses: money("1000"),
|
||||||
|
net: money("800"),
|
||||||
|
refunds: money("200"),
|
||||||
|
}}
|
||||||
|
error={false}
|
||||||
|
fetching={false}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getAllByText("Расходы")).toHaveLength(2);
|
||||||
|
expect(screen.getByText("Возвращено")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("После возвратов")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1 000,00 ₽")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("200,00 ₽")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("800,00 ₽")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { CSSProperties, FC } from "react";
|
import type { CSSProperties, FC } from "react";
|
||||||
|
|
||||||
import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core";
|
import { Skeleton, Stack, Text, Title } from "@mantine/core";
|
||||||
import { OperationIcon } from "@wealthledger/icons";
|
import { OperationIcon } from "@wealthledger/icons";
|
||||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
|
|
||||||
import type { BankSummary } from "../../api/queries";
|
import type { BankSummary } from "../../api/types";
|
||||||
|
|
||||||
import { bankOperationAppearance } from "../../model/operation-appearance";
|
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||||
import classes from "./BankSummary.module.css";
|
import classes from "./BankSummary.module.css";
|
||||||
@@ -20,7 +20,6 @@ type BankSummaryViewProps = {
|
|||||||
error: boolean;
|
error: boolean;
|
||||||
fetching: boolean;
|
fetching: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
retry: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
|
const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
|
||||||
@@ -78,6 +77,8 @@ const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
|
|||||||
</LoadingSkeleton>
|
</LoadingSkeleton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
||||||
@@ -91,25 +92,37 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
|||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
{props.data && (
|
{props.data && (
|
||||||
<Text className={classes.total}>
|
<dl className={classes.totals}>
|
||||||
{formatMoney(props.data.net, { withZeroCents: true })}
|
<div>
|
||||||
</Text>
|
<dt>{t("bank.summary")}</dt>
|
||||||
|
|
||||||
|
<dd>
|
||||||
|
{formatMoney(props.data.expenses, { withZeroCents: true })}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt>{t("bank.refunds")}</dt>
|
||||||
|
|
||||||
|
<dd>
|
||||||
|
{formatMoney(props.data.refunds, { withZeroCents: true })}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt>{t("bank.net")}</dt>
|
||||||
|
|
||||||
|
<dd className={classes.total}>
|
||||||
|
{formatMoney(props.data.net, { withZeroCents: true })}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BankSummaryViewCategories {...props} />
|
<BankSummaryViewCategories {...props} />
|
||||||
|
|
||||||
{props.error && (
|
{props.error && !props.data && <Text role="alert">{t("bank.error")}</Text>}
|
||||||
<Group role="alert">
|
|
||||||
<Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text>
|
|
||||||
|
|
||||||
<Button onClick={props.retry} variant="default">
|
|
||||||
{t("bank.retry")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{props.data && props.fetching && <Text role="status">{t("bank.refreshing")}</Text>}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
waitFor,
|
waitFor,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { describe, expect, it } from "vitest";
|
import { assert, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
@@ -78,15 +78,16 @@ describe("CategoryManager", () => {
|
|||||||
name: "Переименовать",
|
name: "Переименовать",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
assert.isDefined(renameButtons[0]);
|
||||||
fireEvent.click(renameButtons[0]);
|
fireEvent.click(renameButtons[0]);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
await screen.findByRole("button", { name: "Перечитать запись" }),
|
await screen.findByRole("button", { name: "Перечитать запись" }),
|
||||||
);
|
);
|
||||||
await waitFor(() => expect(reads).toBe(3));
|
await waitFor(() => expect(reads).toBe(3));
|
||||||
fireEvent.click(
|
const secondRename = screen.getAllByRole("button", { name: "Переименовать" })[1];
|
||||||
screen.getAllByRole("button", { name: "Переименовать" })[1],
|
assert.isDefined(secondRename);
|
||||||
);
|
fireEvent.click(secondRename);
|
||||||
fireEvent.change(screen.getByLabelText(/Название категории/), {
|
fireEvent.change(screen.getByLabelText(/Название категории/), {
|
||||||
target: { value: "Новая вторая" },
|
target: { value: "Новая вторая" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import { Badge,
|
||||||
Badge,
|
|
||||||
Button,
|
Button,
|
||||||
|
Fieldset,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -12,17 +12,14 @@ import { type FC } from "react";
|
|||||||
import { FormProvider, useController, useFormContext } from "react-hook-form";
|
import { FormProvider, useController, useFormContext } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { CategoryValues } from "../../model/category.schema";
|
||||||
|
|
||||||
import { bankOperationAppearance } from "../../model/operation-appearance";
|
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||||
import { BankFeedback } from "../BankFeedback";
|
import { BankFeedback } from "../BankFeedback";
|
||||||
import classes from "../BankForms.module.css";
|
import {
|
||||||
import { useCategoryManagerVm } from "./CategoryManager.vm";
|
type CategoryManagerProps,
|
||||||
|
useCategoryManagerVm,
|
||||||
type CategoryManagerProps = {
|
} from "./CategoryManager.vm";
|
||||||
onClose: () => void;
|
|
||||||
onCreated?: (id: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CategoryValues = { name: string };
|
|
||||||
|
|
||||||
const CategoryFields: FC = () => {
|
const CategoryFields: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -76,7 +73,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<fieldset className={classes.fieldset} disabled={isPending}>
|
<Fieldset disabled={isPending} variant="unstyled">
|
||||||
<Stack>
|
<Stack>
|
||||||
{categoryRows?.map((category) => {
|
{categoryRows?.map((category) => {
|
||||||
return (
|
return (
|
||||||
@@ -149,7 +146,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</fieldset>
|
</Fieldset>
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ import { useRef, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useBankMutations } from "../../api/mutations";
|
import type { ExpenseCategory } from "../../api/types";
|
||||||
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
|
|
||||||
import { categorySchema } from "../../model/category.schema";
|
import {
|
||||||
|
useCreateExpenseCategory,
|
||||||
|
useUpdateExpenseCategory,
|
||||||
|
} from "../../api/mutations";
|
||||||
|
import { useBankCategories } from "../../api/queries";
|
||||||
|
import { categorySchema, type CategoryValues } from "../../model/category.schema";
|
||||||
import { useBankFormFocus } from "../useBankFormFocus";
|
import { useBankFormFocus } from "../useBankFormFocus";
|
||||||
|
|
||||||
type CategoryManagerProps = {
|
type CategoryManagerProps = {
|
||||||
@@ -15,14 +20,13 @@ type CategoryManagerProps = {
|
|||||||
onCreated?: (id: string) => void;
|
onCreated?: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CategoryValues = { name: string };
|
|
||||||
|
|
||||||
function useCategoryManagerVm(props: CategoryManagerProps) {
|
function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||||
useBankFormFocus();
|
useBankFormFocus();
|
||||||
const [opened, setOpened] = useState(true);
|
const [opened, setOpened] = useState(true);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const categories = useBankCategories();
|
const categories = useBankCategories();
|
||||||
const mutation = useBankMutations();
|
const categoryCreation = useCreateExpenseCategory();
|
||||||
|
const updateCategory = useUpdateExpenseCategory();
|
||||||
const [editing, setEditing] = useState<ExpenseCategory>();
|
const [editing, setEditing] = useState<ExpenseCategory>();
|
||||||
const editSessionRef = useRef(0);
|
const editSessionRef = useRef(0);
|
||||||
const [error, setError] = useState<unknown>();
|
const [error, setError] = useState<unknown>();
|
||||||
@@ -37,28 +41,32 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
|||||||
const isPending = !opened || methods.formState.isSubmitting;
|
const isPending = !opened || methods.formState.isSubmitting;
|
||||||
const onRequestClose = () => !isPending && setOpened(false);
|
const onRequestClose = () => !isPending && setOpened(false);
|
||||||
|
|
||||||
|
const finishEditing = () => {
|
||||||
|
editSessionRef.current++;
|
||||||
|
setEditing(undefined);
|
||||||
|
setLatest(undefined);
|
||||||
|
methods.reset({ name: "" });
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
|
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
|
||||||
void methods.handleSubmit(async (values) => {
|
void methods.handleSubmit(async (values) => {
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editing)
|
if (editing)
|
||||||
await mutation.updateCategory.mutateAsync({
|
await updateCategory.mutateAsync({
|
||||||
body: { name: values.name, version: editing.version },
|
body: { name: values.name, version: editing.version },
|
||||||
id: editing.id,
|
id: editing.id,
|
||||||
});
|
});
|
||||||
else {
|
else {
|
||||||
const result = await mutation.createCategory.mutateAsync({
|
const result = await categoryCreation.mutateAsync({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) props.onCreated?.(result.id);
|
if (result) props.onCreated?.(result.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
editSessionRef.current++;
|
finishEditing();
|
||||||
setEditing(undefined);
|
|
||||||
setLatest(undefined);
|
|
||||||
methods.reset({ name: "" });
|
|
||||||
}
|
}
|
||||||
catch (error_) {
|
catch (error_) {
|
||||||
setError(error_);
|
setError(error_);
|
||||||
@@ -84,10 +92,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
editSessionRef.current++;
|
finishEditing();
|
||||||
setEditing(undefined);
|
|
||||||
setLatest(undefined);
|
|
||||||
methods.reset({ name: "" });
|
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,4 +130,4 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useCategoryManagerVm };
|
export { type CategoryManagerProps, useCategoryManagerVm };
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { DateInput } from "@mantine/dates";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
import { bankDateInputValue } from "../model/form-validation";
|
||||||
|
import { bankDateSchema } from "../model/form.schema";
|
||||||
|
|
||||||
|
type DateField = {
|
||||||
|
onBlur: () => void;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function bankDateField(field: DateField) {
|
||||||
|
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = (value) => {
|
||||||
|
const parsed = bankDateInputValue(value);
|
||||||
|
|
||||||
|
return bankDateSchema.safeParse(parsed).success ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlur: ComponentProps<typeof DateInput>["onBlur"] = (event) => {
|
||||||
|
field.onChange(bankDateInputValue(event.currentTarget.value));
|
||||||
|
field.onBlur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInput: ComponentProps<typeof DateInput>["onInput"] = event =>
|
||||||
|
field.onChange(bankDateInputValue(event.currentTarget.value));
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateParser,
|
||||||
|
onBlur,
|
||||||
|
onChange: (value: null | string) => field.onChange(value ?? ""),
|
||||||
|
onInput,
|
||||||
|
value: bankDateSchema.safeParse(field.value).success
|
||||||
|
? field.value
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { bankDateField };
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
||||||
|
|
||||||
|
type BondCandles = NonNullable<paths["/api/v1/bonds/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||||
|
|
||||||
|
type BondPosition = NonNullable<paths["/api/v1/bonds/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||||
|
|
||||||
const MIN_BOND_SEARCH_LENGTH = 2;
|
const MIN_BOND_SEARCH_LENGTH = 2;
|
||||||
|
|
||||||
|
type BondCoupons = NonNullable<paths["/api/v1/bonds/{uid}/coupons"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||||
|
|
||||||
|
type BondMetrics = NonNullable<paths["/api/v1/bonds/{uid}/metrics"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||||
|
|
||||||
type BondPeriod = NonNullable<
|
type BondPeriod = NonNullable<
|
||||||
NonNullable<
|
NonNullable<
|
||||||
paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"]
|
paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"]
|
||||||
@@ -10,35 +18,28 @@ type BondPeriod = NonNullable<
|
|||||||
|
|
||||||
const BOND_STALE_TIME_MS = 30_000;
|
const BOND_STALE_TIME_MS = 30_000;
|
||||||
|
|
||||||
function bondQueryOptions(uid: string) {
|
|
||||||
return {
|
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}", {
|
|
||||||
params: { path: { uid } },
|
|
||||||
}),
|
|
||||||
staleTime: BOND_STALE_TIME_MS,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useBond(identifier: string) {
|
function useBond(identifier: string) {
|
||||||
const isUid
|
const isUid
|
||||||
= /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
= /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
identifier,
|
identifier,
|
||||||
);
|
);
|
||||||
|
|
||||||
const byUid = useResourceQuery({
|
// eslint-disable-next-line unicorn/prefer-minimal-ternary -- Hoisting params breaks the generated union of endpoint request shapes.
|
||||||
...bondQueryOptions(identifier),
|
const init = isUid
|
||||||
enabled: Boolean(identifier) && isUid,
|
? { params: { path: { uid: identifier } } }
|
||||||
});
|
: { params: { path: { ticker: identifier } } };
|
||||||
|
|
||||||
const byTicker = useResourceQuery({
|
const options = apiQueryClient.queryOptions(
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", {
|
"get",
|
||||||
params: { path: { ticker: identifier } },
|
isUid ? "/api/v1/bonds/{uid}" : "/api/v1/bonds/by-ticker/{ticker}",
|
||||||
}),
|
init,
|
||||||
enabled: Boolean(identifier) && !isUid,
|
);
|
||||||
|
|
||||||
|
return useResourceQuery({
|
||||||
|
...options,
|
||||||
|
enabled: Boolean(identifier),
|
||||||
staleTime: BOND_STALE_TIME_MS,
|
staleTime: BOND_STALE_TIME_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
return isUid ? byUid : byTicker;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useBondCandles(uid: string, period: BondPeriod) {
|
function useBondCandles(uid: string, period: BondPeriod) {
|
||||||
@@ -104,7 +105,10 @@ function useBondSearch(query: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type BondPeriod,
|
type BondCandles,
|
||||||
|
type BondCoupons,
|
||||||
|
type BondMetrics,
|
||||||
|
type BondPosition,
|
||||||
MIN_BOND_SEARCH_LENGTH,
|
MIN_BOND_SEARCH_LENGTH,
|
||||||
useBond,
|
useBond,
|
||||||
useBondCandles,
|
useBondCandles,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
export {
|
export {
|
||||||
type BondPeriod,
|
type BondCandles,
|
||||||
|
type BondCoupons,
|
||||||
|
type BondMetrics,
|
||||||
|
type BondPosition,
|
||||||
MIN_BOND_SEARCH_LENGTH,
|
MIN_BOND_SEARCH_LENGTH,
|
||||||
useBond,
|
useBond,
|
||||||
useBondCandles,
|
useBondCandles,
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ function useBrokerageOperations(
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
type BrokerageOperation,
|
type BrokerageOperation,
|
||||||
|
type BrokerageOperationsQuery,
|
||||||
brokerageOperationsQueryOptions,
|
brokerageOperationsQueryOptions,
|
||||||
brokerageOperationsQueryParameters as brokerageOperationsQueryParams,
|
brokerageOperationsQueryParameters as brokerageOperationsQueryParams,
|
||||||
operationCategories,
|
operationCategories,
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export {
|
|||||||
type OperationCategory,
|
type OperationCategory,
|
||||||
useBrokerageOperations,
|
useBrokerageOperations,
|
||||||
} from "./api/useBrokerageOperations";
|
} from "./api/useBrokerageOperations";
|
||||||
|
export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory";
|
||||||
export { LedgerEntryRow } from "./ui/LedgerEntryRow";
|
export { LedgerEntryRow } from "./ui/LedgerEntryRow";
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { hashKey, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type BrokerageOperationsQuery,
|
||||||
|
brokerageOperationsQueryOptions,
|
||||||
|
useBrokerageOperations,
|
||||||
|
} from "../api/useBrokerageOperations";
|
||||||
|
|
||||||
|
function useBrokerageOperationHistory(
|
||||||
|
accountId: string | undefined,
|
||||||
|
firstPage: Omit<BrokerageOperationsQuery, "cursor">,
|
||||||
|
) {
|
||||||
|
const { queryKey } = brokerageOperationsQueryOptions(accountId, firstPage);
|
||||||
|
const scope = hashKey(queryKey);
|
||||||
|
|
||||||
|
const [pagination, setPagination] = useState<{
|
||||||
|
cursors: string[];
|
||||||
|
scope: string;
|
||||||
|
}>({ cursors: [], scope });
|
||||||
|
|
||||||
|
const cursors = pagination.scope === scope ? pagination.cursors : [];
|
||||||
|
const client = useQueryClient();
|
||||||
|
|
||||||
|
const operations = useBrokerageOperations(accountId, {
|
||||||
|
...firstPage,
|
||||||
|
cursor: cursors.at(-1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isInvalidCursor = isApiErrorCode(
|
||||||
|
apiErrorCode(operations.error),
|
||||||
|
ApiErrorCode.InvalidOperationsCursor,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRecoveringInvalidCursor = cursors.length > 0 && isInvalidCursor;
|
||||||
|
|
||||||
|
const reset = () => setPagination({ cursors: [], scope });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRecoveringInvalidCursor) return;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setPagination({ cursors: [], scope });
|
||||||
|
void client.invalidateQueries({ exact: true, queryKey });
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [client, isRecoveringInvalidCursor, queryKey, scope]);
|
||||||
|
|
||||||
|
const retry = (retryScreen: () => unknown) => {
|
||||||
|
if (isInvalidCursor) {
|
||||||
|
reset();
|
||||||
|
void client.invalidateQueries({
|
||||||
|
exact: true,
|
||||||
|
queryKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else void retryScreen();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasPrevious: cursors.length > 0,
|
||||||
|
isRecoveringInvalidCursor,
|
||||||
|
onNext: () => {
|
||||||
|
if (operations.data?.nextCursor)
|
||||||
|
setPagination({
|
||||||
|
cursors: [...cursors, operations.data.nextCursor],
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPrevious: () => setPagination({ cursors: cursors.slice(0, -1), scope }),
|
||||||
|
operations,
|
||||||
|
reset,
|
||||||
|
retry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useBrokerageOperationHistory };
|
||||||
@@ -19,6 +19,7 @@ import { operationLabelKey, paymentPresentation } from "../model/display";
|
|||||||
type LedgerEntryRowProps = {
|
type LedgerEntryRowProps = {
|
||||||
operation: BrokerageOperation;
|
operation: BrokerageOperation;
|
||||||
withDetails?: boolean;
|
withDetails?: boolean;
|
||||||
|
wrap?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
||||||
@@ -84,6 +85,7 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
|||||||
tone={payment.tone}
|
tone={payment.tone}
|
||||||
value={payment.amount}
|
value={payment.amount}
|
||||||
valueLabel={t(payment.label)}
|
valueLabel={t(payment.label)}
|
||||||
|
wrap={props.wrap}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { renderHook, waitFor } from "@testing-library/react";
|
|
||||||
import { http, HttpResponse } from "msw";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { configureAuthClient } from "@/shared/api";
|
|
||||||
import { createQueryWrapper } from "@/test/query";
|
|
||||||
import { resource } from "@/test/resource";
|
|
||||||
import { server } from "@/test/server";
|
|
||||||
|
|
||||||
import { useBrokerageAccounts } from "./useBrokerageAccounts";
|
|
||||||
|
|
||||||
describe("useBrokerageAccounts", () => {
|
|
||||||
it("loads accounts through the authenticated query seam", async () => {
|
|
||||||
configureAuthClient({
|
|
||||||
accessToken: () => "token",
|
|
||||||
clear: vi.fn(),
|
|
||||||
refresh: () => Promise.resolve(true),
|
|
||||||
});
|
|
||||||
server.use(
|
|
||||||
http.get("*/api/v1/brokerage-accounts", () =>
|
|
||||||
HttpResponse.json(
|
|
||||||
resource([
|
|
||||||
{
|
|
||||||
id: "account-1",
|
|
||||||
name: "Основной",
|
|
||||||
openedOn: "2024-08-13",
|
|
||||||
type: "BROKERAGE",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useBrokerageAccounts(), {
|
|
||||||
wrapper: createQueryWrapper().wrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
||||||
expect(result.current.data).toEqual([
|
|
||||||
{
|
|
||||||
id: "account-1",
|
|
||||||
name: "Основной",
|
|
||||||
openedOn: "2024-08-13",
|
|
||||||
type: "BROKERAGE",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the current connection snapshot after a background refetch error", async () => {
|
|
||||||
configureAuthClient({
|
|
||||||
accessToken: () => "token",
|
|
||||||
clear: vi.fn(),
|
|
||||||
refresh: () => Promise.resolve(true),
|
|
||||||
});
|
|
||||||
let calls = 0;
|
|
||||||
server.use(
|
|
||||||
http.get("*/api/v1/brokerage-accounts", () => {
|
|
||||||
calls += 1;
|
|
||||||
|
|
||||||
return calls === 1
|
|
||||||
? HttpResponse.json(
|
|
||||||
resource([
|
|
||||||
{
|
|
||||||
id: "account-1",
|
|
||||||
name: "Основной",
|
|
||||||
openedOn: "2024-08-13",
|
|
||||||
type: "BROKERAGE",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useBrokerageAccounts(), {
|
|
||||||
wrapper: createQueryWrapper().wrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
||||||
void result.current.refetch();
|
|
||||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
||||||
|
|
||||||
expect(result.current.data?.[0].id).toBe("account-1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,17 +4,22 @@ const brokerageAccountsQueryOptions = () =>
|
|||||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
|
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
|
||||||
|
|
||||||
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
|
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
|
||||||
|
const BROKERAGE_ACCOUNTS_STALE_TIME_MS = 1_800_000;
|
||||||
|
|
||||||
type BrokerageAccount = NonNullable<
|
type BrokerageAccount = NonNullable<
|
||||||
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||||
>[number];
|
>[number];
|
||||||
|
|
||||||
function useBrokerageAccounts() {
|
function useBrokerageAccounts() {
|
||||||
return useResourceQuery({
|
const query = useResourceQuery({
|
||||||
meta: { accessGroup: "brokerage" },
|
meta: { accessGroup: "brokerage" },
|
||||||
...brokerageAccountsQueryOptions(),
|
...brokerageAccountsQueryOptions(),
|
||||||
staleTime: 60_000,
|
staleTime: BROKERAGE_ACCOUNTS_STALE_TIME_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BrokerageAccount,
|
BrokerageAccount,
|
||||||
useBrokerageAccounts,
|
|
||||||
} from "../api/useBrokerageAccounts";
|
} from "../api/useBrokerageAccounts";
|
||||||
|
|
||||||
const VISIBLE_CURRENCY_LIMIT = 2;
|
const VISIBLE_CURRENCY_LIMIT = 2;
|
||||||
@@ -9,11 +10,7 @@ type BrokerageAccountCard = {
|
|||||||
account: BrokerageAccount;
|
account: BrokerageAccount;
|
||||||
positionCounts: null
|
positionCounts: null
|
||||||
| { bonds: number; stocks: number };
|
| { bonds: number; stocks: number };
|
||||||
source?: NonNullable<
|
source?: NonNullable<InvestmentResourceMeta["dependencies"]>[number];
|
||||||
ReturnType<typeof useBrokerageAccounts>["meta"]
|
|
||||||
>["dependencies"] extends (infer T)[] | undefined
|
|
||||||
? T
|
|
||||||
: never;
|
|
||||||
summary:
|
summary:
|
||||||
| { remainingCurrencyCount: number; state: "available"; totals: Money[] }
|
| { remainingCurrencyCount: number; state: "available"; totals: Money[] }
|
||||||
| { state: "empty" }
|
| { state: "empty" }
|
||||||
@@ -30,9 +27,7 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
|
|||||||
|
|
||||||
function brokerageAccountCards(
|
function brokerageAccountCards(
|
||||||
accounts: BrokerageAccount[],
|
accounts: BrokerageAccount[],
|
||||||
dependencies: NonNullable<
|
dependencies: InvestmentResourceMeta["dependencies"] = [],
|
||||||
ReturnType<typeof useBrokerageAccounts>["meta"]
|
|
||||||
>["dependencies"] = [],
|
|
||||||
): BrokerageAccountCard[] {
|
): BrokerageAccountCard[] {
|
||||||
return accounts.map((account) => {
|
return accounts.map((account) => {
|
||||||
const source = dependencies.find(
|
const source = dependencies.find(
|
||||||
|
|||||||
+16
-10
@@ -205,26 +205,32 @@ export const IndependentFreshness: Story
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
availability: "complete",
|
availability: "available",
|
||||||
|
completeness: "complete",
|
||||||
dataRevision: "1",
|
dataRevision: "1",
|
||||||
freshness: "stale",
|
freshness: "stale",
|
||||||
lastSuccessfulAt: "2026-09-24T10:30:00Z",
|
lastAttempt: {
|
||||||
resourceKey: "portfolio/storybook-account",
|
finishedAt: "2026-09-24T10:31:00Z",
|
||||||
stateVersion: "failed",
|
problems: [],
|
||||||
sync: {
|
startedAt: "2026-09-24T10:30:00Z",
|
||||||
errorCode: "TBANK_UNAVAILABLE",
|
|
||||||
id: "failed",
|
|
||||||
state: "failed",
|
state: "failed",
|
||||||
},
|
},
|
||||||
|
lastSuccessfulAt: "2026-09-24T10:30:00Z",
|
||||||
|
resourceKey: "portfolio/storybook-account",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
availability: "missing",
|
availability: "initial-loading",
|
||||||
|
completeness: "incomplete",
|
||||||
dataRevision: "0",
|
dataRevision: "0",
|
||||||
freshness: "unknown",
|
freshness: "unknown",
|
||||||
|
lastAttempt: {
|
||||||
|
finishedAt: null,
|
||||||
|
problems: [],
|
||||||
|
startedAt: "2026-09-24T10:30:00Z",
|
||||||
|
state: "running",
|
||||||
|
},
|
||||||
lastSuccessfulAt: null,
|
lastSuccessfulAt: null,
|
||||||
resourceKey: "portfolio/waiting",
|
resourceKey: "portfolio/waiting",
|
||||||
stateVersion: "waiting",
|
|
||||||
sync: { errorCode: null, id: "waiting", state: "running" },
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { WalletIcon } from "@wealthledger/icons";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
import { formatDate, formatMoney } from "@/shared/lib";
|
||||||
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { BrokerageAccountCard } from "../../model/display";
|
import type { BrokerageAccountCard } from "../../model/display";
|
||||||
|
|
||||||
@@ -53,6 +54,39 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BrokerageAccountSourceStatusProps = {
|
||||||
|
source: BrokerageAccountCard["source"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
|
||||||
|
props,
|
||||||
|
) => {
|
||||||
|
const { source } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const isSyncing = source?.lastAttempt.state === "running";
|
||||||
|
|
||||||
|
const errorText
|
||||||
|
= source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SourceSyncStatus
|
||||||
|
errorText={errorText}
|
||||||
|
lastAttempt={source?.lastAttempt}
|
||||||
|
lastSuccessfulAt={source?.lastSuccessfulAt}
|
||||||
|
stale={source?.freshness === "stale"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSyncing && (
|
||||||
|
<Text role="status" size="xs">
|
||||||
|
{t("accounts.refreshing")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
||||||
const { accounts } = props;
|
const { accounts } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -90,24 +124,7 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
|||||||
|
|
||||||
<BrokerageAccountSummary summary={summary} />
|
<BrokerageAccountSummary summary={summary} />
|
||||||
|
|
||||||
{!!source?.lastSuccessfulAt && (
|
<BrokerageAccountSourceStatus source={source} />
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("investment.sourceTime", {
|
|
||||||
date: formatSnapshotTime(source.lastSuccessfulAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(source?.sync.state === "running"
|
|
||||||
|| source?.sync.state === "queued") && (
|
|
||||||
<Text role="status" size="xs">
|
|
||||||
{t("accounts.refreshing")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{source?.sync.state === "failed" && <Text size="xs">{t("accounts.refreshError")}</Text>}
|
|
||||||
|
|
||||||
{source?.freshness === "stale" && <Text size="xs">{t("investment.stale")}</Text>}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{!!positionCounts && (
|
{!!positionCounts && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export {
|
|||||||
type BrokerageEvent,
|
type BrokerageEvent,
|
||||||
useBrokerageEvents,
|
useBrokerageEvents,
|
||||||
} from "./api/useBrokerageEvents";
|
} from "./api/useBrokerageEvents";
|
||||||
|
export { brokerageEventKey } from "./model/event-key";
|
||||||
export { BrokerageEventCard } from "./ui/BrokerageEventCard";
|
export { BrokerageEventCard } from "./ui/BrokerageEventCard";
|
||||||
export {
|
export {
|
||||||
BrokerageEventsPreview,
|
BrokerageEventsPreview,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { BrokerageEvent } from "../api/useBrokerageEvents";
|
||||||
|
|
||||||
|
function brokerageEventKey(event: BrokerageEvent) {
|
||||||
|
return JSON.stringify([
|
||||||
|
event.account?.id, event.type, event.date, event.instrumentUid,
|
||||||
|
event.figi, event.ticker, event.logoName,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { brokerageEventKey };
|
||||||
@@ -34,7 +34,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
|
|||||||
<EntryCard
|
<EntryCard
|
||||||
imageSrc={instrumentLogoUrl(event.logoName)}
|
imageSrc={instrumentLogoUrl(event.logoName)}
|
||||||
initial={(
|
initial={(
|
||||||
(event.ticker ?? event.displayName).trim()[0] ?? "?"
|
event.ticker.trim()[0] ?? "?"
|
||||||
).toUpperCase()}
|
).toUpperCase()}
|
||||||
metadata={metadata}
|
metadata={metadata}
|
||||||
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
|
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
|
import { Button, Skeleton, Stack, Text } from "@mantine/core";
|
||||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
|
||||||
|
|
||||||
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
|
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
|
||||||
|
|
||||||
|
import { brokerageEventKey } from "../../model/event-key";
|
||||||
import { BrokerageEventCard } from "../BrokerageEventCard";
|
import { BrokerageEventCard } from "../BrokerageEventCard";
|
||||||
|
|
||||||
type BrokerageEventsPreviewProps = {
|
type BrokerageEventsPreviewProps = {
|
||||||
@@ -18,9 +17,6 @@ type BrokerageEventsPreviewProps = {
|
|||||||
type BrokerageEventsPreviewState
|
type BrokerageEventsPreviewState
|
||||||
= | {
|
= | {
|
||||||
items: BrokerageEvent[];
|
items: BrokerageEvent[];
|
||||||
lastSuccessfulAt?: null | string;
|
|
||||||
refreshError?: boolean;
|
|
||||||
stale?: boolean;
|
|
||||||
state: "loaded" | "partial";
|
state: "loaded" | "partial";
|
||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
@@ -34,29 +30,13 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{events.state === "loaded" && (
|
|
||||||
<>
|
|
||||||
{!!events.lastSuccessfulAt && (
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("investment.sourceTime", {
|
|
||||||
date: formatSnapshotTime(events.lastSuccessfulAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!!events.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
|
||||||
|
|
||||||
{!!events.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{events.state === "loading" && (
|
{events.state === "loading" && (
|
||||||
<LoadingSkeleton label={t("events.recentLoading")}>
|
<LoadingSkeleton label={t("events.recentLoading")}>
|
||||||
{["first", "second", "third"].map(placeholder => <Skeleton height={48} key={placeholder} radius="sm" />)}
|
{["first", "second", "third"].map(placeholder => <Skeleton height={48} key={placeholder} radius="sm" />)}
|
||||||
</LoadingSkeleton>
|
</LoadingSkeleton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{events.state === "error" && <Alert color="red">{t("events.recentError")}</Alert>}
|
{events.state === "error" && <Text c="dimmed">{t("states.unavailableText")}</Text>}
|
||||||
|
|
||||||
{events.state === "empty" && <Text c="dimmed">{t("events.recentEmpty")}</Text>}
|
{events.state === "empty" && <Text c="dimmed">{t("events.recentEmpty")}</Text>}
|
||||||
|
|
||||||
@@ -66,15 +46,7 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
|||||||
&& events.items.map(event => (
|
&& events.items.map(event => (
|
||||||
<BrokerageEventCard
|
<BrokerageEventCard
|
||||||
event={event}
|
event={event}
|
||||||
key={JSON.stringify([
|
key={brokerageEventKey(event)}
|
||||||
event.account?.id,
|
|
||||||
event.type,
|
|
||||||
event.date,
|
|
||||||
event.instrumentUid,
|
|
||||||
event.figi,
|
|
||||||
event.ticker,
|
|
||||||
event.logoName,
|
|
||||||
])}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ type IncomeAnalytics = NonNullable<
|
|||||||
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type IncomeAnalyticsMeta
|
||||||
|
= paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["meta"];
|
||||||
|
|
||||||
type IncomeSummary = NonNullable<
|
type IncomeSummary = NonNullable<
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||||
>;
|
>;
|
||||||
@@ -44,6 +47,7 @@ function useIncomeSummary(accountId?: string) {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
type IncomeAnalytics,
|
type IncomeAnalytics,
|
||||||
|
type IncomeAnalyticsMeta,
|
||||||
type IncomeSummary,
|
type IncomeSummary,
|
||||||
useIncomeAnalytics,
|
useIncomeAnalytics,
|
||||||
useIncomeSummary,
|
useIncomeSummary,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
type IncomeAnalytics,
|
type IncomeAnalytics,
|
||||||
|
type IncomeAnalyticsMeta,
|
||||||
type IncomeSummary,
|
type IncomeSummary,
|
||||||
useIncomeAnalytics,
|
useIncomeAnalytics,
|
||||||
useIncomeSummary,
|
useIncomeSummary,
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { renderHook, waitFor } from "@testing-library/react";
|
|
||||||
import { http, HttpResponse } from "msw";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { configureAuthClient } from "@/shared/api";
|
|
||||||
import { createQueryWrapper } from "@/test/query";
|
|
||||||
import { resource } from "@/test/resource";
|
|
||||||
import { server } from "@/test/server";
|
|
||||||
|
|
||||||
import { usePortfolio } from "./usePortfolio";
|
|
||||||
|
|
||||||
describe("usePortfolio", () => {
|
|
||||||
it("loads the account portfolio", async () => {
|
|
||||||
configureAuthClient({
|
|
||||||
accessToken: () => "token",
|
|
||||||
clear: vi.fn(),
|
|
||||||
refresh: () => Promise.resolve(true),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => usePortfolio("account-1"), {
|
|
||||||
wrapper: createQueryWrapper().wrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
||||||
expect(result.current.data).toMatchObject({ positions: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the current connection snapshot after a background refetch error", async () => {
|
|
||||||
configureAuthClient({
|
|
||||||
accessToken: () => "token",
|
|
||||||
clear: vi.fn(),
|
|
||||||
refresh: () => Promise.resolve(true),
|
|
||||||
});
|
|
||||||
let calls = 0;
|
|
||||||
server.use(
|
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
|
||||||
calls += 1;
|
|
||||||
|
|
||||||
return calls === 1
|
|
||||||
? HttpResponse.json(
|
|
||||||
resource({
|
|
||||||
cashBalances: [],
|
|
||||||
isStale: false,
|
|
||||||
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
|
||||||
positions: [],
|
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
|
||||||
totalsByCurrency: [],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => usePortfolio("account-1"), {
|
|
||||||
wrapper: createQueryWrapper().wrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
||||||
void result.current.refetch();
|
|
||||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
|
||||||
|
|
||||||
expect(result.current.data).toMatchObject({
|
|
||||||
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
apiQueryClient,
|
apiQueryClient,
|
||||||
type paths,
|
type paths,
|
||||||
unwrapResourceResult,
|
|
||||||
useAuthenticatedQueries,
|
|
||||||
useResourceQuery,
|
useResourceQuery,
|
||||||
} from "@/shared/api";
|
} from "@/shared/api";
|
||||||
|
|
||||||
const MIN_STOCK_SEARCH_LENGTH = 2;
|
type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||||
|
|
||||||
|
type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||||
|
|
||||||
type StockPeriod = NonNullable<
|
type StockPeriod = NonNullable<
|
||||||
NonNullable<
|
NonNullable<
|
||||||
@@ -14,16 +14,9 @@ type StockPeriod = NonNullable<
|
|||||||
>["period"]
|
>["period"]
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const STOCK_STALE_TIME_MS = 30_000;
|
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
|
||||||
|
|
||||||
function stockQueryOptions(uid: string) {
|
const STOCK_STALE_TIME_MS = 30_000;
|
||||||
return {
|
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}", {
|
|
||||||
params: { path: { uid } },
|
|
||||||
}),
|
|
||||||
staleTime: STOCK_STALE_TIME_MS,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useStock(identifier: string) {
|
function useStock(identifier: string) {
|
||||||
const isUid
|
const isUid
|
||||||
@@ -31,20 +24,22 @@ function useStock(identifier: string) {
|
|||||||
identifier,
|
identifier,
|
||||||
);
|
);
|
||||||
|
|
||||||
const byUid = useResourceQuery({
|
// eslint-disable-next-line unicorn/prefer-minimal-ternary -- Hoisting params breaks the generated union of endpoint request shapes.
|
||||||
...stockQueryOptions(identifier),
|
const init = isUid
|
||||||
enabled: Boolean(identifier) && isUid,
|
? { params: { path: { uid: identifier } } }
|
||||||
});
|
: { params: { path: { ticker: identifier } } };
|
||||||
|
|
||||||
const byTicker = useResourceQuery({
|
const options = apiQueryClient.queryOptions(
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", {
|
"get",
|
||||||
params: { path: { ticker: identifier } },
|
isUid ? "/api/v1/stocks/{uid}" : "/api/v1/stocks/by-ticker/{ticker}",
|
||||||
}),
|
init,
|
||||||
enabled: Boolean(identifier) && !isUid,
|
);
|
||||||
|
|
||||||
|
return useResourceQuery({
|
||||||
|
...options,
|
||||||
|
enabled: Boolean(identifier),
|
||||||
staleTime: STOCK_STALE_TIME_MS,
|
staleTime: STOCK_STALE_TIME_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
return isUid ? byUid : byTicker;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useStockCandles(uid: string, period: StockPeriod) {
|
function useStockCandles(uid: string, period: StockPeriod) {
|
||||||
@@ -99,41 +94,14 @@ function useStockQuote(uid: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function useStocks(uids: string[]) {
|
|
||||||
const uniqueUids = [...new Set(uids)];
|
|
||||||
|
|
||||||
const queries = useAuthenticatedQueries(
|
|
||||||
uniqueUids.map(uniqueUid => stockQueryOptions(uniqueUid)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Map(
|
|
||||||
queries.map((query, 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", {
|
|
||||||
params: { query: { query } },
|
|
||||||
}),
|
|
||||||
enabled: query.length >= MIN_STOCK_SEARCH_LENGTH,
|
|
||||||
staleTime: STOCK_STALE_TIME_MS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
MIN_STOCK_SEARCH_LENGTH,
|
type StockCandles,
|
||||||
type StockPeriod,
|
type StockDividends,
|
||||||
|
type StockPosition,
|
||||||
useStock,
|
useStock,
|
||||||
useStockCandles,
|
useStockCandles,
|
||||||
useStockCompany,
|
useStockCompany,
|
||||||
useStockDividends,
|
useStockDividends,
|
||||||
useStockPosition,
|
useStockPosition,
|
||||||
useStockQuote,
|
useStockQuote,
|
||||||
useStocks,
|
|
||||||
useStockSearch,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
export {
|
export {
|
||||||
MIN_STOCK_SEARCH_LENGTH,
|
type StockCandles,
|
||||||
type StockPeriod,
|
type StockDividends,
|
||||||
|
type StockPosition,
|
||||||
useStock,
|
useStock,
|
||||||
useStockCandles,
|
useStockCandles,
|
||||||
useStockCompany,
|
useStockCompany,
|
||||||
useStockDividends,
|
useStockDividends,
|
||||||
useStockPosition,
|
useStockPosition,
|
||||||
useStockQuote,
|
useStockQuote,
|
||||||
useStocks,
|
|
||||||
useStockSearch,
|
|
||||||
} from "./api/queries";
|
} from "./api/queries";
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { createContext, use } from "react";
|
|||||||
import type { paths } from "@/shared/api";
|
import type { paths } from "@/shared/api";
|
||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
accessToken: string | undefined;
|
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
refresh: () => Promise<boolean>;
|
|
||||||
register: (email: string, password: string) => Promise<void>;
|
register: (email: string, password: string) => Promise<void>;
|
||||||
|
signedOut: boolean;
|
||||||
user: undefined | User;
|
user: undefined | User;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,32 @@ function createAuthWrapper(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expireSession() {
|
||||||
|
server.use(http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
|
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
|
||||||
|
{ once: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.GET("/api/v1/brokerage-accounts");
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// A refresh that changes identity cancels the original user's request.
|
||||||
|
if (!(error instanceof DOMException && error.name === "AbortError")) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestAuthorization() {
|
||||||
|
let authorization: null | string = null;
|
||||||
|
server.use(http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||||
|
authorization = request.headers.get("Authorization");
|
||||||
|
|
||||||
|
return HttpResponse.json(resource([]));
|
||||||
|
}, { once: true }));
|
||||||
|
await apiClient.GET("/api/v1/brokerage-accounts");
|
||||||
|
|
||||||
|
return authorization;
|
||||||
|
}
|
||||||
|
|
||||||
describe("AuthProvider", () => {
|
describe("AuthProvider", () => {
|
||||||
it("authorizes requests after restoring the initial session", async () => {
|
it("authorizes requests after restoring the initial session", async () => {
|
||||||
let authorization: null | string
|
let authorization: null | string
|
||||||
@@ -77,7 +103,7 @@ describe("AuthProvider", () => {
|
|||||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||||
|
|
||||||
expect(result.current.user).toBeUndefined();
|
expect(result.current.user).toBeUndefined();
|
||||||
expect(result.current.accessToken).toBeUndefined();
|
expect(await requestAuthorization()).toBeNull();
|
||||||
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
|
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
|
||||||
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
|
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -106,13 +132,13 @@ describe("AuthProvider", () => {
|
|||||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.accessToken).toBe("restored-token"),
|
expect(result.current.ready).toBe(true),
|
||||||
);
|
);
|
||||||
const response = await apiClient.GET("/api/v1/brokerage-accounts");
|
const response = await apiClient.GET("/api/v1/brokerage-accounts");
|
||||||
|
|
||||||
expect(response.response.status).toBe(401);
|
expect(response.response.status).toBe(401);
|
||||||
await waitFor(() => expect(result.current.user).toBeUndefined());
|
await waitFor(() => expect(result.current.user).toBeUndefined());
|
||||||
expect(result.current.accessToken).toBeUndefined();
|
expect(await requestAuthorization()).toBeNull();
|
||||||
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
|
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
|
||||||
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
|
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -147,7 +173,7 @@ describe("AuthProvider", () => {
|
|||||||
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
|
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
|
||||||
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
|
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
await waitFor(() => expect(result.current.accessToken).toBe("test-token"));
|
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||||
|
|
||||||
expect(queryClient.getQueryData(bankCardsKey)).toEqual([
|
expect(queryClient.getQueryData(bankCardsKey)).toEqual([
|
||||||
{ id: "bank-card-1" },
|
{ id: "bank-card-1" },
|
||||||
@@ -155,7 +181,7 @@ describe("AuthProvider", () => {
|
|||||||
await act(() => result.current.logout());
|
await act(() => result.current.logout());
|
||||||
|
|
||||||
expect(result.current.user).toBeUndefined();
|
expect(result.current.user).toBeUndefined();
|
||||||
expect(result.current.accessToken).toBeUndefined();
|
expect(await requestAuthorization()).toBeNull();
|
||||||
expect(queryClient.getQueryData(statusKey)).toBeUndefined();
|
expect(queryClient.getQueryData(statusKey)).toBeUndefined();
|
||||||
expect(queryClient.getQueryData(portfolioKey)).toBeUndefined();
|
expect(queryClient.getQueryData(portfolioKey)).toBeUndefined();
|
||||||
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
|
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
|
||||||
@@ -265,7 +291,7 @@ describe("AuthProvider", () => {
|
|||||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||||
queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]);
|
queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]);
|
||||||
await act(() => result.current.refresh());
|
await act(() => expireSession());
|
||||||
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]);
|
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]);
|
||||||
let release!: (value: string) => void;
|
let release!: (value: string) => void;
|
||||||
|
|
||||||
@@ -283,7 +309,7 @@ describe("AuthProvider", () => {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
identity = "b";
|
identity = "b";
|
||||||
await act(() => result.current.refresh());
|
await act(() => expireSession());
|
||||||
release("a-data");
|
release("a-data");
|
||||||
await pending;
|
await pending;
|
||||||
expect(result.current.user?.id).toBe("b");
|
expect(result.current.user?.id).toBe("b");
|
||||||
@@ -319,12 +345,12 @@ describe("AuthProvider", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.cards.data?.[0].id).toBe("a-card"),
|
expect(result.current.cards.data?.[0]?.id).toBe("a-card"),
|
||||||
);
|
);
|
||||||
identity = "b";
|
identity = "b";
|
||||||
await act(() => result.current.auth.refresh());
|
await act(() => expireSession());
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.cards.data?.[0].id).toBe("b-card"),
|
expect(result.current.cards.data?.[0]?.id).toBe("b-card"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,23 +29,19 @@ async function request(
|
|||||||
function useAuthProviderVm() {
|
function useAuthProviderVm() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [user, setUser] = useState<undefined | User>(undefined);
|
const [user, setUser] = useState<undefined | User>(undefined);
|
||||||
const [accessToken, setAccessToken] = useState<string | undefined>(undefined);
|
|
||||||
const accessTokenRef = useRef<string | undefined>(undefined);
|
const accessTokenRef = useRef<string | undefined>(undefined);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const [signedOut, setSignedOut] = useState(false);
|
||||||
|
|
||||||
const generationRef = useRef(0);
|
const generationRef = useRef(0);
|
||||||
const userRef = useRef<undefined | User>(undefined);
|
const userRef = useRef<undefined | User>(undefined);
|
||||||
const authQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
const authQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||||
const refreshPromiseRef = useRef<Promise<boolean> | undefined>(undefined);
|
const refreshPromiseRef = useRef<Promise<boolean> | undefined>(undefined);
|
||||||
|
|
||||||
// Cookie-writing requests share ordering, including across tabs with Web Locks.
|
// Order cookie-writing requests within this tab. Other tabs may reauthenticate.
|
||||||
const serialize = useCallback(<T>(run: () => Promise<T>): Promise<T> => {
|
const serialize = useCallback(<T>(run: () => Promise<T>): Promise<T> => {
|
||||||
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
// 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 () =>
|
const next = authQueueRef.current.then(run);
|
||||||
"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.
|
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
||||||
authQueueRef.current = next.catch(() => {});
|
authQueueRef.current = next.catch(() => {});
|
||||||
@@ -58,6 +54,7 @@ function useAuthProviderVm() {
|
|||||||
const changedIdentity
|
const changedIdentity
|
||||||
= userRef.current && userRef.current.id !== authentication.user.id;
|
= userRef.current && userRef.current.id !== authentication.user.id;
|
||||||
|
|
||||||
|
setSignedOut(false);
|
||||||
accessTokenRef.current = authentication.accessToken;
|
accessTokenRef.current = authentication.accessToken;
|
||||||
|
|
||||||
if (changedIdentity) {
|
if (changedIdentity) {
|
||||||
@@ -69,7 +66,6 @@ function useAuthProviderVm() {
|
|||||||
|
|
||||||
userRef.current = authentication.user;
|
userRef.current = authentication.user;
|
||||||
setUser(authentication.user);
|
setUser(authentication.user);
|
||||||
setAccessToken(authentication.accessToken);
|
|
||||||
},
|
},
|
||||||
[queryClient],
|
[queryClient],
|
||||||
);
|
);
|
||||||
@@ -78,7 +74,6 @@ function useAuthProviderVm() {
|
|||||||
generationRef.current += 1;
|
generationRef.current += 1;
|
||||||
userRef.current = undefined;
|
userRef.current = undefined;
|
||||||
setUser(undefined);
|
setUser(undefined);
|
||||||
setAccessToken(undefined);
|
|
||||||
accessTokenRef.current = undefined;
|
accessTokenRef.current = undefined;
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
@@ -111,8 +106,7 @@ function useAuthProviderVm() {
|
|||||||
}
|
}
|
||||||
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
if (refreshPromiseRef.current === pending)
|
refreshPromiseRef.current = undefined;
|
||||||
refreshPromiseRef.current = undefined;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
refreshPromiseRef.current = pending;
|
refreshPromiseRef.current = pending;
|
||||||
@@ -139,6 +133,7 @@ function useAuthProviderVm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
|
setSignedOut(true);
|
||||||
clearSession();
|
clearSession();
|
||||||
await serialize(async () => {
|
await serialize(async () => {
|
||||||
await apiClient.POST("/api/v1/auth/logout");
|
await apiClient.POST("/api/v1/auth/logout");
|
||||||
@@ -156,14 +151,13 @@ function useAuthProviderVm() {
|
|||||||
useEffect(() => () => configureAuthClient(), []);
|
useEffect(() => () => configureAuthClient(), []);
|
||||||
|
|
||||||
const authContextValue = {
|
const authContextValue = {
|
||||||
accessToken,
|
|
||||||
login: (email: string, password: string) =>
|
login: (email: string, password: string) =>
|
||||||
authenticate("/api/v1/auth/login", email, password),
|
authenticate("/api/v1/auth/login", email, password),
|
||||||
logout,
|
logout,
|
||||||
ready,
|
ready,
|
||||||
refresh,
|
|
||||||
register: (email: string, password: string) =>
|
register: (email: string, password: string) =>
|
||||||
authenticate("/api/v1/auth/register", email, password),
|
authenticate("/api/v1/auth/register", email, password),
|
||||||
|
signedOut,
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
.fieldset {
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
import { Alert, Button, Fieldset, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FormProvider, type Resolver, useForm } from "react-hook-form";
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
loginSchema,
|
loginSchema,
|
||||||
registerSchema,
|
registerSchema,
|
||||||
} from "../../model/auth.schema";
|
} from "../../model/auth.schema";
|
||||||
import classes from "./AuthForm.module.css";
|
|
||||||
|
|
||||||
type AuthFormProps = {
|
type AuthFormProps = {
|
||||||
onSubmit: (email: string, password: string) => Promise<void>;
|
onSubmit: (email: string, password: string) => Promise<void>;
|
||||||
@@ -25,7 +24,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
|||||||
const schema = register ? registerSchema : loginSchema;
|
const schema = register ? registerSchema : loginSchema;
|
||||||
|
|
||||||
const methods = useForm<AuthFormValues>({
|
const methods = useForm<AuthFormValues>({
|
||||||
resolver: zodResolver(schema) as Resolver<AuthFormValues>,
|
resolver: zodResolver(schema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -34,7 +33,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
|||||||
|
|
||||||
const [submitError, setSubmitError] = useState<null | string>(null);
|
const [submitError, setSubmitError] = useState<null | string>(null);
|
||||||
|
|
||||||
const onSubmit2 = methods.handleSubmit(async (values) => {
|
const submit = methods.handleSubmit(async (values) => {
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -49,26 +48,26 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form onSubmit={onSubmit2}>
|
<form onSubmit={submit}>
|
||||||
<fieldset className={classes.fieldset} disabled={isSubmitting}>
|
<Fieldset disabled={isSubmitting} variant="unstyled">
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t("auth.email")}
|
label={t("auth.email")}
|
||||||
{...methods.register("email")}
|
{...methods.register("email")}
|
||||||
error={errors.email?.message as string}
|
error={errors.email?.message}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t("auth.password")}
|
label={t("auth.password")}
|
||||||
{...methods.register("password")}
|
{...methods.register("password")}
|
||||||
error={errors.password?.message as string}
|
error={errors.password?.message}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{register && (
|
{register && (
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t("auth.passwordConfirmation")}
|
label={t("auth.passwordConfirmation")}
|
||||||
{...methods.register("passwordConfirmation")}
|
{...methods.register("passwordConfirmation")}
|
||||||
error={errors.passwordConfirmation?.message as string}
|
error={errors.passwordConfirmation?.message}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -82,7 +81,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
|||||||
{t(register ? "auth.haveAccount" : "auth.noAccount")}
|
{t(register ? "auth.haveAccount" : "auth.noAccount")}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</fieldset>
|
</Fieldset>
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import { useAuth } from "../../model/auth-context";
|
||||||
import { AuthForm } from "../AuthForm/AuthForm";
|
import { AuthForm } from "../AuthForm/AuthForm";
|
||||||
import { useLoginFormVm } from "./LoginForm.vm";
|
|
||||||
|
|
||||||
const LoginForm: FC = () => {
|
const LoginForm: FC = () => {
|
||||||
const { onSubmit } = useLoginFormVm();
|
const { login } = useAuth();
|
||||||
|
|
||||||
return <AuthForm onSubmit={onSubmit} />;
|
return <AuthForm onSubmit={login} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { LoginForm };
|
export { LoginForm };
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user