From f876411a37d7bdce4e614366cfbc2d7d7cb072f8 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 22 Sep 2026 22:11:02 +0300 Subject: [PATCH] refactor: establish local frontend workspace boundaries OpenSpec change: evolve-frontend-stack --- .ai/architecture.md | 31 +- .ai/conventions/storybook.md | 5 +- .ai/conventions/ui.md | 30 +- .dockerignore | 27 ++ .storybook/main.ts | 2 +- .storybook/preview.tsx | 3 +- Dockerfile | 4 + README.md | 33 +- biome.json | 4 +- dependency-cruiser.cjs | 13 + knip.json | 17 +- package-lock.json | 59 +++ package.json | 35 +- packages/icons/package.json | 30 ++ packages/icons/src/Icons.stories.tsx | 45 ++ packages/icons/src/Icons.test.tsx | 34 ++ packages/icons/src/index.ts | 4 + packages/icons/src/svg/AddIcon.tsx | 24 + packages/icons/src/svg/DeleteIcon.tsx | 25 + packages/icons/src/svg/EditIcon.tsx | 25 + packages/icons/src/types.ts | 10 + packages/icons/tsconfig.json | 17 + packages/icons/vite.config.ts | 8 + packages/tokens/package.json | 12 + .../theme => packages/tokens}/tokens.json | 0 packages/ui/package.json | 44 ++ .../ui/src}/EntryCard.module.css | 4 +- packages/ui/src/EntryCard.stories.tsx | 37 ++ packages/ui/src/EntryCard.test.tsx | 35 ++ .../ui => packages/ui/src}/EntryCard.tsx | 2 +- .../ui/src}/LoadingSkeleton.stories.tsx | 0 .../ui/src}/LoadingSkeleton.tsx | 0 .../ui/src}/StateView.stories.tsx | 0 .../ui => packages/ui/src}/StateView.test.tsx | 0 .../ui => packages/ui/src}/StateView.tsx | 0 {src/shared/ui => packages/ui/src}/index.ts | 1 - .../theme => packages/ui/src}/styles.css | 0 .../theme => packages/ui/src}/theme.test.ts | 2 +- .../shared/theme => packages/ui/src}/theme.ts | 3 +- packages/ui/tsconfig.json | 19 + packages/ui/vite.config.ts | 14 + src/app/routes/AppRoutes.test.tsx | 3 +- src/app/routes/ProtectedLayout.tsx | 3 +- src/app/ui/App.tsx | 2 +- src/{shared => app}/ui/AppShell.module.css | 0 src/{shared => app}/ui/AppShell.tsx | 2 +- src/app/ui/index.ts | 1 + src/entities/bank-card/index.ts | 1 + .../bank-card/model/operation-kinds.ts | 10 + .../ui/BankOperationIcon.stories.tsx | 2 +- .../bank-card/ui/BankSummary.view.tsx | 2 +- .../broker-operation/ui/LedgerEntryRow.tsx | 2 +- .../brokerage-event/ui/BrokerageEventCard.tsx | 2 +- .../ui/BrokerageEventsPreview.tsx | 2 +- .../ui/InstrumentSection.view.tsx | 2 +- .../ui/TbankConnectionSection.view.tsx | 2 +- src/main.tsx | 1 + .../AccountEventsPageEmpty.view.tsx | 2 +- .../AccountEventsPageError.view.tsx | 2 +- .../AccountEventsPageLoading.view.tsx | 2 +- src/pages/account-income/model/period.ts | 46 ++ .../ui/AccountIncomePage.test.tsx | 3 +- .../account-income/ui/AccountIncomePage.vm.ts | 50 +- .../AccountIncomePageError.view.tsx | 2 +- .../AccountIncomePageLoaded.view.tsx | 2 +- .../AccountIncomePageLoading.view.tsx | 2 +- .../components/IncomePeriodForm.stories.tsx | 58 +++ .../ui/components/IncomePeriodForm.view.tsx | 4 +- .../AccountOperationsPageError.view.tsx | 2 +- .../AccountOperationsPageLoaded.view.tsx | 2 +- .../AccountOperationsPageLoading.view.tsx | 2 +- .../ui/components/AccountPageEmpty.view.tsx | 2 +- .../ui/components/AccountPageError.view.tsx | 2 +- .../ui/components/AccountPageLoading.view.tsx | 2 +- .../ui/components/IncomeSummary.view.tsx | 2 +- .../ui/components/RecentOperations.view.tsx | 2 +- .../ui/components/AccountsPageEmpty.view.tsx | 2 +- .../ui/components/AccountsPageError.view.tsx | 2 +- .../components/AccountsPageLoading.view.tsx | 2 +- src/pages/bond/ui/BondPage.tsx | 2 +- src/pages/bond/ui/BondPage.view.tsx | 2 +- src/pages/bonds/ui/BondsPage.view.tsx | 2 +- src/pages/card/ui/CardPage.view.tsx | 94 +--- src/pages/card/ui/OperationForm.stories.tsx | 113 +++++ src/pages/card/ui/OperationForm.test.tsx | 38 ++ src/pages/card/ui/OperationForm.tsx | 453 ++---------------- src/pages/card/ui/OperationForm.types.ts | 11 +- src/pages/card/ui/OperationForm.view.tsx | 170 +++++-- src/pages/card/ui/OperationForm.vm.ts | 318 ++++++++++++ src/pages/cards/ui/CardsPage.view.tsx | 16 +- src/pages/not-found/ui/NotFoundPage.tsx | 2 +- src/pages/stock/ui/StockPage.tsx | 2 +- src/pages/stock/ui/StockPage.view.tsx | 2 +- src/pages/stocks/ui/StocksPage.view.tsx | 2 +- src/shared/theme/Foundations.stories.tsx | 6 +- src/shared/theme/contrast.test.ts | 2 +- src/shared/theme/index.ts | 3 - src/test/storybook.test.tsx | 9 +- tsconfig.app.json | 2 +- tsconfig.node.json | 6 +- vitest.config.ts | 8 +- 101 files changed, 1487 insertions(+), 666 deletions(-) create mode 100644 packages/icons/package.json create mode 100644 packages/icons/src/Icons.stories.tsx create mode 100644 packages/icons/src/Icons.test.tsx create mode 100644 packages/icons/src/index.ts create mode 100644 packages/icons/src/svg/AddIcon.tsx create mode 100644 packages/icons/src/svg/DeleteIcon.tsx create mode 100644 packages/icons/src/svg/EditIcon.tsx create mode 100644 packages/icons/src/types.ts create mode 100644 packages/icons/tsconfig.json create mode 100644 packages/icons/vite.config.ts create mode 100644 packages/tokens/package.json rename {src/shared/theme => packages/tokens}/tokens.json (100%) create mode 100644 packages/ui/package.json rename {src/shared/ui => packages/ui/src}/EntryCard.module.css (93%) create mode 100644 packages/ui/src/EntryCard.stories.tsx create mode 100644 packages/ui/src/EntryCard.test.tsx rename {src/shared/ui => packages/ui/src}/EntryCard.tsx (97%) rename {src/shared/ui => packages/ui/src}/LoadingSkeleton.stories.tsx (100%) rename {src/shared/ui => packages/ui/src}/LoadingSkeleton.tsx (100%) rename {src/shared/ui => packages/ui/src}/StateView.stories.tsx (100%) rename {src/shared/ui => packages/ui/src}/StateView.test.tsx (100%) rename {src/shared/ui => packages/ui/src}/StateView.tsx (100%) rename {src/shared/ui => packages/ui/src}/index.ts (77%) rename {src/shared/theme => packages/ui/src}/styles.css (100%) rename {src/shared/theme => packages/ui/src}/theme.test.ts (95%) rename {src/shared/theme => packages/ui/src}/theme.ts (96%) create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/ui/vite.config.ts rename src/{shared => app}/ui/AppShell.module.css (100%) rename src/{shared => app}/ui/AppShell.tsx (98%) create mode 100644 src/entities/bank-card/model/operation-kinds.ts create mode 100644 src/pages/account-income/model/period.ts create mode 100644 src/pages/account-income/ui/components/IncomePeriodForm.stories.tsx create mode 100644 src/pages/card/ui/OperationForm.stories.tsx create mode 100644 src/pages/card/ui/OperationForm.vm.ts delete mode 100644 src/shared/theme/index.ts diff --git a/.ai/architecture.md b/.ai/architecture.md index 877b55d..be5a6cd 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -51,23 +51,32 @@ Cross-slice consumer использует `@/` и `index.ts` владельца, композиция принадлежит вышележащему owner. Existing меж-slice исключения не являются разрешением добавлять новые. -Направление зависимостей и public imports частично проверяет -`npm run deps`; Knip проверяет использование exports, а не смысл placement. +Направление зависимостей, public imports и запрет соседних slices в +`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports +одного slice и связи shared segments этот запрет не ограничивает. +Knip проверяет использование exports, а не смысл placement. ## UI foundation -`shared/theme/tokens.json` — переносимый source design tokens; `theme.ts` +Private workspace `packages/tokens` экспортирует переносимый source design tokens +как `@wealthledger/tokens/tokens.json`. Private workspace `packages/ui` адаптирует доверенный JSON к Mantine без runtime schema. App и Storybook читают -public API `shared/theme`. Storybook показывает synthetic presentation states, +`@wealthledger/ui/theme` и явно подключают `@wealthledger/ui/styles.css` после +Mantine CSS. Потребители используют public exports пакетов напрямую. Storybook показывает synthetic presentation states, не использует application API. RTL/MSW проверяют поведение приложения отдельно. -Existing route-aware AppShell расположен в shared/ui; это legacy placement, -а не образец нового shared primitive. При изменении foundation прочитай +Route-aware AppShell и его navigation styles принадлежат `app/ui`; +`ProtectedLayout` использует public API этого владельца. При изменении foundation прочитай [UI](conventions/ui.md#визуальная-основа) и [Storybook](conventions/storybook.md). +Статичные Add/Edit/Delete и публичный `IconProps` принадлежат private +`packages/icons`; package не зависит от UI, domain или motion. +`BankOperationIcon` и mappings остаются у entity. Card/Cards используют package +Add/Edit/Delete; restore/category SVG остаются локальными. + ## Примеры -- Общая busy-область: существующий [LoadingSkeleton](../src/shared/ui/LoadingSkeleton.tsx), а не domain widget в shared. +- Общая busy-область: существующий [LoadingSkeleton](../packages/ui/src/LoadingSkeleton.tsx), а не domain widget в shared. - Account-income state views: [страница](../src/pages/account-income/ui/AccountIncomePage.tsx) и её flat `components/*.view.tsx`; новый page-local filter остаётся здесь. - Portfolio query: [entity public API](../src/entities/portfolio/index.ts); новая UI-сводка с бизнес-смыслом не становится shared только из-за нескольких экранов. @@ -75,7 +84,7 @@ Existing route-aware AppShell расположен в shared/ui; это legacy p После placement/import change: `npm run deps`, `npm run typecheck`, `npm run knip`; после собственного behavior — применимые tests. Финальный gate указан в AGENTS. -Existing dependency config не запрещает все same-layer imports: их и смысл -owner проверяет review. Не объявляй Steiger установленным; graph проверяет -текущий dependency-cruiser. Review отдельно проверяет смысл owner: разрешённый dependency graph не доказывает -правильный выбор слоя. +Graph `src` и `packages` проверяет текущий dependency-cruiser; зависимости +из packages в код приложения `src` запрещены. Generated `packages/*/dist` +не анализируется как исходник; все package sources остаются в graph. Review отдельно проверяет смысл +owner: разрешённый dependency graph не доказывает правильный выбор слоя. diff --git a/.ai/conventions/storybook.md b/.ai/conventions/storybook.md index 62fb6b9..1871394 100644 --- a/.ai/conventions/storybook.md +++ b/.ai/conventions/storybook.md @@ -1,5 +1,8 @@ # Storybook +Единый каталог включает stories приложения и `packages/*/src`; общие +StateView/LoadingSkeleton и их stories принадлежат `packages/ui`. + ## Когда нужна story - Shared UI/theme: показывай принятые visual roles и переиспользуемое поведение. @@ -23,7 +26,7 @@ Stories не достигают application API, auth, VM и query providers д контракт: keyboard activation, доступность действия, local callback. Не повторяй RTL suite и не тестируй внутренности Mantine. Существующий [Button](../../src/shared/ui/Button.stories.tsx) показывает themed control -без production wrapper; [LoadingSkeleton](../../src/shared/ui/LoadingSkeleton.stories.tsx) +без production wrapper; [LoadingSkeleton](../../packages/ui/src/LoadingSkeleton.stories.tsx) — значимые busy states. `npm run typecheck`, `npm run deps:storybook`, `npm run build:catalog` проверяют diff --git a/.ai/conventions/ui.md b/.ai/conventions/ui.md index badd161..a741cce 100644 --- a/.ai/conventions/ui.md +++ b/.ai/conventions/ui.md @@ -13,7 +13,10 @@ Mantine component и общую тему. Для выпадающего выбо Icons ищи у существующего owner (например, [BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon.tsx)); -общая icon library сейчас не принята. Не добавляй пакет ради одного символа. Business/domain +статичные Add/Edit/Delete доступны в `@wealthledger/icons`. Их SVG декоративны +по умолчанию; имя action принадлежит кнопке. Standalone meaningful icon получает +`aria-hidden={false}`, `role="img"` и доступное имя. Category/kind mappings +и анимации остаются в entity. Не добавляй пакет ради одного символа. Business/domain components живут у feature/entity/page, даже при повторном использовании. Wrapper нужен для проектной ответственности, а не ради нового имени @@ -107,10 +110,29 @@ loading/error/empty/loaded и recovery по применимости. [Storybook ## Визуальная основа -Общая тема живёт в `src/shared/theme`: `tokens.json` задаёт переносимые значения, -`theme.ts` адаптирует их к Mantine. Используй public API темы и её semantic roles, -без отдельной палитры в page/component. Поддерживается светлая схема. +Private workspace `packages/tokens` владеет переносимыми значениями +`tokens.json`; `packages/ui/src/theme.ts` читает export пакета +`@wealthledger/tokens/tokens.json` и адаптирует его к Mantine. Используй +`@wealthledger/ui/theme` и его semantic roles; runtime подключает +`@wealthledger/ui/styles.css` после Mantine CSS. Providers остаются у runtime. +Палитра page/component следует этим semantic roles. Поддерживается светлая схема. Цвет дополняет подпись или знак; проверяй контраст конкретной пары text/background. Warning не используй для мелкого текста на белом. Деньги сохраняют валюту, знак и различие между нулём и неизвестной суммой. Общие состояния демонстрирует Storybook; presentation stories используют synthetic data без application API. + +EntryCard, StateView и LoadingSkeleton принадлежат `packages/ui`; публичный import — +`@wealthledger/ui`. Переводы, retry и формы skeleton остаются у callers. +Compatibility re-exports удалены; callers используют public export пакета. + +## Modal animation + +BankCardForm, OperationForm, DeleteOperation и CategoryManager монтируют Modal +с `opened=true`: Mantine Transition начинает в состоянии `entered`. +CSS `modal-appear`/`modal-backdrop-appear` в `packages/ui/src/styles.css` +обеспечивает появление при mount (220ms, только no-preference). Mantine theme +владеет exit transition (180ms), overlay blur и completion callback; +`onExitTransitionEnd` позволяет дождаться закрытия перед unmount и вернуть фокус. +Удаление CSS без изменения mount lifecycle убирает вступительную анимацию. +TbankConnection Modal использует toggle `opened`; вывод о разделении фаз +банковских форм на этот сценарий автоматически не распространяется. diff --git a/.dockerignore b/.dockerignore index ec510c1..0c527df 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,3 +13,30 @@ !public/** !docker/ !docker/** +!packages/ +!packages/tokens/ +!packages/tokens/package.json +!packages/tokens/tokens.json +!packages/ui/ +!packages/ui/package.json +!packages/ui/vite.config.ts +!packages/ui/tsconfig.json +!packages/ui/src/ +!packages/ui/src/theme.ts +!packages/ui/src/styles.css +!packages/ui/src/index.ts +!packages/ui/src/StateView.tsx +!packages/ui/src/LoadingSkeleton.tsx +!packages/ui/src/EntryCard.tsx +!packages/ui/src/EntryCard.module.css +!packages/icons/ +!packages/icons/package.json +!packages/icons/tsconfig.json +!packages/icons/vite.config.ts +!packages/icons/src/ +!packages/icons/src/index.ts +!packages/icons/src/types.ts +!packages/icons/src/svg/ +!packages/icons/src/svg/AddIcon.tsx +!packages/icons/src/svg/EditIcon.tsx +!packages/icons/src/svg/DeleteIcon.tsx diff --git a/.storybook/main.ts b/.storybook/main.ts index ca5d4b1..8557f9c 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,7 +1,7 @@ import type { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { - stories: ["../src/**/*.stories.tsx"], + stories: ["../src/**/*.stories.tsx", "../packages/*/src/**/*.stories.tsx"], addons: ["@storybook/addon-docs"], framework: "@storybook/react-vite", core: { disableTelemetry: true }, diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index b5bfa08..acc23dc 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,12 +1,13 @@ import "@mantine/core/styles.css"; import "@mantine/charts/styles.css"; import "@mantine/dates/styles.css"; +import "@wealthledger/ui/styles.css"; import "dayjs/locale/ru"; import "../src/shared/i18n"; import { MantineProvider } from "@mantine/core"; import { DatesProvider } from "@mantine/dates"; import type { Preview } from "@storybook/react-vite"; -import { cssVariablesResolver, theme } from "@/shared/theme"; +import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; const colorSchemeManager = { get: () => "light" as const, diff --git a/Dockerfile b/Dockerfile index 88feb2d..2f3c85c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,10 +2,14 @@ FROM node:24.21.0-bookworm-slim AS dependencies WORKDIR /app ENV HUSKY=0 COPY package.json package-lock.json ./ +COPY packages/tokens/package.json ./packages/tokens/ +COPY packages/ui/package.json ./packages/ui/ +COPY packages/icons/package.json ./packages/icons/ RUN npm ci FROM dependencies AS development COPY . . +RUN npm run build:packages ENV VITE_API_BASE_URL="" API_PROXY_TARGET=http://backend:3000 EXPOSE 8080 CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "8080", "--strictPort"] diff --git a/README.md b/README.md index e5ee4d9..94f3292 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ staged diff с полной редакцией секретов, затем за `Dockerfile` имеет targets `development` (Vite HMR и proxy `/api/` во внутренний backend) и `production` (nginx на 8080, SPA fallback и same-origin API proxy). Node соответствует `.nvmrc`. Build context ограничен `.dockerignore`; `.env`, local DB и host node_modules -не попадают в image. +не попадают в image. Изменение `packages/tokens` или `packages/ui` требует rebuild development +image, если локальный запуск использует bind-mount только `src`/`public`. ```bash docker build --target development -t wealthledger-frontend:development . @@ -194,3 +195,33 @@ Commits и PR titles — Conventional Commits, включая `spec:`. Верс администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены. Workflow сам не включает эти настройки. Отдельное review approval для одного владельца не требуется. Npm publishing, deploy и Gitea Releases не включены. + +## Локальные пакеты + +`packages/tokens` — private npm workspace с единственным export +`@wealthledger/tokens/tokens.json`. `packages/ui` экспортирует собранные +`@wealthledger/ui/theme` (JS + declarations) и `@wealthledger/ui/styles.css`. +Корневой export `@wealthledger/ui` содержит EntryCard, StateView и LoadingSkeleton; +Потребители импортируют компоненты напрямую из `@wealthledger/ui`. +CSS явно подключается после Mantine; providers остаются в приложении/Storybook. +Тема доступна через `@wealthledger/ui/theme`; compatibility re-exports удалены. + +JSON не требует сборки. `npm ci` связывает workspaces, а существующие команды +разработки, проверок и сборки сначала выполняют `npm run build:packages`. +Library build использует отдельный Vite config без app plugins и TypeScript +для declarations. После изменения package sources перезапустите `npm run dev` +или `npm run dev:catalog`; отдельный watch-процесс пока не нужен. +Начальная версия manifest взята из приложения; отдельный release-процесс +и публикация не настроены. + +Private `@wealthledger/icons` экспортирует `AddIcon`, `EditIcon`, `DeleteIcon` +и type `IconProps`. Единственный source рисунка — TSX в `packages/icons/src/svg`; +поставка содержит ESM/declarations, без raw SVG, CSS, Mantine и motion. +React — peer/external. Размер `size` по умолчанию 24, `strokeWidth` — 1.5; +цвет наследуется через `currentColor`, SVG props можно передать явно. +В action используйте декоративный ``, +доступное имя задавайте кнопке. Standalone пример: +``. +Поддерживается также стандартный `aria-labelledby`; подпись выбирает caller. +Card/Cards используют пакетные Add/Edit/Delete с прежними размерами/strokeWidth. +Restore/category SVG и предметный BankOperationIcon остаются в приложении. diff --git a/biome.json b/biome.json index 6eff3f8..babf97e 100644 --- a/biome.json +++ b/biome.json @@ -7,6 +7,7 @@ "includes": [ "**", "!dist", + "!packages/*/dist", "!storybook-static", "!src/shared/api/generated", "!src/shared/api/spec", @@ -38,7 +39,8 @@ "vite.config.ts", "vitest.config.ts", ".storybook/main.ts", - ".storybook/preview.tsx" + ".storybook/preview.tsx", + "packages/*/vite.config.ts" ], "linter": { "rules": { diff --git a/dependency-cruiser.cjs b/dependency-cruiser.cjs index a87fcea..a2d1217 100644 --- a/dependency-cruiser.cjs +++ b/dependency-cruiser.cjs @@ -1,5 +1,11 @@ module.exports = { forbidden: [ + { + name: "packages-must-not-depend-on-app", + severity: "error", + from: { path: "^packages/" }, + to: { path: "^src/" }, + }, { name: "no-circular", severity: "error", @@ -17,6 +23,12 @@ module.exports = { pathNot: "^src/$1/$2/", }, }, + { + name: "no-same-layer-cross-slice-imports", + severity: "error", + from: { path: "^src/(pages|features|entities)/([^/]+)/" }, + to: { path: "^src/$1/", pathNot: "^src/$1/$2/" }, + }, { name: "shared-must-not-depend-on-layers", severity: "error", @@ -43,6 +55,7 @@ module.exports = { }, ], options: { + exclude: "^packages/[^/]+/dist/", doNotFollow: { path: "node_modules" }, tsConfig: { fileName: "tsconfig.app.json" }, tsPreCompilationDeps: true, diff --git a/knip.json b/knip.json index f2b3ac7..5f5b315 100644 --- a/knip.json +++ b/knip.json @@ -1,4 +1,17 @@ { - "project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"], - "ignoreDependencies": ["conventional-changelog-conventionalcommits"] + "ignoreDependencies": ["conventional-changelog-conventionalcommits"], + "workspaces": { + ".": { + "project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"] + }, + "packages/tokens": { + "entry": ["tokens.json"] + }, + "packages/ui": { + "project": ["src/**/*.{ts,tsx}"] + }, + "packages/icons": { + "project": ["src/**/*.{ts,tsx}"] + } + } } diff --git a/package-lock.json b/package-lock.json index 0caad7d..3205fa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "wealth-ledger-frontend", "version": "0.3.0", + "workspaces": [ + "packages/*" + ], "dependencies": { "@hookform/resolvers": "5.9.1", "@mantine/charts": "9.6.1", @@ -14,6 +17,9 @@ "@mantine/dates": "9.6.1", "@mantine/hooks": "9.6.1", "@tanstack/react-query": "5.102.8", + "@wealthledger/icons": "file:packages/icons", + "@wealthledger/tokens": "file:packages/tokens", + "@wealthledger/ui": "file:packages/ui", "dayjs": "1.11.23", "i18next": "26.4.2", "motion": "13.2.0", @@ -5460,6 +5466,18 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@wealthledger/icons": { + "resolved": "packages/icons", + "link": true + }, + "node_modules/@wealthledger/tokens": { + "resolved": "packages/tokens", + "link": true + }, + "node_modules/@wealthledger/ui": { + "resolved": "packages/ui", + "link": true + }, "node_modules/@webcontainer/env": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@webcontainer/env/-/env-1.1.1.tgz", @@ -16399,6 +16417,47 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "packages/icons": { + "name": "@wealthledger/icons", + "version": "0.3.0", + "devDependencies": { + "@storybook/react-vite": "10.6.0", + "@testing-library/react": "16.3.3", + "@types/react": "19.3.0", + "typescript": "5.9.3", + "vite": "8.3.0", + "vitest": "5.0.0" + }, + "peerDependencies": { + "react": "19.3.0" + } + }, + "packages/tokens": { + "name": "@wealthledger/tokens", + "version": "0.3.0" + }, + "packages/ui": { + "name": "@wealthledger/ui", + "version": "0.3.0", + "dependencies": { + "@wealthledger/tokens": "0.3.0" + }, + "devDependencies": { + "@storybook/react-vite": "10.6.0", + "@testing-library/react": "16.3.3", + "@types/react": "19.3.0", + "storybook": "10.6.0", + "typescript": "5.9.3", + "vite": "8.3.0", + "vitest": "5.0.0" + }, + "peerDependencies": { + "@mantine/core": "9.6.1", + "@mantine/hooks": "9.6.1", + "react": "19.3.0", + "react-dom": "19.3.0" + } } } } diff --git a/package.json b/package.json index 9f5ecc7..d0a529b 100644 --- a/package.json +++ b/package.json @@ -7,27 +7,28 @@ "node": "24.21.0" }, "scripts": { - "dev": "vite", - "dev:catalog": "storybook dev -p 6006 --no-open --disable-telemetry", - "build:catalog": "storybook build --output-dir storybook-static --disable-telemetry", + "dev": "npm run build:packages && vite", + "dev:catalog": "npm run build:packages && storybook dev -p 6006 --no-open --disable-telemetry", + "build:catalog": "npm run build:packages && storybook build --output-dir storybook-static --disable-telemetry", "preview:catalog": "vite preview --outDir storybook-static", - "typecheck": "tsc -b --noEmit", - "build": "tsc -b && vite build", + "typecheck": "npm run build:packages && tsc -b --noEmit", + "build": "npm run build:packages && tsc -b && vite build", "lint": "biome check .", - "test": "vitest", - "test:run": "vitest run", - "test:coverage": "vitest run --coverage", + "test": "npm run build:packages && vitest", + "test:run": "npm run build:packages && vitest run", + "test:coverage": "npm run build:packages && vitest run --coverage", "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", - "knip": "knip", - "deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long", - "verify": "npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run deps:storybook && npm run build && npm run build:catalog", + "knip": "npm run build:packages && knip", + "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 && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run deps:storybook && npm run build && npm run build:catalog", "prepare": "husky", "preview": "vite preview", - "deps:storybook": "depcruise src .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", "release:preview": "release-it --dry-run --ci", "release:prepare": "release-it --ci", - "commitlint": "commitlint" + "commitlint": "commitlint", + "build:packages": "npm run build --workspaces --if-present" }, "lint-staged": { "*.{css,html,json,md,ts,tsx}": "biome format --write --no-errors-on-unmatched" @@ -39,6 +40,9 @@ "@mantine/dates": "9.6.1", "@mantine/hooks": "9.6.1", "@tanstack/react-query": "5.102.8", + "@wealthledger/icons": "file:packages/icons", + "@wealthledger/tokens": "file:packages/tokens", + "@wealthledger/ui": "file:packages/ui", "dayjs": "1.11.23", "i18next": "26.4.2", "motion": "13.2.0", @@ -88,5 +92,8 @@ "allowScripts": { "esbuild@0.28.2": true, "fsevents@2.3.3": true - } + }, + "workspaces": [ + "packages/*" + ] } diff --git a/packages/icons/package.json b/packages/icons/package.json new file mode 100644 index 0000000..e3f8c13 --- /dev/null +++ b/packages/icons/package.json @@ -0,0 +1,30 @@ +{ + "name": "@wealthledger/icons", + "version": "0.3.0", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "sideEffects": false, + "scripts": { + "build": "vite build && tsc -p tsconfig.json" + }, + "peerDependencies": { + "react": "19.3.0" + }, + "devDependencies": { + "@storybook/react-vite": "10.6.0", + "@testing-library/react": "16.3.3", + "@types/react": "19.3.0", + "typescript": "5.9.3", + "vite": "8.3.0", + "vitest": "5.0.0" + } +} diff --git a/packages/icons/src/Icons.stories.tsx b/packages/icons/src/Icons.stories.tsx new file mode 100644 index 0000000..f9b21e2 --- /dev/null +++ b/packages/icons/src/Icons.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AddIcon, DeleteIcon, EditIcon } from "./index"; + +const meta = { + title: "Design System/Icons", + component: AddIcon, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: + "Статичные SVG: size по умолчанию 24, strokeWidth 1.5, цвет currentColor. В кнопке иконка декоративная, имя принадлежит действию. Для самостоятельного изображения задайте aria-hidden={false}, role=img и aria-label либо aria-labelledby. Геометрия взята из Card/Cards; подписи и действия остаются у приложения.", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Decorative: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const Meaningful: Story = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/packages/icons/src/Icons.test.tsx b/packages/icons/src/Icons.test.tsx new file mode 100644 index 0000000..31c48ef --- /dev/null +++ b/packages/icons/src/Icons.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { AddIcon, DeleteIcon, EditIcon } from "./index"; + +describe("icon accessibility", () => { + it.each([AddIcon, EditIcon, DeleteIcon])( + "keeps decoration hidden and exposes explicitly named images: %s", + (Icon) => { + const { rerender } = render( + , + ); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Действие" }).querySelector("svg"), + ).toHaveAttribute("focusable", "false"); + rerender( + , + ); + const icon = screen.getByRole("img", { name: "Пример" }); + expect(icon).toHaveAttribute("width", "18"); + expect(icon).toHaveAttribute("height", "18"); + expect(icon).toHaveAttribute("stroke-width", "2"); + expect(icon).toHaveAttribute("stroke", "currentColor"); + }, + ); +}); diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts new file mode 100644 index 0000000..296d458 --- /dev/null +++ b/packages/icons/src/index.ts @@ -0,0 +1,4 @@ +export { AddIcon } from "./svg/AddIcon"; +export { DeleteIcon } from "./svg/DeleteIcon"; +export { EditIcon } from "./svg/EditIcon"; +export type { IconProps } from "./types"; diff --git a/packages/icons/src/svg/AddIcon.tsx b/packages/icons/src/svg/AddIcon.tsx new file mode 100644 index 0000000..cbd7ff5 --- /dev/null +++ b/packages/icons/src/svg/AddIcon.tsx @@ -0,0 +1,24 @@ +import type { FC } from "react"; +import type { IconProps } from "../types"; + +const AddIcon: FC = (props) => { + const { size = "24", ...svgProps } = props; + return ( + + ); +}; + +export { AddIcon }; diff --git a/packages/icons/src/svg/DeleteIcon.tsx b/packages/icons/src/svg/DeleteIcon.tsx new file mode 100644 index 0000000..6e6321b --- /dev/null +++ b/packages/icons/src/svg/DeleteIcon.tsx @@ -0,0 +1,25 @@ +import type { FC } from "react"; +import type { IconProps } from "../types"; + +const DeleteIcon: FC = (props) => { + const { size = "24", ...svgProps } = props; + return ( + + ); +}; + +export { DeleteIcon }; diff --git a/packages/icons/src/svg/EditIcon.tsx b/packages/icons/src/svg/EditIcon.tsx new file mode 100644 index 0000000..d2c4940 --- /dev/null +++ b/packages/icons/src/svg/EditIcon.tsx @@ -0,0 +1,25 @@ +import type { FC } from "react"; +import type { IconProps } from "../types"; + +const EditIcon: FC = (props) => { + const { size = "24", ...svgProps } = props; + return ( + + ); +}; + +export { EditIcon }; diff --git a/packages/icons/src/types.ts b/packages/icons/src/types.ts new file mode 100644 index 0000000..7f106f5 --- /dev/null +++ b/packages/icons/src/types.ts @@ -0,0 +1,10 @@ +import type { ComponentPropsWithoutRef } from "react"; + +type IconProps = Omit< + ComponentPropsWithoutRef<"svg">, + "children" | "dangerouslySetInnerHTML" +> & { + size?: number | string; +}; + +export type { IconProps }; diff --git a/packages/icons/tsconfig.json b/packages/icons/tsconfig.json new file mode 100644 index 0000000..96b5e87 --- /dev/null +++ b/packages/icons/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist", + "jsx": "react-jsx" + }, + "include": ["src/index.ts"] +} diff --git a/packages/icons/vite.config.ts b/packages/icons/vite.config.ts new file mode 100644 index 0000000..9dd7242 --- /dev/null +++ b/packages/icons/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + lib: { entry: "src/index.ts", formats: ["es"], fileName: "index" }, + rolldownOptions: { external: /^react(\/|$)/ }, + }, +}); diff --git a/packages/tokens/package.json b/packages/tokens/package.json new file mode 100644 index 0000000..67b3554 --- /dev/null +++ b/packages/tokens/package.json @@ -0,0 +1,12 @@ +{ + "name": "@wealthledger/tokens", + "version": "0.3.0", + "private": true, + "type": "module", + "files": [ + "tokens.json" + ], + "exports": { + "./tokens.json": "./tokens.json" + } +} diff --git a/src/shared/theme/tokens.json b/packages/tokens/tokens.json similarity index 100% rename from src/shared/theme/tokens.json rename to packages/tokens/tokens.json diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..7f998bc --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,44 @@ +{ + "name": "@wealthledger/ui", + "version": "0.3.0", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./theme": { + "types": "./dist/theme.d.ts", + "import": "./dist/theme.js" + }, + "./styles.css": "./dist/styles.css" + }, + "sideEffects": [ + "**/*.css" + ], + "scripts": { + "build": "vite build && tsc -p tsconfig.json" + }, + "dependencies": { + "@wealthledger/tokens": "0.3.0" + }, + "peerDependencies": { + "@mantine/core": "9.6.1", + "@mantine/hooks": "9.6.1", + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@storybook/react-vite": "10.6.0", + "@testing-library/react": "16.3.3", + "@types/react": "19.3.0", + "storybook": "10.6.0", + "typescript": "5.9.3", + "vite": "8.3.0", + "vitest": "5.0.0" + } +} diff --git a/src/shared/ui/EntryCard.module.css b/packages/ui/src/EntryCard.module.css similarity index 93% rename from src/shared/ui/EntryCard.module.css rename to packages/ui/src/EntryCard.module.css index cfb27d0..2aca6de 100644 --- a/src/shared/ui/EntryCard.module.css +++ b/packages/ui/src/EntryCard.module.css @@ -4,8 +4,8 @@ gap: var(--mantine-spacing-sm); align-items: center; padding: var(--mantine-spacing-md); - border-color: var(--mantine-color-gray-3); - background: var(--mantine-color-gray-0); + border-color: var(--wl-color-border); + background: var(--wl-color-subtle); } .identity, diff --git a/packages/ui/src/EntryCard.stories.tsx b/packages/ui/src/EntryCard.stories.tsx new file mode 100644 index 0000000..d4a2fd3 --- /dev/null +++ b/packages/ui/src/EntryCard.stories.tsx @@ -0,0 +1,37 @@ +import { Button } from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { EntryCard } from "./EntryCard"; + +const meta = { + title: "Design System/Components/EntryCard", + component: EntryCard, + tags: ["autodocs"], + args: { + title: + "Учебная запись с очень длинным названием для проверки узкого экрана", + metadata: "22.09.2026 · Синтетические данные", + initial: "У", + imageSrc: "data:image/png;base64,invalid", + value: "−1 250,00 ₽", + valueLabel: "Тестовое значение", + tone: "negative", + actions: ( + + ), + }, + parameters: { + docs: { + description: { + component: + "Готовые строки и slots без финансовых вычислений. Невалидное локальное изображение показывает fallback; действие доступно с клавиатуры и на узком экране. Данные вымышлены.", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +export const LongContentAndFallback: Story = {}; diff --git a/packages/ui/src/EntryCard.test.tsx b/packages/ui/src/EntryCard.test.tsx new file mode 100644 index 0000000..213e3d1 --- /dev/null +++ b/packages/ui/src/EntryCard.test.tsx @@ -0,0 +1,35 @@ +import { MantineProvider } from "@mantine/core"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { assert, describe, expect, it } from "vitest"; +import { EntryCard } from "./EntryCard"; + +describe("EntryCard", () => { + it("keeps the fallback after an image fails", () => { + const props = { + title: "Пример", + metadata: "Описание", + initial: "У", + value: "—", + valueLabel: "Неизвестно", + tone: "neutral" as const, + imageSrc: "data:image/png;base64,invalid", + }; + const { container, rerender } = render( + + + , + ); + // Decorative image has no accessible name; trigger its actual failure event. + const image = container.querySelector("img"); + assert(image); + fireEvent.error(image); + rerender( + + + , + ); + expect(container.querySelector("img")).toBeNull(); + expect(screen.getByText("У")).toBeInTheDocument(); + expect(screen.getByText("Обновлённый пример")).toBeInTheDocument(); + }); +}); diff --git a/src/shared/ui/EntryCard.tsx b/packages/ui/src/EntryCard.tsx similarity index 97% rename from src/shared/ui/EntryCard.tsx rename to packages/ui/src/EntryCard.tsx index 7c2ad0c..455e2c4 100644 --- a/src/shared/ui/EntryCard.tsx +++ b/packages/ui/src/EntryCard.tsx @@ -25,7 +25,7 @@ const EntryCard: FC = (props) => { style={ props.color ? { - background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, white)`, + background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`, } : undefined } diff --git a/src/shared/ui/LoadingSkeleton.stories.tsx b/packages/ui/src/LoadingSkeleton.stories.tsx similarity index 100% rename from src/shared/ui/LoadingSkeleton.stories.tsx rename to packages/ui/src/LoadingSkeleton.stories.tsx diff --git a/src/shared/ui/LoadingSkeleton.tsx b/packages/ui/src/LoadingSkeleton.tsx similarity index 100% rename from src/shared/ui/LoadingSkeleton.tsx rename to packages/ui/src/LoadingSkeleton.tsx diff --git a/src/shared/ui/StateView.stories.tsx b/packages/ui/src/StateView.stories.tsx similarity index 100% rename from src/shared/ui/StateView.stories.tsx rename to packages/ui/src/StateView.stories.tsx diff --git a/src/shared/ui/StateView.test.tsx b/packages/ui/src/StateView.test.tsx similarity index 100% rename from src/shared/ui/StateView.test.tsx rename to packages/ui/src/StateView.test.tsx diff --git a/src/shared/ui/StateView.tsx b/packages/ui/src/StateView.tsx similarity index 100% rename from src/shared/ui/StateView.tsx rename to packages/ui/src/StateView.tsx diff --git a/src/shared/ui/index.ts b/packages/ui/src/index.ts similarity index 77% rename from src/shared/ui/index.ts rename to packages/ui/src/index.ts index db527c2..cb0030f 100644 --- a/src/shared/ui/index.ts +++ b/packages/ui/src/index.ts @@ -1,4 +1,3 @@ -export { AppShell } from "./AppShell"; export { EntryCard } from "./EntryCard"; export { LoadingSkeleton } from "./LoadingSkeleton"; export { StateView } from "./StateView"; diff --git a/src/shared/theme/styles.css b/packages/ui/src/styles.css similarity index 100% rename from src/shared/theme/styles.css rename to packages/ui/src/styles.css diff --git a/src/shared/theme/theme.test.ts b/packages/ui/src/theme.test.ts similarity index 95% rename from src/shared/theme/theme.test.ts rename to packages/ui/src/theme.test.ts index cd1dc79..e689f76 100644 --- a/src/shared/theme/theme.test.ts +++ b/packages/ui/src/theme.test.ts @@ -1,7 +1,7 @@ import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core"; +import source from "@wealthledger/tokens/tokens.json"; import { describe, expect, it } from "vitest"; import { colorRoles, cssVariablesResolver, theme } from "./theme"; -import source from "./tokens.json"; describe("design theme adapter", () => { it("preserves scale-aware sizes, unscaled breakpoints and shadow layers", () => { diff --git a/src/shared/theme/theme.ts b/packages/ui/src/theme.ts similarity index 96% rename from src/shared/theme/theme.ts rename to packages/ui/src/theme.ts index 07737b3..ba8504b 100644 --- a/src/shared/theme/theme.ts +++ b/packages/ui/src/theme.ts @@ -1,3 +1,4 @@ +import "./styles.css"; import { type CSSVariablesResolver, colorsTuple, @@ -8,7 +9,7 @@ import { rem, rgba, } from "@mantine/core"; -import designTokens from "./tokens.json"; +import designTokens from "@wealthledger/tokens/tokens.json"; const { color, typography, spacing, radius, shadows, breakpoints } = designTokens; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 0000000..b287378 --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "strict": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist", + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src/index.ts", "src/theme.ts"] +} diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts new file mode 100644 index 0000000..5057d03 --- /dev/null +++ b/packages/ui/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + lib: { + entry: { index: "src/index.ts", theme: "src/theme.ts" }, + formats: ["es"], + cssFileName: "styles", + }, + rolldownOptions: { + external: /^(react(?:-dom)?|@mantine\/[^/]+|@wealthledger\/tokens)(\/|$)/, + }, + }, +}); diff --git a/src/app/routes/AppRoutes.test.tsx b/src/app/routes/AppRoutes.test.tsx index fd796f1..6900830 100644 --- a/src/app/routes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes.test.tsx @@ -1,12 +1,11 @@ import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { HttpResponse, http } from "msw"; import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; - import { AuthProvider } from "@/features/auth"; -import { cssVariablesResolver, theme } from "@/shared/theme"; import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; import { AppRoutes } from "./AppRoutes"; diff --git a/src/app/routes/ProtectedLayout.tsx b/src/app/routes/ProtectedLayout.tsx index 895b5b7..1aa9176 100644 --- a/src/app/routes/ProtectedLayout.tsx +++ b/src/app/routes/ProtectedLayout.tsx @@ -1,6 +1,5 @@ import type { FC } from "react"; -import { AppBreadcrumbs } from "@/app/ui"; -import { AppShell } from "@/shared/ui"; +import { AppBreadcrumbs, AppShell } from "@/app/ui"; import { ProtectedDocumentTitle } from "./DocumentTitle"; const ProtectedLayout: FC = () => { diff --git a/src/app/ui/App.tsx b/src/app/ui/App.tsx index 5621e02..74953f3 100644 --- a/src/app/ui/App.tsx +++ b/src/app/ui/App.tsx @@ -1,11 +1,11 @@ import { MantineProvider } from "@mantine/core"; import { DatesProvider } from "@mantine/dates"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import type { FC } from "react"; import { BrowserRouter } from "react-router"; import { AppRoutes, RouteErrorBoundary } from "@/app/routes"; import { AuthProvider } from "@/features/auth"; -import { cssVariablesResolver, theme } from "@/shared/theme"; const queryClient = new QueryClient({ defaultOptions: { diff --git a/src/shared/ui/AppShell.module.css b/src/app/ui/AppShell.module.css similarity index 100% rename from src/shared/ui/AppShell.module.css rename to src/app/ui/AppShell.module.css diff --git a/src/shared/ui/AppShell.tsx b/src/app/ui/AppShell.tsx similarity index 98% rename from src/shared/ui/AppShell.tsx rename to src/app/ui/AppShell.tsx index cc2e63f..855cd29 100644 --- a/src/shared/ui/AppShell.tsx +++ b/src/app/ui/AppShell.tsx @@ -2,7 +2,7 @@ import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core"; import type { FC, ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router"; -import { bindIconAnimation } from "../lib"; +import { bindIconAnimation } from "@/shared/lib"; import classes from "./AppShell.module.css"; type AppShellProps = { diff --git a/src/app/ui/index.ts b/src/app/ui/index.ts index 0f97dc8..256c689 100644 --- a/src/app/ui/index.ts +++ b/src/app/ui/index.ts @@ -1 +1,2 @@ export { AppBreadcrumbs } from "./AppBreadcrumbs"; +export { AppShell } from "./AppShell"; diff --git a/src/entities/bank-card/index.ts b/src/entities/bank-card/index.ts index ba1c14f..d186bd9 100644 --- a/src/entities/bank-card/index.ts +++ b/src/entities/bank-card/index.ts @@ -18,6 +18,7 @@ export { moneyCents, positiveMoneySchema, } from "./model/form-validation"; +export { bankOperationKinds } from "./model/operation-kinds"; export { bankPeriod } from "./model/period"; export { BankCardFace } from "./ui/BankCardFace"; export { BankCardForm } from "./ui/BankCardForm"; diff --git a/src/entities/bank-card/model/operation-kinds.ts b/src/entities/bank-card/model/operation-kinds.ts new file mode 100644 index 0000000..e849652 --- /dev/null +++ b/src/entities/bank-card/model/operation-kinds.ts @@ -0,0 +1,10 @@ +import type { BankOperation } from "../api/queries"; + +const bankOperationKinds = [ + "expense", + "topup", + "transfer", + "refund", +] as const satisfies readonly BankOperation["kind"][]; + +export { bankOperationKinds }; diff --git a/src/entities/bank-card/ui/BankOperationIcon.stories.tsx b/src/entities/bank-card/ui/BankOperationIcon.stories.tsx index 4929acc..377c320 100644 --- a/src/entities/bank-card/ui/BankOperationIcon.stories.tsx +++ b/src/entities/bank-card/ui/BankOperationIcon.stories.tsx @@ -1,6 +1,6 @@ import { Stack } from "@mantine/core"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { EntryCard } from "@/shared/ui"; +import { EntryCard } from "@wealthledger/ui"; import { BankOperationIcon } from "./BankOperationIcon"; const meta = { diff --git a/src/entities/bank-card/ui/BankSummary.view.tsx b/src/entities/bank-card/ui/BankSummary.view.tsx index 6fc9598..bd4ac37 100644 --- a/src/entities/bank-card/ui/BankSummary.view.tsx +++ b/src/entities/bank-card/ui/BankSummary.view.tsx @@ -1,8 +1,8 @@ import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import type { CSSProperties, FC } from "react"; import { useTranslation } from "react-i18next"; import { formatMoney } from "@/shared/lib"; -import { LoadingSkeleton } from "@/shared/ui"; import type { BankSummary } from "../api/queries"; import { BankOperationIcon } from "./BankOperationIcon"; import classes from "./BankSummary.module.css"; diff --git a/src/entities/broker-operation/ui/LedgerEntryRow.tsx b/src/entities/broker-operation/ui/LedgerEntryRow.tsx index 8944e50..8aaab15 100644 --- a/src/entities/broker-operation/ui/LedgerEntryRow.tsx +++ b/src/entities/broker-operation/ui/LedgerEntryRow.tsx @@ -1,4 +1,5 @@ import { Anchor, Group, Text } from "@mantine/core"; +import { EntryCard } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; @@ -8,7 +9,6 @@ import { formatSnapshotTime, instrumentLogoUrl, } from "@/shared/lib"; -import { EntryCard } from "@/shared/ui"; import type { BrokerageOperation } from "../api/useBrokerageOperations"; import { operationLabelKey, paymentPresentation } from "../model/display"; diff --git a/src/entities/brokerage-event/ui/BrokerageEventCard.tsx b/src/entities/brokerage-event/ui/BrokerageEventCard.tsx index feb11bb..18f61f7 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventCard.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventCard.tsx @@ -1,7 +1,7 @@ +import { EntryCard } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib"; -import { EntryCard } from "@/shared/ui"; import type { BrokerageEvent } from "../api/useBrokerageEvents"; type BrokerageEventCardProps = { event: BrokerageEvent }; diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx index 36fff05..a365439 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx @@ -1,8 +1,8 @@ import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { LoadingSkeleton } from "@/shared/ui"; import type { BrokerageEvent } from "../api/useBrokerageEvents"; import { BrokerageEventCard } from "./BrokerageEventCard"; diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx index f106845..5534f1e 100644 --- a/src/features/instrument-details/ui/InstrumentSection.view.tsx +++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx @@ -8,9 +8,9 @@ import { Text, Title, } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import type { FC, PropsWithChildren, ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { LoadingSkeleton } from "@/shared/ui"; import type { SectionState } from "../model/section"; import classes from "./InstrumentDetails.module.css"; diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx index dd5c9cc..d090d3c 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx @@ -1,7 +1,7 @@ import { Alert, Button, Skeleton } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { LoadingSkeleton } from "@/shared/ui"; type TbankConnectionErrorProps = { retry: () => void; diff --git a/src/main.tsx b/src/main.tsx index 1c7e32f..3a2c079 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import "@mantine/core/styles.css"; import "@mantine/charts/styles.css"; import "@mantine/dates/styles.css"; +import "@wealthledger/ui/styles.css"; import "dayjs/locale/ru"; import "./shared/i18n"; import { App } from "./app"; diff --git a/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx index 5e09eb9..f3cec28 100644 --- a/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx +++ b/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx @@ -1,6 +1,6 @@ +import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { StateView } from "@/shared/ui"; const AccountEventsPageEmpty: FC = () => { const { t } = useTranslation(); diff --git a/src/pages/account-events/ui/components/AccountEventsPageError.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageError.view.tsx index ae03f49..95d463e 100644 --- a/src/pages/account-events/ui/components/AccountEventsPageError.view.tsx +++ b/src/pages/account-events/ui/components/AccountEventsPageError.view.tsx @@ -1,7 +1,7 @@ import { Button } from "@mantine/core"; +import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { StateView } from "@/shared/ui"; type AccountEventsPageErrorProps = { retry: () => void }; diff --git a/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx index 0370db8..0f04cca 100644 --- a/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx +++ b/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx @@ -1,7 +1,7 @@ import { Group, Skeleton } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { LoadingSkeleton } from "@/shared/ui"; const skeletonRowIds = ["first", "second", "third", "fourth"]; diff --git a/src/pages/account-income/model/period.ts b/src/pages/account-income/model/period.ts new file mode 100644 index 0000000..1513a50 --- /dev/null +++ b/src/pages/account-income/model/period.ts @@ -0,0 +1,46 @@ +type IncomePeriod = { from: string; to: string }; +type IncomeForm = IncomePeriod; + +const datePattern = /^\d{4}-\d{2}-\d{2}$/; + +function formatDraftDate(value: string): string { + const [year, month, day] = value.split("-"); + return `${day}.${month}.${year}`; +} + +function parseDraftDate(value: string): string | null { + if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null; + const [day, month, year] = value.split("."); + const date = `${year}-${month}-${day}`; + return isDate(date) ? date : null; +} + +function isDate(value: string): boolean { + if (!datePattern.test(value)) return false; + const parsed = new Date(`${value}T00:00:00Z`); + return ( + Number.isFinite(parsed.valueOf()) && + parsed.toISOString().slice(0, 10) === value + ); +} + +function moscowDate(value: Date): string { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Moscow", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(value); + const part = (type: string) => + parts.find((item) => item.type === type)?.value; + return `${part("year")}-${part("month")}-${part("day")}`; +} + +export { + formatDraftDate, + type IncomeForm, + type IncomePeriod, + isDate, + moscowDate, + parseDraftDate, +}; diff --git a/src/pages/account-income/ui/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage.test.tsx index 3a587f3..2416043 100644 --- a/src/pages/account-income/ui/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage.test.tsx @@ -23,8 +23,9 @@ import type { IncomeAnalytics } from "@/entities/income"; import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; +import { isDate } from "../model/period"; import { AccountIncomePage } from "./AccountIncomePage"; -import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm"; +import { toIncomeDisplayModel } from "./AccountIncomePage.vm"; import { IncomePayments } from "./components/IncomePayments.view"; const analytics = { diff --git a/src/pages/account-income/ui/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage.vm.ts index 9237330..f3eaf64 100644 --- a/src/pages/account-income/ui/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage.vm.ts @@ -6,12 +6,17 @@ import { z } from "zod"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { validationMessage } from "@/shared/i18n"; import { formatDate } from "@/shared/lib"; +import { + formatDraftDate, + type IncomeForm, + type IncomePeriod, + isDate, + moscowDate, + parseDraftDate, +} from "../model/period"; const ISO_MONTH_LENGTH = 7; -type IncomePeriod = { from: string; to: string }; -type IncomeForm = IncomePeriod; - type IncomeDisplayModel = { entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; comparison: { @@ -41,7 +46,6 @@ type AccountIncomePageViewModel = onSubmit: (values: IncomeForm) => void; }; -const datePattern = /^\d{4}-\d{2}-\d{2}$/; const draftDate = z .string() .refine((value) => parseDraftDate(value) !== null, { @@ -129,18 +133,6 @@ const useAccountIncomePageViewModel = ( }; }; -function formatDraftDate(value: string): string { - const [year, month, day] = value.split("-"); - return `${day}.${month}.${year}`; -} - -function parseDraftDate(value: string): string | null { - if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null; - const [day, month, year] = value.split("."); - const date = `${year}-${month}-${day}`; - return isDate(date) ? date : null; -} - function draftPeriod(period: IncomePeriod): IncomeForm { return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) }; } @@ -163,15 +155,6 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod { ); } -function isDate(value: string): boolean { - if (!datePattern.test(value)) return false; - const parsed = new Date(`${value}T00:00:00Z`); - return ( - Number.isFinite(parsed.valueOf()) && - parsed.toISOString().slice(0, 10) === value - ); -} - function toIncomeDisplayModel( analytics: IncomeAnalytics, ): IncomeDisplayModel | null { @@ -276,26 +259,9 @@ function formatIncomeChange( }).format(ratio); } -function moscowDate(value: Date): string { - const parts = new Intl.DateTimeFormat("en-CA", { - timeZone: "Europe/Moscow", - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(value); - const part = (type: string) => - parts.find((item) => item.type === type)?.value; - return `${part("year")}-${part("month")}-${part("day")}`; -} - export { type AccountIncomePageViewModel, - formatDraftDate, type IncomeDisplayModel, - type IncomeForm, - isDate, - moscowDate, - parseDraftDate, toIncomeDisplayModel, useAccountIncomePageViewModel, }; diff --git a/src/pages/account-income/ui/components/AccountIncomePageError.view.tsx b/src/pages/account-income/ui/components/AccountIncomePageError.view.tsx index 3bf8094..e125d81 100644 --- a/src/pages/account-income/ui/components/AccountIncomePageError.view.tsx +++ b/src/pages/account-income/ui/components/AccountIncomePageError.view.tsx @@ -1,7 +1,7 @@ import { Button } from "@mantine/core"; +import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { StateView } from "@/shared/ui"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; type ErrorProps = Extract; diff --git a/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx index 4e36b1e..87f003e 100644 --- a/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx +++ b/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx @@ -7,10 +7,10 @@ import { Text, Title, } from "@mantine/core"; +import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { StateView } from "@/shared/ui"; import classes from "../AccountIncomePage.module.css"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import { IncomeComparisons } from "./IncomeComparisons.view"; diff --git a/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx b/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx index bf93469..f465dbb 100644 --- a/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx +++ b/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx @@ -1,7 +1,7 @@ import { Paper, Skeleton, Stack } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { LoadingSkeleton } from "@/shared/ui"; import classes from "../AccountIncomePage.module.css"; const AccountIncomePageLoading: FC = () => { diff --git a/src/pages/account-income/ui/components/IncomePeriodForm.stories.tsx b/src/pages/account-income/ui/components/IncomePeriodForm.stories.tsx new file mode 100644 index 0000000..29f735c --- /dev/null +++ b/src/pages/account-income/ui/components/IncomePeriodForm.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { FC } from "react"; +import { FormProvider, useForm, useFormState } from "react-hook-form"; +import type { IncomeForm } from "../../model/period"; +import { IncomePeriodForm } from "./IncomePeriodForm.view"; + +const period = { from: "01.08.2026", to: "27.08.2026" }; +const errors = { + to: { type: "manual", message: "Синтетическая ошибка периода" }, +}; + +type PeriodExampleProps = { invalid?: boolean }; + +const PeriodExample: FC = (props) => { + const methods = useForm({ + defaultValues: period, + errors: props.invalid ? errors : undefined, + }); + const { isSubmitSuccessful } = useFormState({ control: methods.control }); + const submitted = isSubmitSuccessful ? methods.getValues() : null; + return ( + + undefined)} + onReset={() => { + methods.reset(period); + }} + /> + + {submitted + ? `Применено: ${submitted.from} – ${submitted.to}` + : "Период не применён"} + + + ); +}; + +const meta = { + title: "Product/IncomePeriodForm", + component: PeriodExample, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: + "Изолированная форма с синтетическим периодом. Submit показывает значения локально; reset возвращает fixture. Ошибка задана явно: query, URL и production validation остаются у page VM, API и router providers не используются.", + }, + }, + }, + render: () => , +} satisfies Meta; + +export default meta; +type Story = StoryObj; +export const Default: Story = {}; +export const ValidationError: Story = { + render: () => , +}; diff --git a/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx b/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx index ae78c66..7b1be80 100644 --- a/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx +++ b/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx @@ -3,13 +3,13 @@ import { DatePickerInput } from "@mantine/dates"; import type { FC, FormEventHandler } from "react"; import { useController, useFormContext, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import classes from "../AccountIncomePage.module.css"; import { formatDraftDate, type IncomeForm, moscowDate, parseDraftDate, -} from "../AccountIncomePage.vm"; +} from "../../model/period"; +import classes from "../AccountIncomePage.module.css"; type IncomePeriodFormProps = { onSubmit: FormEventHandler; diff --git a/src/pages/account-operations/ui/components/AccountOperationsPageError.view.tsx b/src/pages/account-operations/ui/components/AccountOperationsPageError.view.tsx index c5987d6..ace026c 100644 --- a/src/pages/account-operations/ui/components/AccountOperationsPageError.view.tsx +++ b/src/pages/account-operations/ui/components/AccountOperationsPageError.view.tsx @@ -1,7 +1,7 @@ import { Button } from "@mantine/core"; +import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { StateView } from "@/shared/ui"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; type AccountOperationsPageErrorProps = Extract< diff --git a/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx index 2f02397..019d6ba 100644 --- a/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx @@ -8,6 +8,7 @@ import { Title, } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; +import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { @@ -15,7 +16,6 @@ import { type OperationCategory, operationCategories, } from "@/entities/broker-operation"; -import { StateView } from "@/shared/ui"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; type AccountOperationsPageLoadedProps = Extract< diff --git a/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx b/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx index df54e8e..63b8310 100644 --- a/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx +++ b/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx @@ -1,7 +1,7 @@ import { Group, Skeleton, Stack } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { LoadingSkeleton } from "@/shared/ui"; const skeletonRowIds = ["first", "second", "third", "fourth"]; diff --git a/src/pages/account/ui/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/components/AccountPageEmpty.view.tsx index 8e8ca97..0292eb7 100644 --- a/src/pages/account/ui/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/components/AccountPageEmpty.view.tsx @@ -1,9 +1,9 @@ import { Stack } from "@mantine/core"; +import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import type { BrokerageAccount } from "@/entities/brokerage-account"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; -import { StateView } from "@/shared/ui"; import type { AccountPageIncomeSummary, AccountPageRecentEvents, diff --git a/src/pages/account/ui/components/AccountPageError.view.tsx b/src/pages/account/ui/components/AccountPageError.view.tsx index f10b739..4d85c56 100644 --- a/src/pages/account/ui/components/AccountPageError.view.tsx +++ b/src/pages/account/ui/components/AccountPageError.view.tsx @@ -1,8 +1,8 @@ import { Button } from "@mantine/core"; +import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { StateView } from "@/shared/ui"; type AccountPageErrorProps = { code?: string; retry: () => void }; diff --git a/src/pages/account/ui/components/AccountPageLoading.view.tsx b/src/pages/account/ui/components/AccountPageLoading.view.tsx index ac07237..47a6a97 100644 --- a/src/pages/account/ui/components/AccountPageLoading.view.tsx +++ b/src/pages/account/ui/components/AccountPageLoading.view.tsx @@ -1,7 +1,7 @@ import { Paper, SimpleGrid, Skeleton } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { LoadingSkeleton } from "@/shared/ui"; import classes from "../AccountPage.module.css"; const AccountPageLoading: FC = () => { diff --git a/src/pages/account/ui/components/IncomeSummary.view.tsx b/src/pages/account/ui/components/IncomeSummary.view.tsx index 6b86d66..c789b98 100644 --- a/src/pages/account/ui/components/IncomeSummary.view.tsx +++ b/src/pages/account/ui/components/IncomeSummary.view.tsx @@ -1,10 +1,10 @@ import { Button, Paper, Stack, Text } from "@mantine/core"; +import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import type { IncomeSummary as IncomeSummaryData } from "@/entities/income"; import { formatMoney } from "@/shared/lib"; -import { StateView } from "@/shared/ui"; type IncomeSummaryProps = | { state: "loading"; to: string } diff --git a/src/pages/account/ui/components/RecentOperations.view.tsx b/src/pages/account/ui/components/RecentOperations.view.tsx index 5829887..4b4c69a 100644 --- a/src/pages/account/ui/components/RecentOperations.view.tsx +++ b/src/pages/account/ui/components/RecentOperations.view.tsx @@ -1,9 +1,9 @@ import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { LedgerEntryRow } from "@/entities/broker-operation"; -import { LoadingSkeleton } from "@/shared/ui"; import type { AccountPageRecentOperations } from "../AccountPage.vm"; type RecentOperationsProps = { diff --git a/src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx b/src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx index c7e63b8..5e16dc4 100644 --- a/src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx +++ b/src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx @@ -1,7 +1,7 @@ import { Stack } from "@mantine/core"; +import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { StateView } from "@/shared/ui"; import { AccountsRefreshState, type AccountsRefreshStateProps, diff --git a/src/pages/accounts/ui/components/AccountsPageError.view.tsx b/src/pages/accounts/ui/components/AccountsPageError.view.tsx index 46faf78..a808eec 100644 --- a/src/pages/accounts/ui/components/AccountsPageError.view.tsx +++ b/src/pages/accounts/ui/components/AccountsPageError.view.tsx @@ -1,8 +1,8 @@ import { Button, Stack } from "@mantine/core"; +import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { StateView } from "@/shared/ui"; type AccountsPageErrorProps = { code?: string; retry?: () => void }; diff --git a/src/pages/accounts/ui/components/AccountsPageLoading.view.tsx b/src/pages/accounts/ui/components/AccountsPageLoading.view.tsx index 5248e56..da72c3b 100644 --- a/src/pages/accounts/ui/components/AccountsPageLoading.view.tsx +++ b/src/pages/accounts/ui/components/AccountsPageLoading.view.tsx @@ -1,7 +1,7 @@ import { Card, Group, SimpleGrid, Skeleton, Stack } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { LoadingSkeleton } from "@/shared/ui"; const AccountsPageLoading: FC = () => { const { t } = useTranslation(); diff --git a/src/pages/bond/ui/BondPage.tsx b/src/pages/bond/ui/BondPage.tsx index 63e0047..906dab0 100644 --- a/src/pages/bond/ui/BondPage.tsx +++ b/src/pages/bond/ui/BondPage.tsx @@ -1,8 +1,8 @@ import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link, Navigate } from "react-router"; -import { LoadingSkeleton } from "@/shared/ui"; import { BondPageView } from "./BondPage.view"; import { useBondPageVm } from "./BondPage.vm"; diff --git a/src/pages/bond/ui/BondPage.view.tsx b/src/pages/bond/ui/BondPage.view.tsx index d194c8f..ee92b95 100644 --- a/src/pages/bond/ui/BondPage.view.tsx +++ b/src/pages/bond/ui/BondPage.view.tsx @@ -12,6 +12,7 @@ import { Text, Title, } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { @@ -26,7 +27,6 @@ import { formatMoney, formatSnapshotTime, } from "@/shared/lib"; -import { LoadingSkeleton } from "@/shared/ui"; import classes from "./BondPage.module.css"; import type { BondPageVm } from "./BondPage.vm"; diff --git a/src/pages/bonds/ui/BondsPage.view.tsx b/src/pages/bonds/ui/BondsPage.view.tsx index 66c42bf..e8e9ab3 100644 --- a/src/pages/bonds/ui/BondsPage.view.tsx +++ b/src/pages/bonds/ui/BondsPage.view.tsx @@ -12,6 +12,7 @@ import { TextInput, Title, } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; @@ -21,7 +22,6 @@ import { formatMoney, formatSnapshotTime, } from "@/shared/lib"; -import { LoadingSkeleton } from "@/shared/ui"; import classes from "./BondsPage.module.css"; import type { BondsPageVm } from "./BondsPage.vm"; diff --git a/src/pages/card/ui/CardPage.view.tsx b/src/pages/card/ui/CardPage.view.tsx index b04d6c1..50698e9 100644 --- a/src/pages/card/ui/CardPage.view.tsx +++ b/src/pages/card/ui/CardPage.view.tsx @@ -10,6 +10,8 @@ import { Text, Title, } from "@mantine/core"; +import { AddIcon, DeleteIcon, EditIcon } from "@wealthledger/icons"; +import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui"; import { type FC, useLayoutEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; @@ -20,7 +22,6 @@ import { BankPeriodPicker, BankSummaryView, } from "@/entities/bank-card"; -import { EntryCard, LoadingSkeleton, StateView } from "@/shared/ui"; import classes from "./CardPage.module.css"; import type { CardPageVm } from "./CardPage.vm"; @@ -106,19 +107,7 @@ const CardPageView: FC = (props) => { title={t("bank.editCard")} onClick={() => vm.setEditingCard(true)} > - + = (props) => { loading={vm.archivePending} onClick={() => void vm.toggleArchive()} > - + {card.archivedAt ? ( + + ) : ( + + )} @@ -196,18 +183,7 @@ const CardPageView: FC = (props) => { disabled={Boolean(card.archivedAt) || !vm.cards.data} onClick={() => vm.setOperation("new")} > - + ({ value, label: t(`bank.kinds.${value}`) }), - )} + data={props.kinds} {...field} disabled={isSubmitting || props.editing} onChange={(value) => { @@ -99,24 +106,22 @@ const OperationFields: FC = (props) => { label={t("bank.date")} required valueFormat="DD.MM.YYYY" - value={ - bankDateSchema.safeParse(field.value).success ? field.value : null - } + value={props.validDate(field.value) ? field.value : null} dateParser={(value) => - bankDateSchema.safeParse(bankDateInputValue(value)).success - ? bankDateInputValue(value) + props.validDate(props.dateInputValue(value)) + ? props.dateInputValue(value) : null } fixOnBlur={false} onInput={(event) => - field.onChange(bankDateInputValue(event.currentTarget.value)) + field.onChange(props.dateInputValue(event.currentTarget.value)) } onChange={(value) => field.onChange(value ?? "")} onBlur={(event) => { - field.onChange(bankDateInputValue(event.currentTarget.value)); + field.onChange(props.dateInputValue(event.currentTarget.value)); field.onBlur(); }} - maxDate={bankToday()} + maxDate={props.maxDate()} popoverProps={{ withinPortal: false, middlewares: { shift: { crossAxis: true } }, @@ -153,7 +158,6 @@ const OperationFields: FC = (props) => { type RefundFieldsProps = { editing: boolean; expenses: Choice[]; - selected?: Choice; onSelect: (id: string) => void; limit: BankOperation["availableRefund"] | undefined; }; @@ -172,14 +176,7 @@ const RefundFields: FC = (props) => {