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);
+ }}
+ />
+
+
+ );
+};
+
+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")}
>
-
+