Compare commits
104
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b3e35ca8 | ||
|
|
41f0ae7469 | ||
|
|
2b94a16ae5 | ||
|
|
e2062ce837 | ||
|
|
d910d624d6 | ||
|
|
cc45352243 | ||
|
|
ed0b5388ab | ||
|
|
134c951178 | ||
|
|
2fd7d59e65 | ||
|
|
668f2ef70e | ||
|
|
cadba8d075 | ||
|
|
2db39f25d9 | ||
|
|
2d070ce2b5 | ||
|
|
5077bd4f0b | ||
|
|
e2d143df46 | ||
|
|
ad93725a84 | ||
|
|
8ce1e8e444 | ||
|
|
aaee9591d7 | ||
|
|
9619dc9791 | ||
|
|
71fbe7774d | ||
|
|
870b7e6327 | ||
|
|
70a2c335fd | ||
|
|
7242240b21 | ||
|
|
71126c91f0 | ||
|
|
6792f5b70a | ||
|
|
32e8fe9402 | ||
|
|
5796d2d789 | ||
|
|
a56edf2b37 | ||
|
|
1d4990491f | ||
|
|
431559be0f | ||
|
|
0e4bbec2a7 | ||
|
|
c24704affa | ||
|
|
58b4e12633 | ||
|
|
3075081df1 | ||
|
|
1a92f023c1 | ||
|
|
df86b8450f | ||
|
|
b5ceb77208 | ||
|
|
b009101b7b | ||
|
|
7e6fd95cfa | ||
|
|
2cd6b1e72d | ||
|
|
3af9df0ae2 | ||
|
|
7b6f34c776 | ||
|
|
ed811e88fa | ||
|
|
eefdde29f8 | ||
|
|
5134106696 | ||
|
|
ebe92684a7 | ||
|
|
6f1226b668 | ||
|
|
d409a297e5 | ||
|
|
ed63adad94 | ||
|
|
bc28973d1a | ||
|
|
20618fb033 | ||
|
|
612ab62509 | ||
|
|
f295dea6a6 | ||
|
|
f3725c202b | ||
|
|
edc7097bd7 | ||
|
|
233680448f | ||
|
|
e38ecd87d6 | ||
|
|
e5f8bbe33c | ||
|
|
f7f53ff8e4 | ||
|
|
df05c8c7db | ||
|
|
e67c47b102 | ||
|
|
43b0f5574a | ||
|
|
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 |
+4
-3
@@ -34,9 +34,10 @@ contract; frontend не читает его checkout для генерации
|
|||||||
| Представление/поведение бизнес-сущности | `entities/<entity>` |
|
| Представление/поведение бизнес-сущности | `entities/<entity>` |
|
||||||
| Переиспользуемый код без WealthLedger domain meaning | `shared` |
|
| Переиспользуемый код без WealthLedger domain meaning | `shared` |
|
||||||
|
|
||||||
Локальный FSD profile: `app → pages → features → entities → shared`, без `widgets`.
|
Локальный FSD profile: `app → pages → features → entities → shared`.
|
||||||
Крупный уникальный блок остаётся в своей page. Form, mutation или click handler
|
Слой `widgets` не используется: композиция экрана и компонент для одной page
|
||||||
сами по себе не требуют feature. Два callers не делают domain helper generic shared.
|
остаются у page. Form, mutation или click handler сами по себе не
|
||||||
|
требуют feature. Два callers не делают domain helper generic shared.
|
||||||
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
|
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
|
||||||
|
|
||||||
**Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner.
|
**Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner.
|
||||||
|
|||||||
+20
-1
@@ -30,7 +30,8 @@ backend checkout и не проверяют backend runtime OpenAPI; измен
|
|||||||
shape/status/route сохраняет
|
shape/status/route сохраняет
|
||||||
публичный контракт предоставленного snapshot.
|
публичный контракт предоставленного snapshot.
|
||||||
Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot;
|
Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot;
|
||||||
`openapi:check` генерирует во временный каталог и сравнивает, не меняя tracked files.
|
`openapi:check` запускает native `--check` того же CLI и падает при устаревших
|
||||||
|
generated types, не меняя tracked files.
|
||||||
|
|
||||||
## Auth и invalidation
|
## Auth и invalidation
|
||||||
|
|
||||||
@@ -39,6 +40,15 @@ 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.
|
||||||
|
|
||||||
|
Refresh, завершившийся `401`, очищает session. Сетевая ошибка и `5xx`
|
||||||
|
(например, деплой backend) не доказывают конец session: она сохраняется,
|
||||||
|
`unavailable` показывает retry-экран вместо login, а следующий `401` снова
|
||||||
|
запускает refresh. Retry/backoff внутри refresh не добавляется.
|
||||||
|
|
||||||
|
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 +60,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,33 @@ 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.
|
| Pre-commit | Hook не скачивает tools: вызывай установленные бинарники из `node_modules/.bin` (не `npx`). Full graph не входит в hook; он выполняется в `npm run verify` и CI. |
|
||||||
|
|
||||||
|
Повтори 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, раздел «Релизы».
|
обычную работу. PR сливается squash или rebase-merge; merge-коммит не
|
||||||
|
используется. `main` обновляется только fast-forward из `develop`, поэтому
|
||||||
|
`origin/main` всегда предок `origin/develop`:
|
||||||
|
`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
|
||||||
|
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
|
||||||
|
Перед релизом прочитай местный 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.
|
||||||
|
|
||||||
## Формы
|
## Формы
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
!public/**
|
!public/**
|
||||||
!docker/
|
!docker/
|
||||||
!docker/**
|
!docker/**
|
||||||
|
!scripts/
|
||||||
|
!scripts/minimatch-compat/
|
||||||
|
!scripts/minimatch-compat/**
|
||||||
!packages/
|
!packages/
|
||||||
!packages/tokens/
|
!packages/tokens/
|
||||||
!packages/tokens/package.json
|
!packages/tokens/package.json
|
||||||
|
|||||||
@@ -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 (если есть)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name: Frontend verify
|
||||||
|
|
||||||
|
# Metadata-only: refreshes only the `commits` context after a PR title edit.
|
||||||
|
# Same workflow name keeps the required context; `security` and `verify`
|
||||||
|
# statuses of the head revision stay untouched (no skipped overwrite).
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [edited]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: frontend-pr-title-${{ gitea.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
commits:
|
||||||
|
timeout-minutes: 15
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
- run: npm ci --ignore-scripts
|
||||||
|
- name: Check PR title and commits
|
||||||
|
env:
|
||||||
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
run: |
|
||||||
|
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
||||||
|
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||||
@@ -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
|
||||||
|
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"
|
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||||
|
fi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
needs: commits
|
needs: commits
|
||||||
@@ -62,7 +66,7 @@ jobs:
|
|||||||
- name: Verify pre-commit secret blocking and redaction
|
- name: Verify pre-commit secret blocking and redaction
|
||||||
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
|
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
|
||||||
- name: Audit runtime and development dependencies
|
- name: Audit runtime and development dependencies
|
||||||
run: npm audit --include=dev --audit-level=low
|
run: npm audit --include=dev --audit-level=moderate
|
||||||
|
|
||||||
verify:
|
verify:
|
||||||
needs: [commits, security]
|
needs: [commits, security]
|
||||||
|
|||||||
+5
-2
@@ -1,3 +1,6 @@
|
|||||||
gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $?
|
gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $?
|
||||||
npx lint-staged
|
if [ ! -x node_modules/.bin/lint-staged ]; then
|
||||||
npm run deps
|
echo "lint-staged is not installed; run npm ci" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
node_modules/.bin/lint-staged
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|
||||||
| Решение / сигнал | Читать |
|
| Решение / сигнал | Читать |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
+227
@@ -1,5 +1,232 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.4.0...v2.0.0) (2026-11-11)
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* support RUB only (WealthLedger/main[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#168](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/168))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **account:** add performance tab vs Moscow Exchange index (WealthLedger/main[#54](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/54)) ([#167](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/167)) ([e2062ce](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e2062ce8372d196e00e4dc4e75e072d40b56db21))
|
||||||
|
* clarify grouped coupon payouts ([#162](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/162)) ([#165](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/165)) ([cc45352](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cc45352243ebf4f34295415de2356a2af163f27a))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* polish accounts portfolio summary ([#159](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/159)) ([134c951](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/134c9511789f2f9372d6a9186910067a79c26ad0))
|
||||||
|
* unify history and upcoming events labels ([#161](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/161)) ([#163](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/163)) ([ed0b538](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ed0b5388ab148ebf58048beda61022bbee0ab3aa))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* support RUB only (WealthLedger/main[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#168](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/168)) ([2b94a16](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2b94a16ae5c68c3e2134fe68652bdf5b14564783))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* remove timing races from screen-sync and route transition tests ([#138](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/138)) ([#166](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/166)) ([d910d62](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/d910d624d6583ae0a6ef66133eb7a2bb8eea3e60))
|
||||||
|
|
||||||
|
## [1.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.3.0...v1.4.0) (2026-11-06)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **investments:** warn and offer retry after exhausted read retries (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#136](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/136)) ([8ce1e8e](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/8ce1e8e44486dcc23d4c074559ca2bb517880b60))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **account:** show source metadata on empty account and income (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#133](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/133)) ([9619dc9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/9619dc979172dbc16391206e801ed67862d20116))
|
||||||
|
* align audit findings for Events, currency and auth (WealthLedger/frontend[#155](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/155)) ([668f2ef](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/668f2ef70e535a1222e7f600b1be4a31fefc6f8b))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* **api:** consume bank operation oneOf contract (WealthLedger/frontend[#144](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/144)) ([#152](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/152)) ([2d070ce](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2d070ce2b54bbee466ecf6cc18b437910f0c07f8))
|
||||||
|
* drop dead query helpers and simplify read deadline (WealthLedger/frontend[#141](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/141)) ([#149](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/149)) ([5077bd4](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5077bd4f0baa4a59643e875485c2f08253cc198d))
|
||||||
|
* move portfolio structure into account page, drop widgets (WealthLedger/frontend[#139](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/139)) ([#140](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/140)) ([e2d143d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e2d143df46e650139e49b7c80a886008ede86d2a))
|
||||||
|
* normalize view models and share breadcrumbs and titles (WealthLedger/frontend[#143](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/143)) ([#153](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/153)) ([2db39f2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2db39f25d9478350ad56a1b2e2ca6496068257c5))
|
||||||
|
* replace repeated lazy page wrappers with lazyPage helper ([#131](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/131)) ([#132](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/132)) ([71fbe77](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71fbe7774d3ed63fcb84bc644da997921bc049b2))
|
||||||
|
* share path-root query predicate (WealthLedger/frontend[#142](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/142)) ([#154](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/154)) ([cadba8d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cadba8d075daf9b20c0c9de97ad5bd8f5a3e74fc))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* **api:** update OpenAPI snapshot with UpcomingEventsDto.window (WealthLedger/frontend[#134](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/134)) ([#135](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/135)) ([aaee959](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/aaee9591d7880193ee9b0c14339e8477264e4a94))
|
||||||
|
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/6)) ([#137](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/137)) ([ad93725](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ad93725a84615ec329631b71ed8db33337085740))
|
||||||
|
|
||||||
|
## [1.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.1...v1.3.0) (2026-11-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **account:** add concentration tab with top-5 slices and chart (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/48)) ([#127](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/127)) ([70a2c33](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/70a2c335fdddb8b6a99e3f609befe06aeb8cc45d))
|
||||||
|
* group all-account rows by instrument and day (WealthLedger/main[#47](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/47)) ([#126](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/126)) ([71126c9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71126c91f0b30f4d65a313ddc7d7f86963340455))
|
||||||
|
* make brokerage account list compact ([#111](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/111)) ([c24704a](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c24704affacbe7fbc08f5e2b61bb6e7ffa6053b9))
|
||||||
|
* remove top header and rework mobile bottom navigation ([#109](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/109)) ([1a92f02](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1a92f023c1c79f646424f72710ab74de63386a11))
|
||||||
|
* replace account page accordion with tabs and show open position result ([#112](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/112)) ([3075081](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3075081df188de0630d314ba15a34fe6efd1eea9))
|
||||||
|
* show return, cash and structure on account cards ([#114](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/114)) ([431559b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/431559be0fca9eb37f6a09d930d502f939884e39))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* round money to currency minor units without negative zero (WealthLedger/frontend[#118](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/118)) ([#119](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/119)) ([1d49904](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1d4990491f254e1ec6bafad8204872eaae2982d0))
|
||||||
|
* show account source sync status (WealthLedger/frontend[#120](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/120)) ([#121](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/121)) ([a56edf2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a56edf2b3757ef0b0f6c42ecbd422a1a563f6505))
|
||||||
|
* show history loading alert only while loading (WealthLedger/frontend[#124](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/124)) ([#125](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/125)) ([6792f5b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6792f5b70aa3ba90b2eb2df7e63d3ae2f669e295))
|
||||||
|
* stabilize auth client registration (WealthLedger/frontend[#95](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/95)) ([#122](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/122)) ([5796d2d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5796d2d789963e66a3a4bcd79eab44700f16bd31))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* derive api error codes from contract (WealthLedger/backend[#117](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/117)) ([#123](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/123)) ([32e8fe9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32e8fe9402665ea0c350f5f247d87e328ffd44c8))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* pin date in all accounts events test ([#115](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/115)) ([58b4e12](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/58b4e126330d01c1d6865bd0e9dd066a028fba08))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* **deps:** resolve minimatch and glob audit advisories (WealthLedger/main[#49](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/49)) ([#128](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/128)) ([7242240](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7242240b2188e9d1ca1325e65d0e1a0a3655d882))
|
||||||
|
* sync brokerage accounts contract (WealthLedger/backend[#138](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/138)) ([0e4bbec](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0e4bbec2a7b814579095ea7ef6db68a70508fa29))
|
||||||
|
|
||||||
|
## [1.2.1](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.0...v1.2.1) (2026-10-24)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* keep session when refresh fails on network error or 5xx (WealthLedger/frontend[#97](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/97)) ([3af9df0](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3af9df0ae2f0fb314c276504ef7d50a7472de680))
|
||||||
|
* show snapshot time in browser time zone ([#101](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/101)) ([b009101](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b009101b7b954e8462672a9da859267ebb71edc4))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* bump Node to 24.22.1 (WealthLedger/backend[#122](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/122)) ([2cd6b1e](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2cd6b1e72d97117b0c37465aac3d989b0469c1c3))
|
||||||
|
* bump test stack and fix npm audit findings ([#105](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/105)) ([7e6fd95](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7e6fd95cfaf7d288242fb46ec7091f561e6f3de5))
|
||||||
|
* **deps:** update non-major dependencies [#9](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/9) ([b5ceb77](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b5ceb77208e35a0e389258f3d3c4b85852980042))
|
||||||
|
* resolve dev dependency audit findings (WealthLedger/frontend[#103](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/103)) ([7b6f34c](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7b6f34c776fa3517c93b5c3468e3b277aaa6273f))
|
||||||
|
|
||||||
|
## [1.2.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.1.0...v1.2.0) (2026-10-18)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* unify income payment cards and remove comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/33)) ([#74](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/74)) ([43b0f55](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/43b0f5574a79e26de0abae35de16d8aa8700f718))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* align password validation with backend ASCII policy (WealthLedger/backend[#60](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/60)) ([#89](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/89)) ([ebe9268](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ebe92684a74b4e2617b04ef00d9406b8a0f674ea))
|
||||||
|
* **auth,bank:** guard switch-auth nav and surface banking retry ([e38ecd8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e38ecd87d6f69968bb56e6035ceeb6cfda208a3f)), closes [#56](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/56) [#57](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/57)
|
||||||
|
* **bank:** break BankFeedback's shared/api edge for stories ([#80](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/80)) ([2336804](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/233680448f97dc3ec57d7665ec14c68ac92410e5))
|
||||||
|
* deliver screener OpenAPI constraints (WealthLedger/backend[#70](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/70)) ([#85](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/85)) ([ed63ada](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ed63adad94f5d0e20f7d0d1446a235f953d10ec2))
|
||||||
|
* **events:** seven-date default period and picker from today (WealthLedger/frontend[#63](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/63)) ([612ab62](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/612ab6250979bf8d9b57f4feb6e1476ed3d759e4))
|
||||||
|
* respect query eligibility in screen sync (WealthLedger/frontend[#54](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/54)) ([f295dea](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f295dea6a6a33c597c753b52aff629d5382c963a))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* derive payment-system options from schema tuple ([#58](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/58)) ([#75](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/75)) ([e67c47b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e67c47b102329877eb5d9c9d8bb4ea7c0bc70883))
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
* use native openapi-typescript --check ([#60](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/60)) ([#77](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/77)) ([df05c8c](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/df05c8c7dbb5d6816fb95d54ca43cd0d83eecd48))
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
* keep pre-commit proportionate and avoid tool downloads (WealthLedger/frontend[#62](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/62)) ([#86](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/86)) ([d409a29](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/d409a297e5f9ae5658d4efa958bda9a012a859a1))
|
||||||
|
* recheck PR title on edited without the full pipeline (WealthLedger/frontend[#64](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/64)) ([bc28973](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/bc28973d1ad5e15bfbc2e5b73b00ba63449933db))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* await client settlement in late-response cases (WealthLedger/frontend[#61](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/61)) ([#81](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/81)) ([f3725c2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f3725c202bd8cef4ba711085df82a921a2da6b7d))
|
||||||
|
* cover non-retry of INVALID_TBANK_TOKEN reads (WealthLedger/backend[#59](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/59)) ([#90](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/90)) ([5134106](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/513410669680480a1f68f5720744cc85e43c8286))
|
||||||
|
* cover null events and income envelopes on account pages ([#72](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/72)) ([#79](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/79)) ([e5f8bbe](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e5f8bbe33cd438f432281818c5276abe9a425646))
|
||||||
|
* cover null resource envelopes for account pages ([#70](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/70)) ([#71](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/71)) ([#78](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/78)) ([f7f53ff](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f7f53ff8e4de3c1d06fac6063974ff9667145ed6))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* adopt linear git model in frontend docs ([#91](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/91)) ([eefdde2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/eefdde29f8da269581e90bd797078e10a39e472f))
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|||||||
+3
-2
@@ -1,10 +1,11 @@
|
|||||||
FROM node:24.21.0-bookworm-slim AS dependencies
|
FROM node:24.22.1-bookworm-slim AS dependencies
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV HUSKY=0
|
ENV HUSKY=0
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY packages/tokens/package.json ./packages/tokens/
|
COPY packages/tokens/package.json ./packages/tokens/
|
||||||
COPY packages/ui/package.json ./packages/ui/
|
COPY packages/ui/package.json ./packages/ui/
|
||||||
COPY packages/icons/package.json ./packages/icons/
|
COPY packages/icons/package.json ./packages/icons/
|
||||||
|
COPY scripts/minimatch-compat ./scripts/minimatch-compat
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
FROM dependencies AS development
|
FROM dependencies AS development
|
||||||
@@ -19,7 +20,7 @@ COPY . .
|
|||||||
ENV VITE_API_BASE_URL=""
|
ENV VITE_API_BASE_URL=""
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:1.28.0-alpine AS production
|
FROM nginx:1.31.8-alpine AS production
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
ARG REVISION
|
ARG REVISION
|
||||||
LABEL org.opencontainers.image.version=$VERSION \
|
LABEL org.opencontainers.image.version=$VERSION \
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ npm run dev
|
|||||||
|
|
||||||
Для commits установите [Gitleaks](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1)
|
Для commits установите [Gitleaks](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1)
|
||||||
8.30.1 в `PATH` (macOS: `brew install gitleaks`). Pre-commit сначала проверяет
|
8.30.1 в `PATH` (macOS: `brew install gitleaks`). Pre-commit сначала проверяет
|
||||||
staged diff с полной редакцией секретов, затем запускает formatting и FSD checks.
|
staged diff с полной редакцией секретов, затем запускает установленный
|
||||||
Находка или отсутствие Gitleaks блокирует commit.
|
`node_modules/.bin/lint-staged` только для staged JS/TS. Hook не скачивает tools и
|
||||||
|
не запускает full graph: полный `npm run verify` выполняется в CI и перед передачей.
|
||||||
|
Находка или отсутствие Gitleaks либо lint-staged блокирует commit.
|
||||||
Проверка этого поведения: `node --test scripts/test-pre-commit.mjs`.
|
Проверка этого поведения: `node --test scripts/test-pre-commit.mjs`.
|
||||||
|
|
||||||
## Контейнерный image
|
## Контейнерный image
|
||||||
@@ -116,14 +118,17 @@ Chromium при ширинах 375 и 1280 px. Browser project не исполь
|
|||||||
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
|
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
|
||||||
последовательно на PR; на push в `develop`/`main` — `security → verify`.
|
последовательно на PR; на push в `develop`/`main` — `security → verify`.
|
||||||
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
|
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
|
||||||
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
|
Изменение заголовка PR запускает `.gitea/workflows/pr-title.yml` (то же имя workflow,
|
||||||
|
только job `commits`): статусы `security` и `verify` на HEAD не перезаписываются.
|
||||||
Новый запуск отменяет устаревший для того же PR/ветки и типа события
|
Новый запуск отменяет устаревший для того же PR/ветки и типа события
|
||||||
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
|
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
|
||||||
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
|
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
|
||||||
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
|
`npm audit --include=dev --audit-level=moderate`. Advisory уровня moderate и выше
|
||||||
исключений frontend нет. Локально можно выполнить те же команды:
|
блокирует security job; low не блокирует, т.к. для них часто нет исправленной версии
|
||||||
|
(например, GHSA-gcqx-w48m-w2h4 в build-only deepmerge). Других исключений нет.
|
||||||
|
Локально можно выполнить те же команды:
|
||||||
`gitleaks git --log-opts=--all --redact=100 --no-banner` и
|
`gitleaks git --log-opts=--all --redact=100 --no-banner` и
|
||||||
`npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry;
|
`npm audit --include=dev --audit-level=moderate`. Audit требует доступа к npm registry;
|
||||||
проверка секретов выполняется локально и не выводит их значения.
|
проверка секретов выполняется локально и не выводит их значения.
|
||||||
|
|
||||||
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
|
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
|
||||||
@@ -203,7 +208,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,36 +217,61 @@ preset по имени из `.release-it.json`. Версия preset закреп
|
|||||||
## Релизы
|
## Релизы
|
||||||
|
|
||||||
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
|
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
|
||||||
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
|
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
|
||||||
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
|
общие правила — в Engineering Workflow по ссылке из AGENTS.
|
||||||
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
|
PR сливается squash или rebase-merge; merge-коммит не используется. `main`
|
||||||
|
обновляется только fast-forward из `develop`, поэтому история линейна:
|
||||||
|
|
||||||
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
|
```text
|
||||||
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
|
<type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z
|
||||||
`develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в
|
```
|
||||||
`release/hotfix-<name>` и проходит тот же процесс.
|
|
||||||
|
Инвариант: `origin/main` всегда предок `origin/develop`. Проверка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch -q origin
|
||||||
|
git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
|
||||||
|
включая `spec:`. Версии вручную не повышай.
|
||||||
|
|
||||||
|
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь
|
||||||
|
инвариант. Создай `release/X.Y.Z` от проверенного `develop`;
|
||||||
|
`nvm use --silent`, `npm ci`.
|
||||||
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
|
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
|
||||||
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
|
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
|
||||||
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
|
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
|
||||||
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
|
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
|
||||||
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
|
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
|
||||||
открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных
|
открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных
|
||||||
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `main`
|
`verify`, `security`, `commits` выполни fast-forward-only merge. Если
|
||||||
изменился, согласуй историю и повтори проверки кандидата.
|
`develop` изменился, обнови release-ветку и повтори проверки кандидата.
|
||||||
4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only.
|
||||||
|
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
||||||
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
|
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
|
||||||
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
|
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
|
||||||
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
|
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
|
||||||
5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь,
|
|
||||||
что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после
|
Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3.
|
||||||
merge создай отсутствующий тег без нового bump; до merge исправь и проверь
|
Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег
|
||||||
существующий кандидат без повторного повышения версии.
|
без нового bump; до merge исправь и проверь существующий кандидат без
|
||||||
|
повторного повышения версии.
|
||||||
|
|
||||||
|
Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают.
|
||||||
|
Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом;
|
||||||
|
пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение
|
||||||
|
требует явного решения: `release/hotfix-<name>` от `main`, PR в `main`, тег,
|
||||||
|
затем merge `main → develop` для восстановления инварианта.
|
||||||
|
|
||||||
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
|
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
|
||||||
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
|
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
|
||||||
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
|
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
|
||||||
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
|
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
|
||||||
администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены.
|
администратором, включена блокировка устаревшей ветки, обязательны `verify`,
|
||||||
|
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
|
||||||
|
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
|
||||||
|
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
|
||||||
Workflow сам не включает эти настройки. Отдельное review approval для одного
|
Workflow сам не включает эти настройки. Отдельное review approval для одного
|
||||||
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
|
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -17,12 +17,21 @@ http {
|
|||||||
default $http_x_forwarded_proto;
|
default $http_x_forwarded_proto;
|
||||||
'' $scheme;
|
'' $scheme;
|
||||||
}
|
}
|
||||||
|
# One shared budget: the external proxy's client-IP forwarding is not verified.
|
||||||
|
map "$request_method:$uri" $auth_admission_key {
|
||||||
|
default "";
|
||||||
|
POST:/api/v1/auth/login auth;
|
||||||
|
POST:/api/v1/auth/register auth;
|
||||||
|
}
|
||||||
|
limit_req_zone $auth_admission_key zone=auth_admission:1m rate=2r/s;
|
||||||
|
limit_req_status 429;
|
||||||
server {
|
server {
|
||||||
listen 8080;
|
listen 8080;
|
||||||
server_name _;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
resolver 127.0.0.11 valid=5s ipv6=off;
|
resolver 127.0.0.11 valid=5s ipv6=off;
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
limit_req zone=auth_admission burst=5 nodelay;
|
||||||
set $backend http://backend:3000;
|
set $backend http://backend:3000;
|
||||||
proxy_pass $backend$request_uri;
|
proxy_pass $backend$request_uri;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
|
|||||||
+6
-1
@@ -25,7 +25,11 @@ const readability = {
|
|||||||
)
|
)
|
||||||
context.report({ messageId: "schema", node });
|
context.report({ messageId: "schema", node });
|
||||||
},
|
},
|
||||||
FunctionDeclaration: checkParameters,
|
FunctionDeclaration(node) {
|
||||||
|
checkParameters(node);
|
||||||
|
if (node.id?.name?.endsWith?.("ViewModel"))
|
||||||
|
context.report({ messageId: "vmName", node: node.id });
|
||||||
|
},
|
||||||
FunctionExpression: checkParameters,
|
FunctionExpression: checkParameters,
|
||||||
ImportDeclaration(node) {
|
ImportDeclaration(node) {
|
||||||
if (
|
if (
|
||||||
@@ -95,6 +99,7 @@ const readability = {
|
|||||||
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
|
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
|
||||||
svg: "Own SVG markup in packages/icons, and import its public component.",
|
svg: "Own SVG markup in packages/icons, and import its public component.",
|
||||||
union: "Put complex union members on separate lines.",
|
union: "Put complex union members on separate lines.",
|
||||||
|
vmName: "Name custom hooks use*Vm, not use*ViewModel.",
|
||||||
},
|
},
|
||||||
schema: [],
|
schema: [],
|
||||||
type: "suggestion",
|
type: "suggestion",
|
||||||
|
|||||||
@@ -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 }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<meta name="theme-color" content="#ffffff" />
|
<meta name="theme-color" content="#ffffff" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>WealthLedger</title>
|
<title>WealthLedger</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"ignoreDependencies": ["conventional-changelog-conventionalcommits"],
|
"ignoreDependencies": ["conventional-changelog-conventionalcommits", "minimatch"],
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
".": {
|
".": {
|
||||||
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
|
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
|
||||||
|
|||||||
Generated
+1307
-2646
File diff suppressed because it is too large
Load Diff
+65
-50
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-frontend",
|
"name": "wealth-ledger-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.0",
|
"version": "2.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "24.21.0"
|
"node": "24.22.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run build:packages && vite",
|
"dev": "npm run build:packages && vite",
|
||||||
@@ -20,10 +20,10 @@
|
|||||||
"test:coverage": "npm run build:packages && vitest run --coverage",
|
"test:coverage": "npm run build:packages && vitest run --coverage",
|
||||||
"test:lint": "node --test scripts/test-eslint.mjs",
|
"test:lint": "node --test scripts/test-eslint.mjs",
|
||||||
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
|
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
|
||||||
"openapi:check": "node scripts/check-openapi.mjs",
|
"openapi:check": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts --check",
|
||||||
"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,85 +34,100 @@
|
|||||||
"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.2",
|
||||||
"@mantine/charts": "9.6.1",
|
"@mantine/charts": "9.7.3",
|
||||||
"@mantine/core": "9.6.1",
|
"@mantine/core": "9.7.3",
|
||||||
"@mantine/dates": "9.6.1",
|
"@mantine/dates": "9.7.3",
|
||||||
"@mantine/hooks": "9.6.1",
|
"@mantine/hooks": "9.7.3",
|
||||||
"@tanstack/react-query": "5.102.8",
|
"@mantine/notifications": "9.7.3",
|
||||||
|
"@tanstack/react-query": "5.104.5",
|
||||||
"@wealthledger/icons": "file:packages/icons",
|
"@wealthledger/icons": "file:packages/icons",
|
||||||
"@wealthledger/tokens": "file:packages/tokens",
|
"@wealthledger/tokens": "file:packages/tokens",
|
||||||
"@wealthledger/ui": "file:packages/ui",
|
"@wealthledger/ui": "file:packages/ui",
|
||||||
"dayjs": "1.11.23",
|
"dayjs": "1.11.23",
|
||||||
"i18next": "26.4.2",
|
"i18next": "26.5.10",
|
||||||
"motion": "13.2.0",
|
"motion": "13.5.1",
|
||||||
"openapi-fetch": "0.17.0",
|
"openapi-fetch": "0.17.0",
|
||||||
"openapi-react-query": "0.5.4",
|
"openapi-react-query": "0.5.4",
|
||||||
"react": "19.3.0",
|
"react": "19.3.1",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.1",
|
||||||
"react-hook-form": "7.88.0",
|
"react-hook-form": "7.91.0",
|
||||||
"react-i18next": "17.0.14",
|
"react-i18next": "17.1.8",
|
||||||
"react-is": "19.3.0",
|
"react-is": "19.3.1",
|
||||||
"react-router": "8.3.1",
|
"react-router": "8.5.0",
|
||||||
"recharts": "3.10.1",
|
"recharts": "3.10.1",
|
||||||
"zod": "4.6.4"
|
"zod": "4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "8.0.5",
|
"@babel/core": "8.0.9",
|
||||||
"@commitlint/cli": "21.2.2",
|
"@commitlint/cli": "21.2.4",
|
||||||
"@commitlint/config-conventional": "21.2.2",
|
"@commitlint/config-conventional": "21.2.4",
|
||||||
"@eslint-react/eslint-plugin": "5.21.0",
|
"@eslint-react/eslint-plugin": "5.28.8",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@release-it/conventional-changelog": "12.0.0",
|
"@release-it/conventional-changelog": "12.0.2",
|
||||||
"@rolldown/plugin-babel": "0.2.4",
|
"@rolldown/plugin-babel": "0.2.4",
|
||||||
"@storybook/addon-docs": "10.6.0",
|
"@storybook/addon-docs": "10.6.3",
|
||||||
"@storybook/react-vite": "10.6.0",
|
"@storybook/react-vite": "10.6.3",
|
||||||
"@stylistic/eslint-plugin": "5.10.0",
|
"@stylistic/eslint-plugin": "5.10.0",
|
||||||
"@tanstack/eslint-plugin-query": "5.104.0",
|
"@tanstack/eslint-plugin-query": "5.104.5",
|
||||||
"@testing-library/jest-dom": "7.0.1",
|
"@testing-library/jest-dom": "7.0.1",
|
||||||
"@testing-library/react": "16.3.3",
|
"@testing-library/react": "16.3.3",
|
||||||
"@types/node": "24.13.4",
|
"@types/node": "24.19.2",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.2",
|
||||||
"@vitest/browser-playwright": "5.0.0",
|
"@vitest/browser": "5.0.5",
|
||||||
"@vitest/coverage-v8": "5.0.0",
|
"@vitest/browser-playwright": "5.0.5",
|
||||||
|
"@vitest/coverage-v8": "5.0.5",
|
||||||
"@vitest/eslint-plugin": "1.6.27",
|
"@vitest/eslint-plugin": "1.6.27",
|
||||||
|
"@vitest/ui": "5.0.5",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"conventional-changelog-conventionalcommits": "10.4.0",
|
"conventional-changelog-conventionalcommits": "10.4.1",
|
||||||
"dependency-cruiser": "18.2.0",
|
"dependency-cruiser": "18.7.0",
|
||||||
"eslint": "10.11.0",
|
"eslint": "10.13.0",
|
||||||
"eslint-import-resolver-typescript": "4.4.5",
|
"eslint-import-resolver-typescript": "4.4.5",
|
||||||
"eslint-plugin-import-x": "4.17.1",
|
"eslint-plugin-import-x": "4.17.1",
|
||||||
"eslint-plugin-jest-dom": "5.10.1",
|
"eslint-plugin-jest-dom": "5.10.1",
|
||||||
"eslint-plugin-no-unsanitized": "4.1.5",
|
"eslint-plugin-no-unsanitized": "4.1.5",
|
||||||
"eslint-plugin-perfectionist": "5.12.1",
|
"eslint-plugin-perfectionist": "5.13.0",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
"eslint-plugin-react-refresh": "0.5.7",
|
"eslint-plugin-react-refresh": "0.5.7",
|
||||||
"eslint-plugin-regexp": "3.3.1",
|
"eslint-plugin-regexp": "3.3.1",
|
||||||
"eslint-plugin-security": "4.1.0",
|
"eslint-plugin-security": "4.2.0",
|
||||||
"eslint-plugin-sonarjs": "4.2.1",
|
"eslint-plugin-sonarjs": "4.2.2",
|
||||||
"eslint-plugin-storybook": "10.6.0",
|
"eslint-plugin-storybook": "10.6.3",
|
||||||
"eslint-plugin-testing-library": "7.16.2",
|
"eslint-plugin-testing-library": "7.16.2",
|
||||||
"eslint-plugin-unicorn": "76.0.0",
|
"eslint-plugin-unicorn": "76.0.0",
|
||||||
"globals": "17.12.0",
|
"globals": "17.13.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"jsdom": "30.0.1",
|
"jsdom": "30.1.3",
|
||||||
"knip": "6.35.1",
|
"knip": "6.43.0",
|
||||||
"lint-staged": "17.5.1",
|
"lint-staged": "17.6.0",
|
||||||
|
"minimatch": "file:scripts/minimatch-compat",
|
||||||
"msw": "2.15.0",
|
"msw": "2.15.0",
|
||||||
"openapi-typescript": "7.13.0",
|
"openapi-typescript": "7.13.0",
|
||||||
"playwright": "1.63.0",
|
"playwright": "1.64.1",
|
||||||
"release-it": "21.0.2",
|
"release-it": "21.1.1",
|
||||||
"storybook": "10.6.0",
|
"storybook": "10.6.3",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.70.1",
|
"typescript-eslint": "8.72.0",
|
||||||
"vite": "8.3.0",
|
"vite": "8.3.5",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "2.0.0",
|
||||||
"vitest": "5.0.0"
|
"vitest": "5.0.5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"release-it": {
|
||||||
|
"basic-ftp": "6.2.6",
|
||||||
|
"undici": "8.12.3"
|
||||||
|
},
|
||||||
|
"@vitest/browser": "5.0.5",
|
||||||
|
"@vitest/ui": "5.0.5",
|
||||||
|
"@vitest/mocker": "5.0.5",
|
||||||
|
"minimatch": "$minimatch",
|
||||||
|
"glob": "14.0.3"
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"esbuild@0.28.2": true,
|
"esbuild@0.28.2": true,
|
||||||
|
|||||||
@@ -20,14 +20,14 @@
|
|||||||
"build": "vite build && tsc -p tsconfig.json"
|
"build": "vite build && tsc -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "19.3.0"
|
"react": "19.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@storybook/react-vite": "10.6.0",
|
"@storybook/react-vite": "10.6.3",
|
||||||
"@testing-library/react": "16.3.3",
|
"@testing-library/react": "16.3.3",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vite": "8.3.0",
|
"vite": "8.3.5",
|
||||||
"vitest": "5.0.0"
|
"vitest": "5.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -27,18 +27,18 @@
|
|||||||
"@wealthledger/tokens": "0.3.0"
|
"@wealthledger/tokens": "0.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@mantine/core": "9.6.1",
|
"@mantine/core": "9.7.3",
|
||||||
"@mantine/hooks": "9.6.1",
|
"@mantine/hooks": "9.7.3",
|
||||||
"react": "19.3.0",
|
"react": "19.3.1",
|
||||||
"react-dom": "19.3.0"
|
"react-dom": "19.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@storybook/react-vite": "10.6.0",
|
"@storybook/react-vite": "10.6.3",
|
||||||
"@testing-library/react": "16.3.3",
|
"@testing-library/react": "16.3.3",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"storybook": "10.6.0",
|
"storybook": "10.6.3",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vite": "8.3.0",
|
"vite": "8.3.5",
|
||||||
"vitest": "5.0.0"
|
"vitest": "5.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } };
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ type EntryCardProps = {
|
|||||||
tone: "negative"
|
tone: "negative"
|
||||||
| "neutral"
|
| "neutral"
|
||||||
| "positive";
|
| "positive";
|
||||||
value: string;
|
value: ReactNode;
|
||||||
valueLabel: string;
|
valueLabel: string;
|
||||||
|
wrap?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EntryCard: FC<EntryCardProps> = (props) => {
|
const EntryCard: FC<EntryCardProps> = (props) => {
|
||||||
@@ -27,11 +28,19 @@ 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 : ""}`;
|
||||||
|
const valueComponent = typeof props.value === "string" ? undefined : "div";
|
||||||
|
|
||||||
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 +52,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>
|
||||||
|
|
||||||
@@ -55,7 +64,11 @@ const EntryCard: FC<EntryCardProps> = (props) => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack className={classes.value} gap={2}>
|
<Stack className={classes.value} gap={2}>
|
||||||
<Text className={classes[props.tone]} fw={700}>
|
<Text
|
||||||
|
className={classes[props.tone]}
|
||||||
|
component={valueComponent}
|
||||||
|
fw={700}
|
||||||
|
>
|
||||||
{props.value}
|
{props.value}
|
||||||
</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",
|
||||||
|
|||||||
+16
-1
@@ -1,15 +1,30 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": ["config:recommended", ":semanticCommitTypeAll(chore)"],
|
"extends": ["config:recommended", ":semanticCommitTypeAll(chore)", "security:minimumReleaseAgeNpm"],
|
||||||
"timezone": "Europe/Moscow",
|
"timezone": "Europe/Moscow",
|
||||||
"labels": ["dependencies"],
|
"labels": ["dependencies"],
|
||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"rangeStrategy": "pin",
|
"rangeStrategy": "pin",
|
||||||
|
"prConcurrentLimit": 5,
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true,
|
||||||
|
"schedule": ["before 4am on the first day of the month"]
|
||||||
|
},
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"matchUpdateTypes": ["minor", "patch"],
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
"groupName": "non-major dependencies"
|
"groupName": "non-major dependencies"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["github-actions"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "non-major github actions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["docker-compose", "dockerfile"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "non-major container images"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matchUpdateTypes": ["major"],
|
"matchUpdateTypes": ["major"],
|
||||||
"dependencyDashboardApproval": true
|
"dependencyDashboardApproval": true
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import nodePath from "node:path";
|
|
||||||
|
|
||||||
const temporaryDirectory = await mkdtemp(
|
|
||||||
nodePath.join(tmpdir(), "wealth-ledger-openapi-"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts");
|
|
||||||
const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts");
|
|
||||||
const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js");
|
|
||||||
let exitCode = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const generated = spawnSync(
|
|
||||||
process.execPath,
|
|
||||||
[cliPath, "src/shared/api/spec/openapi.json", "-o", generatedPath],
|
|
||||||
{ encoding: "utf8" },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (generated.status === 0) {
|
|
||||||
const [committed, current] = await Promise.all([
|
|
||||||
readFile(committedPath, "utf8"),
|
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
|
||||||
readFile(generatedPath, "utf8"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (committed !== current) {
|
|
||||||
console.error(
|
|
||||||
"Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.",
|
|
||||||
);
|
|
||||||
exitCode = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
process.stderr.write(generated.stderr || generated.stdout);
|
|
||||||
exitCode = generated.status ?? 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
await rm(temporaryDirectory, { force: true, recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exitCode = exitCode;
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// ponytail: CommonJS twin of index.js (import-x ships lib/index.cjs too).
|
||||||
|
const minimatch = require("minimatch-11");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...minimatch,
|
||||||
|
makeRe: (pattern, options = {}) => ({
|
||||||
|
test: path => minimatch.minimatch(path, pattern, options),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// ponytail: shim for eslint-plugin-import-x@4.17.1, which still imports the
|
||||||
|
// makeRe export removed in minimatch 11. Its only caller (no-internal-modules)
|
||||||
|
// uses the result via .test(), so a matcher object is enough. Delete this
|
||||||
|
// package and the override once import-x supports minimatch 11.
|
||||||
|
import { minimatch } from "minimatch-11";
|
||||||
|
|
||||||
|
export * from "minimatch-11";
|
||||||
|
|
||||||
|
export const makeRe = (pattern, options = {}) => ({
|
||||||
|
test: path => minimatch(path, pattern, options),
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "minimatch",
|
||||||
|
"version": "11.0.2-compat.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "minimatch 11 plus the makeRe export eslint-plugin-import-x 4.17.1 still imports. Remove when import-x supports minimatch 11.",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"import": "./index.js",
|
||||||
|
"require": "./index.cjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"minimatch-11": "npm:minimatch@11.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-1
@@ -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(); }",
|
||||||
@@ -70,7 +85,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
|
|||||||
"security/detect-non-literal-regexp",
|
"security/detect-non-literal-regexp",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"scripts/check-openapi.mjs",
|
"scripts/test-pre-commit.mjs",
|
||||||
"import { exec } from \"node:child_process\"; exec(process.argv[2]);",
|
"import { exec } from \"node:child_process\"; exec(process.argv[2]);",
|
||||||
"security/detect-child-process",
|
"security/detect-child-process",
|
||||||
],
|
],
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ test("pre-commit scans the index, redacts findings and fails closed", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
git("init", "--quiet");
|
git("init", "--quiet");
|
||||||
|
// The hook must run the installed lint-staged binary, never download one.
|
||||||
|
const localBin = nodePath.join(cwd, "node_modules", ".bin");
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||||
|
mkdirSync(localBin, { recursive: true });
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||||
|
writeFileSync(nodePath.join(localBin, "lint-staged"), "#!/bin/sh\necho ran >> downstream\n", { mode: 0o755 });
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||||
writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n");
|
writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n");
|
||||||
git("add", "sample.txt");
|
git("add", "sample.txt");
|
||||||
@@ -77,3 +83,37 @@ test("pre-commit scans the index, redacts findings and fails closed", () => {
|
|||||||
rmSync(cwd, { force: true, recursive: true });
|
rmSync(cwd, { force: true, recursive: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("pre-commit never downloads tools and blocks when lint-staged is missing", () => {
|
||||||
|
const hook = nodePath.resolve(".husky/pre-commit");
|
||||||
|
const cwd = mkdtempSync(nodePath.join(tmpdir(), "frontend-hook-"));
|
||||||
|
const bin = nodePath.join(cwd, "bin");
|
||||||
|
const marker = nodePath.join(cwd, "downstream");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||||
|
mkdirSync(bin);
|
||||||
|
// Gitleaks passes; npx/npm must never be reached, even if present on PATH.
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||||
|
writeFileSync(nodePath.join(bin, "gitleaks"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||||
|
|
||||||
|
for (const command of ["npx", "npm"]) {
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||||
|
writeFileSync(nodePath.join(bin, command), "#!/bin/sh\necho ran >> downstream\n", { mode: 0o755 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = spawnSync("/bin/sh", ["-e", hook], {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, PATH: `${bin}${nodePath.delimiter}${process.env.PATH}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.notEqual(result.status, 0, "missing lint-staged must block commit");
|
||||||
|
assert.match(result.stderr, /lint-staged is not installed/);
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
|
||||||
|
assert.ok(!existsSync(marker), "hook must not invoke npx or npm");
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
rmSync(cwd, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -11,19 +11,28 @@ import {
|
|||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { MemoryRouter, useLocation } from "react-router";
|
import { MemoryRouter, useLocation } from "react-router";
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||||
import type { IncomeAnalytics } from "@/entities/income";
|
import type { IncomeAnalytics } from "@/entities/income";
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
|
||||||
import { AuthProvider } from "@/features/auth";
|
import { AuthProvider } from "@/features/auth";
|
||||||
|
import { defaultPeriod, formatDate } from "@/shared/lib";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { resource } from "@/test/resource";
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
import { AppRoutes } from "./AppRoutes";
|
import { AppRoutes } from "./AppRoutes";
|
||||||
|
|
||||||
|
// Mantine calendar labels follow dayjs default (English) locale.
|
||||||
|
function dayLabel(date: string) {
|
||||||
|
const monthName = new Intl.DateTimeFormat("en", { month: "long" })
|
||||||
|
.format(new Date(`${date}T12:00:00Z`));
|
||||||
|
|
||||||
|
return `${Number(date.slice(8))} ${monthName} ${date.slice(0, 4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
const accounts = [
|
const accounts = [
|
||||||
{ id: "a", name: "Основной" },
|
{ id: "a", name: "Основной" },
|
||||||
{ id: "b", name: "ИИС" },
|
{ id: "b", name: "ИИС" },
|
||||||
@@ -37,19 +46,21 @@ const position: Portfolio["positions"][number]
|
|||||||
displayName: "Сбербанк",
|
displayName: "Сбербанк",
|
||||||
figi: "SBER",
|
figi: "SBER",
|
||||||
isin: null,
|
isin: null,
|
||||||
|
issuer: null,
|
||||||
logoName: null,
|
logoName: null,
|
||||||
quantity: "2",
|
quantity: "2",
|
||||||
|
sector: null,
|
||||||
ticker: "SBER",
|
ticker: "SBER",
|
||||||
type: "STOCK",
|
type: "STOCK",
|
||||||
};
|
};
|
||||||
|
|
||||||
const portfolio: Portfolio = {
|
const portfolio: Portfolio = {
|
||||||
cashBalances: [],
|
cash: { currency: "RUB", value: "0" },
|
||||||
isStale: false,
|
isStale: false,
|
||||||
lastSuccessfulAt: "2026-08-15T12:00:00Z",
|
lastSuccessfulAt: "2026-08-15T12:00:00Z",
|
||||||
positions: accounts.map(account => ({ ...position, account })),
|
positions: accounts.map(account => ({ ...position, account })),
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
|
||||||
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
|
totals: { currency: "RUB", value: "1200" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const receipt: BrokerageOperation
|
const receipt: BrokerageOperation
|
||||||
@@ -95,19 +106,11 @@ const analytics: IncomeAnalytics
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
period: { from: "2026-08-01", to: "2026-08-31" },
|
period: { from: "2026-08-01", to: "2026-08-31" },
|
||||||
previous: {
|
total: {
|
||||||
period: { from: "2026-07-01", to: "2026-07-31" },
|
|
||||||
totalsByCurrency: [],
|
|
||||||
},
|
|
||||||
totalsByCurrency: [
|
|
||||||
{
|
|
||||||
coupon: { currency: "RUB", value: "2" },
|
coupon: { currency: "RUB", value: "2" },
|
||||||
currency: "RUB",
|
|
||||||
dividend: { currency: "RUB", value: "0" },
|
dividend: { currency: "RUB", value: "0" },
|
||||||
total: { currency: "RUB", value: "2" },
|
total: { currency: "RUB", value: "2" },
|
||||||
},
|
},
|
||||||
],
|
|
||||||
yearComparison: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CurrentLocation: FC = () => {
|
const CurrentLocation: FC = () => {
|
||||||
@@ -141,6 +144,7 @@ function renderPage(path: string) {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post("*/api/v1/auth/refresh", () =>
|
http.post("*/api/v1/auth/refresh", () =>
|
||||||
@@ -209,7 +213,8 @@ beforeEach(() => {
|
|||||||
type: "coupon",
|
type: "coupon",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
incomeTotalsByCurrency: [],
|
incomeTotal: { currency: "RUB", value: "0" },
|
||||||
|
window: { from: "2026-09-01", to: "2026-10-01" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -219,9 +224,9 @@ beforeEach(() => {
|
|||||||
describe("All accounts routes", () => {
|
describe("All accounts routes", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["/accounts/all", "Все счета"],
|
["/accounts/all", "Все счета"],
|
||||||
["/accounts/all/operations", "Журнал операций"],
|
["/accounts/all/operations", "История операций"],
|
||||||
["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"],
|
["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"],
|
||||||
["/accounts/all/events", "Будущие события"],
|
["/accounts/all/events", "Предстоящие события"],
|
||||||
])("opens %s directly", async (path, title) => {
|
])("opens %s directly", async (path, title) => {
|
||||||
renderPage(path);
|
renderPage(path);
|
||||||
expect(
|
expect(
|
||||||
@@ -238,6 +243,61 @@ describe("All accounts routes", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["operations", "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("restores applied dates when switching accounts in events", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
vi.setSystemTime(new Date("2026-10-15T12:00:00Z"));
|
||||||
|
// The events calendar blocks past dates, so pick within selectable dates.
|
||||||
|
const search = `?from=${defaultPeriod()[0]}&to=${defaultPeriod()[1]}`;
|
||||||
|
renderPage(`/accounts/all/events${search}`);
|
||||||
|
const picker = await screen.findByLabelText("Период");
|
||||||
|
fireEvent.click(picker);
|
||||||
|
const [fromDate, toDate] = defaultPeriod();
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: dayLabel(fromDate) }),
|
||||||
|
);
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: dayLabel(toDate) }),
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||||
|
`${formatDate(fromDate)} – ${formatDate(toDate)}`,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
|
||||||
|
`/accounts/b/events${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);
|
||||||
@@ -246,14 +306,16 @@ describe("All accounts routes", () => {
|
|||||||
"href",
|
"href",
|
||||||
`/accounts/${account.id}`,
|
`/accounts/${account.id}`,
|
||||||
);
|
);
|
||||||
for (const suffix of ["operations", "income", "events"])
|
|
||||||
expect(
|
for (const [suffix, tab] of [
|
||||||
screen
|
["operations", "История операций"],
|
||||||
.getAllByRole("link")
|
["income", "Доход"],
|
||||||
.find(
|
["events", "Предстоящие события"],
|
||||||
link => link.getAttribute("href") === `/accounts/all/${suffix}`,
|
]) {
|
||||||
),
|
fireEvent.click(screen.getByRole("tab", { name: tab }));
|
||||||
).toBeInTheDocument();
|
expect(screen.getByRole("link", { name: "Подробнее" }))
|
||||||
|
.toHaveAttribute("href", `/accounts/all/${suffix}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches accounts within operations with applied filters and a fresh cursor", async () => {
|
it("switches accounts within operations with applied filters and a fresh cursor", async () => {
|
||||||
@@ -312,6 +374,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 +403,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 +426,12 @@ describe("All accounts routes", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
|
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
|
||||||
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("button", {
|
screen.queryByRole("button", {
|
||||||
name: "Повторить",
|
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([])),
|
||||||
@@ -133,6 +190,30 @@ describe("AppRoutes", () => {
|
|||||||
expect(document.title).toBe("Карты — WealthLedger");
|
expect(document.title).toBe("Карты — WealthLedger");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers a retry instead of the login form while the server is unreachable, then restores the route", async () => {
|
||||||
|
let isOutage = true;
|
||||||
|
server.use(
|
||||||
|
http.post("*/api/v1/auth/refresh", () =>
|
||||||
|
isOutage
|
||||||
|
? new HttpResponse(null, { status: 502 })
|
||||||
|
: HttpResponse.json({
|
||||||
|
accessToken: "test-token",
|
||||||
|
user: { email: "user@example.com", id: "user-1" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderRoutes("/accounts");
|
||||||
|
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось подключиться");
|
||||||
|
expect(screen.queryByRole("heading", { name: "Вход" })).not.toBeInTheDocument();
|
||||||
|
isOutage = false;
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { level: 2, name: "Нет инвестиционных счетов" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
it("lazy-loads the protected shell for an authenticated accounts route", async () => {
|
it("lazy-loads the protected shell for an authenticated accounts route", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post("*/api/v1/auth/refresh", () =>
|
http.post("*/api/v1/auth/refresh", () =>
|
||||||
@@ -151,7 +232,8 @@ describe("AppRoutes", () => {
|
|||||||
}),
|
}),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("navigation", { name: "WealthLedger" }),
|
screen.getAllByRole("navigation", { name: "Основная навигация" }),
|
||||||
).toBeInTheDocument();
|
).toHaveLength(2);
|
||||||
|
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { FC } from "react";
|
import type { ComponentType, FC } from "react";
|
||||||
|
|
||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router";
|
import { Navigate, Route, Routes } from "react-router";
|
||||||
@@ -6,101 +6,35 @@ import { Navigate, Route, Routes } from "react-router";
|
|||||||
import { LazyPage } from "../RouteFallback";
|
import { LazyPage } from "../RouteFallback";
|
||||||
import { Protected, Public } from "../RouteGuards";
|
import { Protected, Public } from "../RouteGuards";
|
||||||
|
|
||||||
const CardPage = lazy(async () => {
|
// Pages use named exports; React.lazy needs a default export per dynamic import (one chunk each).
|
||||||
const { CardPage } = await import("@/pages/card");
|
function lazyPage<Name extends string>(
|
||||||
|
load: () => Promise<Record<Name, ComponentType>>,
|
||||||
|
name: Name,
|
||||||
|
) {
|
||||||
|
return lazy(async () => {
|
||||||
|
const page = await load();
|
||||||
|
|
||||||
return { default: CardPage };
|
// eslint-disable-next-line security/detect-object-injection -- `name` is a compile-time literal checked against the module's exports.
|
||||||
|
return { default: page[name] };
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const CardsPage = lazy(async () => {
|
const CardPage = lazyPage(async () => import("@/pages/card"), "CardPage");
|
||||||
const { CardsPage } = await import("@/pages/cards");
|
const CardsPage = lazyPage(async () => import("@/pages/cards"), "CardsPage");
|
||||||
|
const BondPage = lazyPage(async () => import("@/pages/bond"), "BondPage");
|
||||||
return { default: CardsPage };
|
const BondsPage = lazyPage(async () => import("@/pages/bonds"), "BondsPage");
|
||||||
});
|
const StockPage = lazyPage(async () => import("@/pages/stock"), "StockPage");
|
||||||
|
const StocksPage = lazyPage(async () => import("@/pages/stocks"), "StocksPage");
|
||||||
const BondPage = lazy(async () => {
|
const AccountPage = lazyPage(async () => import("@/pages/account"), "AccountPage");
|
||||||
const { BondPage } = await import("@/pages/bond");
|
const AccountEventsPage = lazyPage(async () => import("@/pages/account-events"), "AccountEventsPage");
|
||||||
|
const AccountOperationsPage = lazyPage(async () => import("@/pages/account-operations"), "AccountOperationsPage");
|
||||||
return { default: BondPage };
|
const AccountIncomePage = lazyPage(async () => import("@/pages/account-income"), "AccountIncomePage");
|
||||||
});
|
const AccountsPage = lazyPage(async () => import("@/pages/accounts"), "AccountsPage");
|
||||||
|
const LoginPage = lazyPage(async () => import("@/pages/login"), "LoginPage");
|
||||||
const BondsPage = lazy(async () => {
|
const NotFoundPage = lazyPage(async () => import("@/pages/not-found"), "NotFoundPage");
|
||||||
const { BondsPage } = await import("@/pages/bonds");
|
const RegisterPage = lazyPage(async () => import("@/pages/register"), "RegisterPage");
|
||||||
|
const SettingsPage = lazyPage(async () => import("@/pages/settings"), "SettingsPage");
|
||||||
return { default: BondsPage };
|
const ProtectedLayout = lazyPage(async () => import("../ProtectedLayout"), "ProtectedLayout");
|
||||||
});
|
|
||||||
|
|
||||||
const StockPage = lazy(async () => {
|
|
||||||
const { StockPage } = await import("@/pages/stock");
|
|
||||||
|
|
||||||
return { default: StockPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const StocksPage = lazy(async () => {
|
|
||||||
const { StocksPage } = await import("@/pages/stocks");
|
|
||||||
|
|
||||||
return { default: StocksPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountPage = lazy(async () => {
|
|
||||||
const { AccountPage } = await import("@/pages/account");
|
|
||||||
|
|
||||||
return { default: AccountPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountEventsPage = lazy(async () => {
|
|
||||||
const { AccountEventsPage } = await import("@/pages/account-events");
|
|
||||||
|
|
||||||
return { default: AccountEventsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountOperationsPage = lazy(async () => {
|
|
||||||
const { AccountOperationsPage } = await import("@/pages/account-operations");
|
|
||||||
|
|
||||||
return { default: AccountOperationsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountIncomePage = lazy(async () => {
|
|
||||||
const { AccountIncomePage } = await import("@/pages/account-income");
|
|
||||||
|
|
||||||
return { default: AccountIncomePage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountsPage = lazy(async () => {
|
|
||||||
const { AccountsPage } = await import("@/pages/accounts");
|
|
||||||
|
|
||||||
return { default: AccountsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const LoginPage = lazy(async () => {
|
|
||||||
const { LoginPage } = await import("@/pages/login");
|
|
||||||
|
|
||||||
return { default: LoginPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const NotFoundPage = lazy(async () => {
|
|
||||||
const { NotFoundPage } = await import("@/pages/not-found");
|
|
||||||
|
|
||||||
return { default: NotFoundPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const RegisterPage = lazy(async () => {
|
|
||||||
const { RegisterPage } = await import("@/pages/register");
|
|
||||||
|
|
||||||
return { default: RegisterPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const SettingsPage = lazy(async () => {
|
|
||||||
const { SettingsPage } = await import("@/pages/settings");
|
|
||||||
|
|
||||||
return { default: SettingsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const ProtectedLayout = lazy(async () => {
|
|
||||||
const { ProtectedLayout } = await import("../ProtectedLayout");
|
|
||||||
|
|
||||||
return { default: ProtectedLayout };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AppRoutes: FC = () => (
|
const AppRoutes: FC = () => (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -200,42 +134,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">
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
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";
|
||||||
import { useStock } from "@/entities/stock";
|
import { useStock } from "@/entities/stock";
|
||||||
|
import { useAppDocumentTitle } from "@/shared/ui";
|
||||||
|
|
||||||
type DocumentTitleProps = { title: string };
|
type DocumentTitleProps = { title: string };
|
||||||
|
|
||||||
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
|
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
|
||||||
const { t } = useTranslation();
|
useAppDocumentTitle(props.title);
|
||||||
useDocumentTitle(`${props.title} — ${t("appName")}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type AccountDocumentTitleProps = {
|
type AccountDocumentTitleProps = {
|
||||||
@@ -82,14 +81,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 (
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import type { FC, PropsWithChildren } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
|
|
||||||
import { Button, Stack, Text, Title } from "@mantine/core";
|
import { Button, Stack, Text, Title } from "@mantine/core";
|
||||||
import { useDocumentTitle } from "@mantine/hooks";
|
|
||||||
import { Component } from "react";
|
import { Component } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useLocation } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
|
|
||||||
import { useAuth } from "@/features/auth";
|
import { useAuth } from "@/features/auth";
|
||||||
|
import { useAppDocumentTitle } from "@/shared/ui";
|
||||||
|
|
||||||
type BoundaryProps = PropsWithChildren<{
|
type BoundaryProps = PropsWithChildren<{
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
routeKey: string;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type RouteErrorFallbackProps = {
|
type RouteErrorFallbackProps = {
|
||||||
@@ -24,15 +23,9 @@ 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 render() {
|
||||||
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
|
|
||||||
this.setState({ hasError: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return this.state.hasError
|
return this.state.hasError
|
||||||
? <RouteErrorFallback authenticated={this.props.authenticated} />
|
? <RouteErrorFallback authenticated={this.props.authenticated} />
|
||||||
: (
|
: (
|
||||||
@@ -44,7 +37,7 @@ class Boundary extends Component<BoundaryProps, State> {
|
|||||||
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
|
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
|
||||||
const { authenticated } = props;
|
const { authenticated } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`);
|
useAppDocumentTitle(t("routeError.title"));
|
||||||
const to = authenticated ? "/accounts" : "/login";
|
const to = authenticated ? "/accounts" : "/login";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,10 +63,7 @@ const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Boundary
|
<Boundary authenticated={Boolean(user)} key={`${pathname}${search}:${key}`}>
|
||||||
authenticated={Boolean(user)}
|
|
||||||
routeKey={`${pathname}${search}:${key}`}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Boundary>
|
</Boundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,7 @@
|
|||||||
import type { FC, PropsWithChildren } from "react";
|
import { Button, Container } from "@mantine/core";
|
||||||
|
import { StateView } from "@wealthledger/ui";
|
||||||
|
import { type FC, type PropsWithChildren, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Navigate, useLocation } from "react-router";
|
import { Navigate, useLocation } from "react-router";
|
||||||
|
|
||||||
import { useAuth } from "@/features/auth";
|
import { useAuth } from "@/features/auth";
|
||||||
@@ -8,16 +10,36 @@ 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, retry, signedOut, unavailable, user } = useAuth();
|
||||||
|
const { t } = useTranslation();
|
||||||
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" />;
|
||||||
|
|
||||||
|
// The server was unreachable (e.g. mid-deploy), so the session is unproven, not gone.
|
||||||
|
if (!user && unavailable)
|
||||||
|
return (
|
||||||
|
<Container py="xl" size={560}>
|
||||||
|
<StateView
|
||||||
|
action={<Button onClick={retry}>{t("auth.retry")}</Button>}
|
||||||
|
error
|
||||||
|
text={t("auth.unavailableText")}
|
||||||
|
title={t("auth.unavailableTitle")}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 +52,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,88 +2,59 @@ 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";
|
||||||
import { BondBreadcrumbs } from "./BondBreadcrumbs";
|
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
import { CardBreadcrumbs } from "./CardBreadcrumbs";
|
import { EntityBreadcrumbs } from "./EntityBreadcrumbs";
|
||||||
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" });
|
if (bond) {
|
||||||
const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
|
return <EntityBreadcrumbs identifier={bond.params.bondId ?? ""} kind="bond" />;
|
||||||
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" });
|
if (stock) {
|
||||||
const bond = useMatch({ end: true, path: "/bonds/:bondId" });
|
return (
|
||||||
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
|
<EntityBreadcrumbs identifier={stock.params.stockId ?? ""} kind="stock" />
|
||||||
if (stock)
|
);
|
||||||
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
|
if (card) {
|
||||||
accountId={events.params.accountId ?? ""}
|
return <EntityBreadcrumbs identifier={card.params.cardId ?? ""} kind="card" />;
|
||||||
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 (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
||||||
|
|
||||||
if (accounts)
|
if (accounts)
|
||||||
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useBond } from "@/entities/bond";
|
|
||||||
|
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
|
||||||
|
|
||||||
type BondBreadcrumbsProps = { identifier: string };
|
|
||||||
|
|
||||||
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
|
|
||||||
const { data } = useBond(props.identifier);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BreadcrumbTrail
|
|
||||||
items={[
|
|
||||||
{ label: t("bond.searchTitle"), to: "/bonds" },
|
|
||||||
{ label: data?.name ?? t("bond.instrument") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { BondBreadcrumbs };
|
|
||||||
@@ -39,4 +39,4 @@ const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { BreadcrumbTrail };
|
export { BreadcrumbTrail, type Crumb };
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useBankCard } from "@/entities/bank-card";
|
|
||||||
|
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
|
||||||
|
|
||||||
type CardBreadcrumbsProps = { identifier: string };
|
|
||||||
|
|
||||||
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
|
|
||||||
const { data } = useBankCard(props.identifier);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BreadcrumbTrail
|
|
||||||
items={[
|
|
||||||
{ label: t("bank.title"), to: "/cards" },
|
|
||||||
{ label: data?.name ?? t("bank.card") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { CardBreadcrumbs };
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useBankCard } from "@/entities/bank-card";
|
||||||
|
import { useBond } from "@/entities/bond";
|
||||||
|
import { useStock } from "@/entities/stock";
|
||||||
|
|
||||||
|
import type { Crumb } from "./BreadcrumbTrail";
|
||||||
|
|
||||||
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
|
|
||||||
|
type EntityBreadcrumbsProps = {
|
||||||
|
identifier: string;
|
||||||
|
kind:
|
||||||
|
| "bond"
|
||||||
|
| "card"
|
||||||
|
| "stock";
|
||||||
|
};
|
||||||
|
|
||||||
|
const EntityBreadcrumbs: FC<EntityBreadcrumbsProps> = (props) => {
|
||||||
|
const { identifier, kind } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const bond = useBond(kind === "bond" ? identifier : "");
|
||||||
|
const card = useBankCard(kind === "card" ? identifier : "");
|
||||||
|
const stock = useStock(kind === "stock" ? identifier : "");
|
||||||
|
|
||||||
|
let parent: Crumb;
|
||||||
|
let name: string;
|
||||||
|
|
||||||
|
if (kind === "bond") {
|
||||||
|
parent = { label: t("bond.searchTitle"), to: "/bonds" };
|
||||||
|
name = bond.data?.name ?? t("bond.instrument");
|
||||||
|
}
|
||||||
|
else if (kind === "stock") {
|
||||||
|
parent = { label: t("stock.searchTitle"), to: "/stocks" };
|
||||||
|
name = stock.data?.name ?? t("stock.share");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
parent = { label: t("bank.title"), to: "/cards" };
|
||||||
|
name = card.data?.name ?? t("bank.card");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BreadcrumbTrail items={[parent, { label: name }]} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { EntityBreadcrumbs };
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useStock } from "@/entities/stock";
|
|
||||||
|
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
|
||||||
|
|
||||||
type StockBreadcrumbsProps = { identifier: string };
|
|
||||||
|
|
||||||
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
|
|
||||||
const { data } = useStock(props.identifier);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BreadcrumbTrail
|
|
||||||
items={[
|
|
||||||
{ label: t("stock.searchTitle"), to: "/stocks" },
|
|
||||||
{ label: data?.name ?? t("stock.share") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { StockBreadcrumbs };
|
|
||||||
@@ -1,12 +1,22 @@
|
|||||||
|
.root {
|
||||||
|
--bottom-nav-height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
max-width: 1120px;
|
max-width: 1120px;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.brand {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--mantine-color-gray-3);
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0 var(--mantine-spacing-md);
|
min-height: 60px;
|
||||||
|
padding-inline: var(--mantine-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navList {
|
||||||
|
padding: var(--mantine-spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottomNav {
|
.bottomNav {
|
||||||
@@ -52,29 +62,85 @@
|
|||||||
.bottomNav {
|
.bottomNav {
|
||||||
background: var(--mantine-color-white);
|
background: var(--mantine-color-white);
|
||||||
border-top: 1px solid var(--mantine-color-gray-3);
|
border-top: 1px solid var(--mantine-color-gray-3);
|
||||||
bottom: 0;
|
display: grid;
|
||||||
display: flex;
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
justify-content: space-around;
|
inset: auto 0 0;
|
||||||
left: 0;
|
min-height: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||||
padding: var(--mantine-spacing-xs) 2px;
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
gap: 2px;
|
padding-inline:
|
||||||
font-size: 11px;
|
max(4px, env(safe-area-inset-left, 0px))
|
||||||
& a {
|
max(4px, env(safe-area-inset-right, 0px));
|
||||||
min-width: 0;
|
|
||||||
padding: 8px 2px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 0;
|
z-index: var(--mantine-z-index-app);
|
||||||
z-index: 200;
|
}
|
||||||
|
|
||||||
|
.bottomNavItem {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--mantine-color-gray-7);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: var(--bottom-nav-height);
|
||||||
|
min-width: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:active .iconPill {
|
||||||
|
background-color: var(--mantine-color-gray-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--mantine-primary-color-filled);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.bottomNavItem:hover .iconPill {
|
||||||
|
background-color: var(--mantine-color-gray-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconPill {
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
height: 28px;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomNavLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 16px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.bottomNavItem,
|
||||||
|
.iconPill {
|
||||||
|
transition:
|
||||||
|
background-color 150ms ease-in-out,
|
||||||
|
color 150ms ease-in-out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
color: var(--mantine-primary-color-filled);
|
color: var(--mantine-primary-color-filled);
|
||||||
font-weight: 600;
|
|
||||||
|
& .iconPill {
|
||||||
|
background-color: var(--mantine-primary-color-light);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
padding-bottom: 5rem;
|
padding-bottom: calc(
|
||||||
|
var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px) +
|
||||||
|
var(--mantine-spacing-md)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { AppShell } from "./AppShell";
|
||||||
|
|
||||||
|
function renderShell(path: string) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<AppShell />
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AppShell", () => {
|
||||||
|
it("has no header banner", () => {
|
||||||
|
renderShell("/cards");
|
||||||
|
|
||||||
|
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks only the current bottom navigation item", () => {
|
||||||
|
renderShell("/cards");
|
||||||
|
|
||||||
|
const bottomNav = screen
|
||||||
|
.getAllByRole("navigation", { name: "Основная навигация" })
|
||||||
|
.at(-1);
|
||||||
|
|
||||||
|
if (!bottomNav) {
|
||||||
|
throw new Error("Bottom navigation is not rendered");
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = within(bottomNav).getAllByRole("link");
|
||||||
|
|
||||||
|
expect(links).toHaveLength(5);
|
||||||
|
expect(within(bottomNav).getByRole("link", { name: "Карты" })).toHaveAttribute(
|
||||||
|
"aria-current",
|
||||||
|
"page",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
within(bottomNav).getByRole("link", { name: "Счета" }),
|
||||||
|
).not.toHaveAttribute("aria-current");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,12 @@ type AppShellProps = {
|
|||||||
contentAboveOutlet?: ReactNode;
|
contentAboveOutlet?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getBottomNavItemClass({ isActive }: { isActive: boolean }) {
|
||||||
|
return isActive
|
||||||
|
? `${classes.bottomNavItem} ${classes.active}`
|
||||||
|
: classes.bottomNavItem;
|
||||||
|
}
|
||||||
|
|
||||||
const AppShell: FC<AppShellProps> = (props) => {
|
const AppShell: FC<AppShellProps> = (props) => {
|
||||||
const { contentAboveOutlet } = props;
|
const { contentAboveOutlet } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -24,31 +30,31 @@ const AppShell: FC<AppShellProps> = (props) => {
|
|||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
icon: <PortfolioIcon size={20} />,
|
Icon: PortfolioIcon,
|
||||||
iconClassName: classes.portfolioIcon,
|
iconClassName: classes.portfolioIcon,
|
||||||
label: t("navigation.accounts"),
|
label: t("navigation.accounts"),
|
||||||
to: "/accounts",
|
to: "/accounts",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <CardsIcon size={20} />,
|
Icon: CardsIcon,
|
||||||
iconClassName: classes.cardsIcon,
|
iconClassName: classes.cardsIcon,
|
||||||
label: t("navigation.cards"),
|
label: t("navigation.cards"),
|
||||||
to: "/cards",
|
to: "/cards",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <StocksIcon size={20} />,
|
Icon: StocksIcon,
|
||||||
iconClassName: classes.stocksIcon,
|
iconClassName: classes.stocksIcon,
|
||||||
label: t("navigation.stocks"),
|
label: t("navigation.stocks"),
|
||||||
to: "/stocks",
|
to: "/stocks",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BondsIcon size={20} />,
|
Icon: BondsIcon,
|
||||||
iconClassName: classes.bondsIcon,
|
iconClassName: classes.bondsIcon,
|
||||||
label: t("navigation.bonds"),
|
label: t("navigation.bonds"),
|
||||||
to: "/bonds",
|
to: "/bonds",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <SettingsIcon size={20} />,
|
Icon: SettingsIcon,
|
||||||
iconClassName: classes.settingsIcon,
|
iconClassName: classes.settingsIcon,
|
||||||
label: t("navigation.settings"),
|
label: t("navigation.settings"),
|
||||||
to: "/settings",
|
to: "/settings",
|
||||||
@@ -57,15 +63,16 @@ const AppShell: FC<AppShellProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineAppShell
|
<MantineAppShell
|
||||||
header={{ height: 60 }}
|
className={classes.root}
|
||||||
navbar={{ breakpoint: "sm", collapsed: { mobile: true }, width: 220 }}
|
navbar={{ breakpoint: "sm", collapsed: { mobile: true }, width: 220 }}
|
||||||
padding="md"
|
padding="md"
|
||||||
>
|
>
|
||||||
<MantineAppShell.Header className={classes.header}>
|
<MantineAppShell.Navbar aria-label={t("navigation.primary")}>
|
||||||
|
<div className={classes.brand}>
|
||||||
<Text fw={700}>{t("appName")}</Text>
|
<Text fw={700}>{t("appName")}</Text>
|
||||||
</MantineAppShell.Header>
|
</div>
|
||||||
|
|
||||||
<MantineAppShell.Navbar p="sm">
|
<div className={classes.navList}>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<NavLink
|
<NavLink
|
||||||
active={pathname.startsWith(item.to)}
|
active={pathname.startsWith(item.to)}
|
||||||
@@ -74,15 +81,14 @@ const AppShell: FC<AppShellProps> = (props) => {
|
|||||||
key={item.to}
|
key={item.to}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
leftSection={(
|
leftSection={(
|
||||||
<span
|
<span className={`${classes.navIcon} ${item.iconClassName}`}>
|
||||||
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
|
<item.Icon size={20} />
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</MantineAppShell.Navbar>
|
</MantineAppShell.Navbar>
|
||||||
|
|
||||||
<MantineAppShell.Main className={classes.main}>
|
<MantineAppShell.Main className={classes.main}>
|
||||||
@@ -93,22 +99,23 @@ const AppShell: FC<AppShellProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</MantineAppShell.Main>
|
</MantineAppShell.Main>
|
||||||
|
|
||||||
<nav aria-label={t("appName")} className={classes.bottomNav}>
|
<nav
|
||||||
{items.map((item) => {
|
aria-label={t("navigation.primary")}
|
||||||
const appearanceClass = pathname.startsWith(item.to)
|
className={classes.bottomNav}
|
||||||
? classes.active
|
>
|
||||||
: undefined;
|
{items.map(item => (
|
||||||
|
|
||||||
return (
|
|
||||||
<RouterNavLink
|
<RouterNavLink
|
||||||
className={appearanceClass}
|
className={getBottomNavItemClass}
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
>
|
>
|
||||||
{item.label}
|
<span className={classes.iconPill}>
|
||||||
|
<item.Icon size={24} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={classes.bottomNavLabel}>{item.label}</span>
|
||||||
</RouterNavLink>
|
</RouterNavLink>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
</MantineAppShell>
|
</MantineAppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ApiErrorCode,
|
ApiErrorCode,
|
||||||
apiErrorCode,
|
apiErrorCode,
|
||||||
isApiErrorCode,
|
isApiErrorCode,
|
||||||
|
isGetUnder,
|
||||||
} from "@/shared/api";
|
} from "@/shared/api";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -14,7 +15,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,48 +27,83 @@ async function mutationData<T>(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useBankMutations() {
|
const bankCardGetPredicate = isGetUnder(["/api/v1/bank-cards"]);
|
||||||
|
|
||||||
|
function useBankMutationOptions() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidate = () =>
|
const invalidate = () =>
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
predicate: ({ queryKey }) => {
|
predicate: ({ queryKey }) => bankCardGetPredicate(queryKey),
|
||||||
const [method, path] = queryKey;
|
|
||||||
|
|
||||||
return (
|
|
||||||
method === "get"
|
|
||||||
&& typeof path === "string"
|
|
||||||
&& (path === "/api/v1/bank-cards"
|
|
||||||
|| path.startsWith("/api/v1/bank-cards/"))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 +113,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 +128,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 +143,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 };
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
function errorCode(error: unknown): string | undefined {
|
||||||
|
return typeof error === "object" && error !== null && "code" in error
|
||||||
|
? String(error.code)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { errorCode };
|
||||||
@@ -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,10 +1,12 @@
|
|||||||
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 paymentSystemValues = ["mir", "visa", "mastercard"] as const;
|
||||||
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 +36,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") },
|
||||||
);
|
);
|
||||||
@@ -65,7 +64,7 @@ const bankCardSchema = z.object({
|
|||||||
openingBalance: moneySchema,
|
openingBalance: moneySchema,
|
||||||
openingDate: bankDateSchema,
|
openingDate: bankDateSchema,
|
||||||
paymentSystem: z
|
paymentSystem: z
|
||||||
.enum(["", "mir", "visa", "mastercard"], {
|
.enum(["", ...paymentSystemValues], {
|
||||||
error: validationMessage("bank.validation.paymentSystem"),
|
error: validationMessage("bank.validation.paymentSystem"),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
@@ -77,5 +76,6 @@ export {
|
|||||||
bankCardSchema,
|
bankCardSchema,
|
||||||
type BankCardValues,
|
type BankCardValues,
|
||||||
bankDateSchema,
|
bankDateSchema,
|
||||||
|
paymentSystemValues,
|
||||||
positiveMoneySchema,
|
positiveMoneySchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,8 @@
|
|||||||
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, paymentSystemValues } from "../../model/form.schema";
|
||||||
|
import { bankDateField } from "../bankDateField";
|
||||||
import { useOpeningDateField } from "./useOpeningDateField";
|
|
||||||
import { usePaymentSystemField } from "./usePaymentSystemField";
|
|
||||||
|
|
||||||
function useBankCardFieldsVm() {
|
function useBankCardFieldsVm() {
|
||||||
const {
|
const {
|
||||||
@@ -14,10 +12,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 = paymentSystemValues.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 };
|
|
||||||
@@ -3,7 +3,7 @@ import type { FC } from "react";
|
|||||||
import { Alert, Button, Group, Text } from "@mantine/core";
|
import { Alert, Button, Group, Text } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { errorCode } from "../model/error-code";
|
||||||
|
|
||||||
type BankFeedbackProps = {
|
type BankFeedbackProps = {
|
||||||
cached?: boolean;
|
cached?: boolean;
|
||||||
@@ -14,7 +14,7 @@ type BankFeedbackProps = {
|
|||||||
|
|
||||||
const BankFeedback: FC<BankFeedbackProps> = (props) => {
|
const BankFeedback: FC<BankFeedbackProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const code = apiErrorCode(props.error);
|
const code = errorCode(props.error);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert color="orange" role="alert">
|
<Alert color="orange" role="alert">
|
||||||
|
|||||||
@@ -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,15 +1,16 @@
|
|||||||
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 { BankFeedback } from "../BankFeedback";
|
||||||
import classes from "./BankSummary.module.css";
|
import classes from "./BankSummary.module.css";
|
||||||
|
|
||||||
const CATEGORY_COLUMNS = 2;
|
const CATEGORY_COLUMNS = 2;
|
||||||
@@ -17,10 +18,10 @@ const CATEGORY_COLUMNS = 2;
|
|||||||
type BankSummaryViewProps = {
|
type BankSummaryViewProps = {
|
||||||
all?: boolean;
|
all?: boolean;
|
||||||
data?: BankSummary;
|
data?: BankSummary;
|
||||||
error: boolean;
|
error: unknown;
|
||||||
fetching: boolean;
|
fetching: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
retry: () => void;
|
retry?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
|
const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
|
||||||
@@ -78,6 +79,8 @@ const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
|
|||||||
</LoadingSkeleton>
|
</LoadingSkeleton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
||||||
@@ -91,25 +94,44 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
|||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
{props.data && (
|
{props.data && (
|
||||||
<Text className={classes.total}>
|
<dl className={classes.totals}>
|
||||||
|
<div>
|
||||||
|
<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 })}
|
{formatMoney(props.data.net, { withZeroCents: true })}
|
||||||
</Text>
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BankSummaryViewCategories {...props} />
|
<BankSummaryViewCategories {...props} />
|
||||||
|
|
||||||
{props.error && (
|
{Boolean(props.error) && (
|
||||||
<Group role="alert">
|
<BankFeedback
|
||||||
<Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text>
|
cached={Boolean(props.data)}
|
||||||
|
error={props.error}
|
||||||
<Button onClick={props.retry} variant="default">
|
loadingError={!props.data}
|
||||||
{t("bank.retry")}
|
retry={props.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 };
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user