diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..d9d709b --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "codegraph": { + "type": "stdio", + "command": "codegraph", + "args": [ + "serve", + "--mcp", + "--path", + "C:\\Users\\shats\\Dev\\cloudflare-domain-manager" + ] + } + } +} diff --git a/.cursor/rules/ai-coding-assistant.mdc b/.cursor/rules/ai-coding-assistant.mdc new file mode 100644 index 0000000..edfa468 --- /dev/null +++ b/.cursor/rules/ai-coding-assistant.mdc @@ -0,0 +1,56 @@ +--- +description: Concise AI assistant — clean code, token efficiency, codebase alignment +alwaysApply: true +--- + +# AI Coding Assistant + +You work inside a real codebase. Be precise, concise, and aligned with existing patterns. + +## Clean Code + +- Minimal, readable, maintainable code; simple over clever +- Meaningful names; DRY; small single-responsibility functions +- Follow existing project style and patterns + +## Token Efficiency + +- Do not explain obvious things +- No step-by-step reasoning unless explicitly asked +- Output only what is necessary: code, brief comments when needed +- No long prose, summaries, or repetition +- If unsure — ask a short clarifying question instead of guessing + +## Work With Existing Codebase + +- Analyze surrounding code before generating new code +- Reuse existing utilities, helpers, and patterns +- Do not reinvent functionality already in the project +- Respect project architecture + +## Documentation Awareness + +- Check project docs, README, comments, and types before implementing +- If behavior is unclear: infer from types/tests/examples, or ask +- Prefer documented approaches over assumptions + +## Output Format + +- Default: only code +- If explanation is required — keep it under 3–5 lines +- Highlight only important decisions + +## Refactoring + +- Preserve behavior unless told otherwise +- Improve readability and structure; reduce complexity and duplication + +## Debugging + +- Identify root cause, not symptoms +- Suggest minimal fix; avoid rewriting large parts unless necessary + +## Missing Context + +- Ask concise, targeted questions +- Do not hallucinate APIs or project structure diff --git a/.cursor/rules/backend-api-ui.mdc b/.cursor/rules/backend-api-ui.mdc new file mode 100644 index 0000000..0f91e77 --- /dev/null +++ b/.cursor/rules/backend-api-ui.mdc @@ -0,0 +1,37 @@ +--- +description: Backend API — при изменениях, затрагивающих UI, строго следовать shadcn Components/Blocks +globs: apps/api/**/*,packages/shared/**/* +alwaysApply: false +--- + +# Backend API + shadcn/ui + +Fastify backend: `apps/api/`. Контракты — `@cfdm/shared` (Zod). Frontend — TanStack Query. + +## Обязательный порядок + +1. **Backend** — route, service, Vitest (`app.inject()`) +2. **Схемы** — `@cfdm/shared` (не дублировать в `apps/web/src/lib/schemas.ts`) +3. **UI** — shadcn MCP ([`shadcn-mcp.mdc`](shadcn-mcp.mdc)) + +## Запрещено на frontend при доработке API + +- Новые raw `` / `
`, ` + + +``` + +### Card + +`CardHeader` / `CardTitle` / `CardDescription` / `CardContent` / `CardFooter` — полная композиция из docs. + +### Таблицы + +`Table`, `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell` — из docs. +Сложная таблица → [Data Table](https://ui.shadcn.com/docs/components/data-table) + block `dashboard-01`. + +### Графики + +`Chart` + `ChartContainer` + `chartConfig` с `var(--chart-1)` — не raw recharts без обёртки. + +### Иконки в Button + +```tsx + +``` + +Без `size-4` на иконке внутри shadcn-компонента. + +## Стек (не shadcn, но обязателен) + +TanStack Router + Query — [`vite-tanstack-frontend.mdc`](vite-tanstack-frontend.mdc). + +- Preset: **base-nova** + **neutral** — [`apps/web/components.json`](apps/web/components.json), [`packages/ui/components.json`](packages/ui/components.json) +- `@base-ui/react` → `render` prop (не Radix `asChild`) +- **Не Next.js** — нет Server Components, `'use client'` + +## Эталоны проекта + +| Зона | Файл | Block | +|------|------|-------| +| Shell | `apps/web/src/components/layout/app-shell.tsx` | [sidebar-07](https://ui.shadcn.com/blocks) | +| Login | `apps/web/src/routes/login.tsx` | [login-03](https://ui.shadcn.com/blocks) | +| Dashboard | `apps/web/src/routes/_auth/index.tsx` | [dashboard-01](https://ui.shadcn.com/blocks) | +| CRUD | `routes/_auth/services.tsx`, `domains/index.tsx` | Card + Field + Table | + +## Структура файлов + +``` +apps/web/src/ + components/ ← domain + layout + shared (blocks) + routes/ ← страницы (композиция @cfdm/ui, без raw HTML) + queries/ ← queryOptions (не inline в routes) + lib/schemas.ts ← Zod для форм + +packages/ui/src/ + components/ ← только CLI (не трогать под кейс) + hooks/ ← registry hooks (use-mobile) + lib/utils.ts ← cn() + styles/globals.css ← только output shadcn CLI +``` + +## Чеклист перед завершением задачи + +- [ ] MCP shadcn: search + examples (+ add command при новых примитивах) +- [ ] Решение есть в https://ui.shadcn.com/docs/components или /blocks +- [ ] Компоненты добавлены через `pnpm dlx shadcn@latest add` из `apps/web` +- [ ] Нет кастомного CSS и raw HTML-примитивов +- [ ] Semantic tokens, `variant`/`size` вместо переопределения className +- [ ] UI-импорты через `@cfdm/ui/components/*` +- [ ] `pnpm --filter web build` без ошибок + +## Язык + +Ответы пользователю — русский. Commits — [`commit-messages-ru.mdc`](commit-messages-ru.mdc). diff --git a/.cursor/rules/frontend-ui-patterns.mdc b/.cursor/rules/frontend-ui-patterns.mdc new file mode 100644 index 0000000..d259ada --- /dev/null +++ b/.cursor/rules/frontend-ui-patterns.mdc @@ -0,0 +1,111 @@ +--- +description: Единые UI-паттерны web — shared components, docs workflow, матрица стандартизации +globs: apps/web/**/* +alwaysApply: false +--- + +# Frontend UI Patterns + +См. также: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks). + +## Docs workflow (обязательно) + +0. Codegraph `codegraph_explore` — найти существующие shared/domain-компоненты +1. Skill [`.agents/skills/shadcn/SKILL.md`](../.agents/skills/shadcn/SKILL.md) — component selection +2. MCP `plugin-shadcn-shadcn` — search → examples → add command +3. CLI: `cd apps/web && pnpm dlx shadcn@latest docs ` → сверить API с [Components](https://ui.shadcn.com/docs/components) +4. Код по examples + docs API (только после совпадения MCP ↔ docs) +5. Context7 — **только** TanStack Router/Query, Recharts (не shadcn primitives) +6. MCP `get_audit_checklist` — перед merge +7. Codegraph `codegraph_status` — Pending sync пустой + +## Иерархия компонентов + +``` +@cfdm/ui/components/* ← только CLI (packages/ui) +apps/web/src/components/ ← shared + domain + layout + page-shell.tsx ← обёртка страницы + page-header.tsx + empty-state.tsx + query-state.tsx + confirm-dialog.tsx + data-table-card.tsx + section-cards.tsx + status-badge.tsx + form-sheet.tsx ← Sheet + RHF FormProvider + form-field.tsx ← Field + Controller + aria-invalid + table-card.tsx ← Card + Table wrapper + loading-button.tsx ← Button + Spinner + label swap + section-cards-skeleton.tsx + table-skeleton.tsx + layout/ ← app-shell, site-header + domain-* ← бизнес-компоненты +``` + +## Матрица стандартизации + +| Элемент | Shared | Primitive | +|---------|--------|-----------| +| Page wrapper | `PageShell` | — | +| Page title | `PageHeader` | — | +| Stat metrics | `SectionCards` | `Card` | +| Data list | `DataTableCard` | `Table`, `InputGroup` | +| Empty | `EmptyState` | `Empty` | +| Loading / Error | `QueryState` | `Skeleton`, `Alert` | +| Status | `StatusBadge` | `Badge` | +| Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` | +| Form field | `FormField` | `Field`, `Input`, `Select` | +| Submit button | `LoadingButton` | `Button`, `Spinner` | +| Table wrapper | `TableCard` | `Table`, `Card` | +| Delete confirm | `ConfirmDialog` | `AlertDialog` | +| List row | — | `Item variant="outline" size="sm"` | +| Nav | `AppSidebar` | `Sidebar` | +| Breadcrumbs | `SiteHeader` | `Breadcrumb` | +| Dates | `lib/format.ts` | — | + +## Overlay selection + +| Сценарий | Компонент | +|----------|-----------| +| Create/edit форма | `Sheet` | +| Destructive confirm | `AlertDialog` via `ConfirmDialog` | +| Modal preview | `Dialog` | + +## Block registry + +| Зона | Block | +|------|-------| +| Shell | sidebar-07 | +| Dashboard | dashboard-01 | +| Login | login-03 | + +## Spacing + +``` +PageShell: gap-4 md:gap-6, px-4 lg:px-6 py-4 md:py-6 +Card grid: gap-4 +FieldGroup: gap-4 +Item list: gap-2 +Toolbar: gap-2 +``` + +**Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner. + +## UX/UI 2026 (состояния данных) + +Каждый блок: **default, hover, focus, disabled, empty, loading, error**. + +- **Loading** — `Skeleton` с размерами финального контента (`QueryState skeleton={…}`), не Spinner на странице +- **Empty** — `EmptyState` с CTA (кнопка создания) +- **Zero-results** — отдельный empty с «Сбросить фильтр» (не «Создать») +- **Error** — `QueryState` + `onRetry` + иконка + текст +- **Overflow** — `truncate`, `max-w-*`, `Tooltip`; `tabular-nums` для чисел +- **Density** — таблицы `h-10 text-sm`; max 1 primary CTA на экран +- **A11y** — `aria-invalid` на полях, `aria-label`/`sr-only` на icon-only кнопках, цвет не единственный сигнал статуса + +## Button hierarchy (max 1 primary per screen) + +1. `default` — главный CTA +2. `outline` — вторичные действия +3. `ghost` / `link` — навигация, cancel +4. `destructive` — только с `ConfirmDialog` diff --git a/.cursor/rules/gitflow.mdc b/.cursor/rules/gitflow.mdc new file mode 100644 index 0000000..2f2025e --- /dev/null +++ b/.cursor/rules/gitflow.mdc @@ -0,0 +1,113 @@ +--- +description: Gitflow Workflow Rules. These rules should be applied when performing git operations. +globs: ["**/*"] +alwaysApply: false +--- +# Gitflow Workflow Rules + +## Main Branches + +### main (or master) +- Contains production-ready code +- Never commit directly to main +- Only accepts merges from: + - hotfix/* branches + - release/* branches +- Must be tagged with version number after each merge + +### develop +- Main development branch +- Contains latest delivered development changes +- Source branch for feature branches +- Never commit directly to develop + +## Supporting Branches + +### feature/* +- Branch from: develop +- Merge back into: develop +- Naming convention: feature/[issue-id]-descriptive-name +- Example: feature/123-user-authentication +- Must be up-to-date with develop before creating PR +- Delete after merge + +### release/* +- Branch from: develop +- Merge back into: + - main + - develop +- Naming convention: release/vX.Y.Z +- Example: release/v1.2.0 +- Only bug fixes, documentation, and release-oriented tasks +- No new features +- Delete after merge + +### hotfix/* +- Branch from: main +- Merge back into: + - main + - develop +- Naming convention: hotfix/vX.Y.Z +- Example: hotfix/v1.2.1 +- Only for urgent production fixes +- Delete after merge + +## Commit Messages + +- Format: `type(scope): description` +- Types: + - feat: New feature + - fix: Bug fix + - docs: Documentation changes + - style: Formatting, missing semicolons, etc. + - refactor: Code refactoring + - test: Adding tests + - chore: Maintenance tasks + +## Version Control + +### Semantic Versioning +- MAJOR version for incompatible API changes +- MINOR version for backwards-compatible functionality +- PATCH version for backwards-compatible bug fixes + +## Pull Request Rules + +1. All changes must go through Pull Requests +2. Required approvals: minimum 1 +3. CI checks must pass +4. No direct commits to protected branches (main, develop) +5. Branch must be up to date before merging +6. Delete branch after merge + +## Branch Protection Rules + +### main & develop +- Require pull request reviews +- Require status checks to pass +- Require branches to be up to date +- Include administrators in restrictions +- No force pushes +- No deletions + +## Release Process + +1. Create release branch from develop +2. Bump version numbers +3. Fix any release-specific issues +4. Create PR to main +5. After merge to main: + - Tag release + - Merge back to develop + - Delete release branch + +## Hotfix Process + +1. Create hotfix branch from main +2. Fix the issue +3. Bump patch version +4. Create PR to main +5. After merge to main: + - Tag release + - Merge back to develop + - Delete hotfix branch diff --git a/.cursor/rules/project-structure.mdc b/.cursor/rules/project-structure.mdc index 7476e82..eed4a4d 100644 --- a/.cursor/rules/project-structure.mdc +++ b/.cursor/rules/project-structure.mdc @@ -1,26 +1,81 @@ --- -description: Структура проекта vps-tracker и соглашения по именованию +description: Структура vps-tracker — pnpm monorepo (apps/web, apps/api, packages/ui, packages/shared, packages/db) alwaysApply: true --- # Структура проекта vps-tracker -## Папки +pnpm workspaces monorepo. Frontend — shadcn/ui + TanStack Router/Query + TS. Backend — Fastify + Drizzle + better-sqlite3 + TS. -- `server/` — Express backend, SQLite (sql.js) -- `src/` — React frontend (Vite) -- `server/adapters/` — один адаптер на провайдера API (billmanager) -- `server/routes/` — Express роутеры по сущностям -- `server/db/` — схема, миграции, seed +## Layout + +``` +vps-tracker/ +├── apps/ +│ ├── web/ # Vite SPA (TSX) — TanStack Router + Query, shadcn/ui +│ └── api/ # Fastify 5 API (TS) — @fastify/* + Drizzle +├── packages/ +│ ├── ui/ # @cfdm/ui — shadcn primitives (output `shadcn add`) +│ ├── shared/ # @cfdm/shared — Zod-схемы контрактов, общие типы +│ └── db/ # @cfdm/db — Drizzle schema, repositories, миграции +├── data/ # SQLite база (том Docker, gitignored) +├── pnpm-workspace.yaml +├── package.json +└── tsconfig.base.json +``` + +## apps/web + +``` +apps/web/ +├── components.json # ui alias → @cfdm/ui/components +├── vite.config.ts # React + TanStack Router plugin, proxy /api → apps/api +├── tsconfig.json +└── src/ + ├── main.tsx # QueryClientProvider, createRouter, import '@cfdm/ui/globals.css' + ├── routes/ # file-based routes (__root.tsx, _auth/...) + ├── queries/ # queryOptions + key factories по сущностям + ├── components/ # shared + layout + domain (blocks) + └── lib/ # api-client, queryClient, router, schemas +``` + +## apps/api + +``` +apps/api/ +└── src/ + ├── index.ts # buildApp() + ├── config.ts # env через Zod + ├── routes/ # тонкие Fastify plugins + ├── services/ # бизнес-логика + адаптеры (billmanager/*) + └── plugins/ # @fastify/* registration +``` + +## packages/db + +``` +packages/db/src/ +├── schema/ # Drizzle tables по сущностям +├── repositories/ # typed queries (inArray, JOIN, transaction) +└── migrations/ # drizzle-kit generate/migrate +``` ## Именование -- Файлы: kebab-case (provider-accounts.js, row-mappers.js) -- Роуты: `/api/vps`, `/api/provider-accounts`, `/api/sync/:accountId` +- Файлы: kebab-case (`provider-accounts.ts`, `row-mappers.ts`) +- Компоненты: PascalCase (`PageHeader.tsx`) +- Роуты API: `/api/vps`, `/api/provider-accounts`, `/api/sync/:accountId` - ID записей: `vps-bm-{accountId}-{externalId}`, `pay-bm-{accountId}-{externalId}` ## Barrel exports -Модули с несколькими файлами экспортируют через `index.js`: -- `server/adapters/billmanager/index.js` — testConnection, syncFromBillmanager, fetchDashboardInfo -- `server/db/index.js` — initDb, getDb, saveDb +- `packages/ui` — `@cfdm/ui/components/*`, `@cfdm/ui/lib/utils`, `@cfdm/ui/hooks/*`, `@cfdm/ui/globals.css` +- `packages/shared` — `@cfdm/shared/contracts/*` (Zod), `@cfdm/shared/types/*` +- `packages/db` — `@cfdm/db/schema`, `@cfdm/db/repositories/*` +- `apps/api/src/services/billmanager/index.ts` — `testConnection`, `syncFromBillmanager`, `fetchDashboardInfo` + +## Скоупы правил + +- Frontend (`apps/web`, `packages/ui`) — [`frontend-monorepo.mdc`](frontend-monorepo.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc), [`vite-tanstack-frontend.mdc`](vite-tanstack-frontend.mdc), [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`shadcn-ui-production.mdc`](shadcn-ui-production.mdc) +- Backend (`apps/api`, `packages/db`, `packages/shared`) — [`backend-fastify.mdc`](backend-fastify.mdc), [`backend-drizzle.mdc`](backend-drizzle.mdc), [`backend-mcp.mdc`](backend-mcp.mdc), [`backend-testing.mdc`](backend-testing.mdc), [`backend-api-ui.mdc`](backend-api-ui.mdc), [`sqlite.mdc`](sqlite.mdc) +- API + UI-связка — [`backend-api-ui.mdc`](backend-api-ui.mdc) diff --git a/.cursor/rules/server-conventions.mdc b/.cursor/rules/server-conventions.mdc deleted file mode 100644 index 7c7757c..0000000 --- a/.cursor/rules/server-conventions.mdc +++ /dev/null @@ -1,25 +0,0 @@ ---- -description: Паттерны для Express, db, adapters -globs: server/**/*.js -alwaysApply: false ---- - -# Server conventions - -## Express - -- Роутеры в `routes/`, подключаются в `index.js` через `app.use('/api/...', router)` -- Ошибки: `res.status(500).json({ error: err.message })` -- 404: `res.status(404).json({ error: 'Not found' })` - -## Database - -- Доступ через `getDb()` — обёртка с `prepare().all()`, `prepare().get()`, `run()` -- После `run()` вызывается `saveDb()` автоматически -- Миграции в `db/migrations.js`, добавляют колонки через ALTER TABLE - -## Adapters - -- Один провайдер = папка в `adapters/` (billmanager) -- Разделение: client (HTTP), parsers, mappers, operations, sync -- Публичный API через `index.js` diff --git a/.cursor/rules/shadcn-mcp.mdc b/.cursor/rules/shadcn-mcp.mdc new file mode 100644 index 0000000..0e990e9 --- /dev/null +++ b/.cursor/rules/shadcn-mcp.mdc @@ -0,0 +1,49 @@ +--- +description: ВСЕГДА использовать MCP-плагин shadcn UI перед любым UI-кодом +alwaysApply: true +--- + +# shadcn MCP — обязательно + +Перед **любой** задачей с UI (новый экран, компонент, стили, рефакторинг внешнего вида) — **сначала MCP** `plugin-shadcn-shadcn`, не память и не веб-поиск. + +CLI и docs — **после** MCP, по команде из `get_add_command_for_items`. + +## Порядок (строго) + +0. **Codegraph** `codegraph_explore` — найти существующие реализации и shared-обёртки (один вызов перед правками) +1. **`get_project_registries`** — какие registry доступны в проекте +2. **`search_items_in_registries`** — компонент, block, example (`query`: `"card"`, `"tabs demo"`, `"dashboard"`, `"scroll-area"`) +3. **`get_item_examples_from_registries`** — полный код примера перед написанием JSX +4. **`get_add_command_for_items`** — точная CLI-команда `pnpm dlx shadcn@latest add ...` +5. Выполнить add из `apps/web` (см. [`frontend-monorepo.mdc`](frontend-monorepo.mdc)) +6. **CLI docs (обязательно):** `cd apps/web && pnpm dlx shadcn@latest docs ` — сверить API/props с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components) +7. Сверить examples из MCP с API из docs CLI — реализовать только после совпадения +8. Адаптировать пример под TanStack Router / Query → `apps/web/src/` +9. **Context7** — только TanStack / Recharts / не-shadcn (не заменяет шаги 1–8 для примитивов) +10. **`get_audit_checklist`** — перед merge PR +11. **Codegraph** `codegraph_status` — Pending sync пустой после правок + +## Когда вызывать MCP + +| Задача | MCP | +|--------|-----| +| Новая страница / layout | `search` → `types: ["block"]` → examples → add block | +| Нет примитива в `@cfdm/ui` | `search` → `get_add_command_for_items` → add | +| Сомнение в API/props | `get_item_examples_from_registries` | +| Ревью UI перед сдачей | `get_audit_checklist` | + +## Запрещено + +- Писать UI по памяти, не проверив MCP +- Копипаст с ui.shadcn.com без examples/add из MCP +- Самописные примитивы, если есть item в registry +- Пропускать MCP «потому что компонент простой» + +## Сервер и инструменты + +- **MCP server:** `plugin-shadcn-shadcn` +- **Инструменты:** `get_project_registries`, `search_items_in_registries`, `get_item_examples_from_registries`, `get_add_command_for_items`, `view_items_in_registries`, `list_items_in_registries`, `get_audit_checklist` +- Перед вызовом — прочитать schema в `mcps/plugin-shadcn-shadcn/tools/` + +Связанные правила: [`shadcn-ui-production.mdc`](shadcn-ui-production.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc). diff --git a/.cursor/rules/shadcn-ui-production.mdc b/.cursor/rules/shadcn-ui-production.mdc new file mode 100644 index 0000000..988db8e --- /dev/null +++ b/.cursor/rules/shadcn-ui-production.mdc @@ -0,0 +1,41 @@ +--- +description: shadcn/ui — глобальные UI-принципы проекта; frontend см. frontend-shadcn.mdc +alwaysApply: true +--- + +# shadcn/ui — правила проекта + +UI строится **исключительно** по [shadcn/ui](https://ui.shadcn.com/docs/installation): [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks), [Monorepo](https://ui.shadcn.com/docs/monorepo). + +**Первый шаг любой UI-задачи — MCP `plugin-shadcn-shadcn`** (см. [`shadcn-mcp.mdc`](shadcn-mcp.mdc)): search → examples → add command → CLI. + +## Разработка frontend + +**Все правила frontend** — в [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc) и [`frontend-monorepo.mdc`](frontend-monorepo.mdc) (globs: `apps/web/**`, `packages/ui/**`). + +Кратко: docs → CLI из `apps/web` → Block → композиция → `pnpm --filter web build`. Кастомный CSS и самописные примитивы **запрещены**. + +## Стек + +- Monorepo: `apps/web` + `packages/ui` (`@cfdm/ui`), pnpm workspaces +- Vite + TanStack Router/Query + shadcn **base-nova** +- Конфиг: [`apps/web/components.json`](apps/web/components.json), [`packages/ui/components.json`](packages/ui/components.json) +- Тема: `pnpm dlx shadcn@latest apply b2fA --only theme -y` — единственный способ менять `packages/ui/src/styles/globals.css` + +## Backend → UI + +При правках API с экранами: [`backend-api-ui.mdc`](backend-api-ui.mdc). Backend: `apps/api` (Fastify + Drizzle). + +## Устаревший стек (запрещён в apps/web) + +Проект мигрирует с Tabler-стека на shadcn/ui. **Не использовать**: + +- `@tabler/core` и `@tabler/core/dist/css/tabler.min.css` — заменить на `@cfdm/ui/globals.css` +- `@tabler/icons-react` — иконки только `lucide-react` +- `chart.js` — графики только `recharts` через shadcn `Chart`/`ChartContainer` +- `react-router-dom` — роутинг только TanStack Router (`createFileRoute`, file-based routes) +- Bootstrap/Tabler utility-классы (`page`, `navbar-vertical`, `nav-link`, `container-tight`, `spinner-border`, `d-lg-none`, `page-wrapper`) — layout через Tailwind + shadcn blocks (sidebar-07) + +## Язык + +Русский. Commits: [`commit-messages-ru.mdc`](commit-messages-ru.mdc). diff --git a/.cursor/rules/sqlite.mdc b/.cursor/rules/sqlite.mdc new file mode 100644 index 0000000..2cb3402 --- /dev/null +++ b/.cursor/rules/sqlite.mdc @@ -0,0 +1,188 @@ +--- +description: Definitive guidelines for writing robust, performant, and secure SQLite code. Focuses on schema design, query optimization, and transaction management. +globs: **/* +--- +# sqlite Best Practices + +> В проекте используется **better-sqlite3** через Drizzle (`packages/db`). WASM-`sql.js` выводится из эксплуатации. См. [`backend-drizzle.mdc`](backend-drizzle.mdc). + +SQLite is the go-to embedded SQL engine for local, reliable storage. Adhere to these rules to ensure your SQLite code is maintainable, performant, and secure. + +## 1. Data Modeling & Schema Design + +Design your schema for integrity and performance from day one. + +* **Primary Keys**: Always use `INTEGER PRIMARY KEY AUTOINCREMENT` for ID columns. This optimizes `rowid` lookups and simplifies ID generation. + * ❌ BAD: + ```sql + CREATE TABLE users ( + id TEXT PRIMARY KEY, -- Manual UUIDs or similar + name TEXT NOT NULL + ); + ``` + * ✅ GOOD: + ```sql + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + ); + ``` + +* **Data Types & Constraints**: Declare appropriate data types and enforce integrity with `NOT NULL`, `UNIQUE`, and `FOREIGN KEY` constraints. + * ❌ BAD: + ```sql + CREATE TABLE products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, -- Allows NULL, no uniqueness + price REAL + ); + ``` + * ✅ GOOD: + ```sql + CREATE TABLE products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + price REAL NOT NULL, + stock INTEGER DEFAULT 0, + category_id INTEGER, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL + ); + ``` + +* **Naming Conventions**: Use `lower_case_snake_case` for all table, column, and index names. Avoid SQLite keywords as identifiers. + * ❌ BAD: `CREATE TABLE My_Users ( UserId INTEGER PRIMARY KEY );` + * ✅ GOOD: `CREATE TABLE my_users ( user_id INTEGER PRIMARY KEY );` + +## 2. Performance Considerations + +Optimize for speed by minimizing I/O and leveraging the SQLite engine. + +* **Enable WAL Mode**: Always enable Write-Ahead Logging for better concurrency and write performance. + * ❌ BAD: Default journal mode (`DELETE`). + * ✅ GOOD (at database open or once): + ```sql + PRAGMA journal_mode = WAL; + ``` + +* **Relax Synchronous Mode**: When using WAL, set `synchronous` to `NORMAL` for faster commits, accepting minimal risk of data loss on power failure (not app crash). + * ❌ BAD: Default `synchronous = FULL`. + * ✅ GOOD (at database open or once): + ```sql + PRAGMA synchronous = NORMAL; + ``` + +* **Indexes**: Create indexes on columns frequently used in `WHERE`, `ORDER BY`, `GROUP BY`, or `JOIN` clauses. Avoid over-indexing. + * ❌ BAD: + ```sql + SELECT * FROM users WHERE email = 'test@example.com'; -- No index on email + ``` + * ✅ GOOD: + ```sql + CREATE INDEX idx_users_email ON users(email); + SELECT id, name FROM users WHERE email = 'test@example.com'; + ``` + * **Multi-column Indexes**: For queries filtering/sorting on multiple columns, create a multi-column index matching the query order. + ```sql + CREATE INDEX idx_products_category_price ON products(category_id, price); + SELECT * FROM products WHERE category_id = 1 ORDER BY price DESC; + ``` + +* **Query Optimization**: Select only the columns you need. Push filtering, sorting, and aggregation into SQL. + * ❌ BAD: + ```sql + SELECT * FROM products; -- Fetch all columns + -- Then filter/sort in application code + ``` + * ✅ GOOD: + ```sql + SELECT id, name, price FROM products WHERE stock > 0 ORDER BY price ASC LIMIT 10; + ``` + +## 3. Transactions & Concurrency + +Ensure data consistency and improve write performance with explicit transactions. + +* **Wrap Writes in Transactions**: Group multiple `INSERT`, `UPDATE`, `DELETE` operations within a single transaction. This significantly reduces disk I/O. + * ❌ BAD: + ```sql + INSERT INTO logs (action) VALUES ('User created'); + INSERT INTO users (name) VALUES ('New User'); + INSERT INTO logs (action) VALUES ('User name updated'); + UPDATE users SET name = 'Updated User' WHERE id = 1; + ``` + * ✅ GOOD: + ```sql + BEGIN; + INSERT INTO logs (action) VALUES ('User created'); + INSERT INTO users (name) VALUES ('New User'); + INSERT INTO logs (action) VALUES ('User name updated'); + UPDATE users SET name = 'Updated User' WHERE id = 1; + COMMIT; + ``` + +* **Error Handling**: Use `ROLLBACK` to revert all changes if any operation within a transaction fails. + * ✅ GOOD: + ```sql + BEGIN; + -- Perform operations + INSERT INTO users (name) VALUES ('Valid User'); + INSERT INTO users (name) VALUES (NULL); -- This will fail due to NOT NULL + -- If an error occurs, catch it and: + ROLLBACK; + -- Else: + COMMIT; + ``` + +## 4. Security Best Practices + +Prevent common vulnerabilities like SQL injection. + +* **Prepared Statements**: Always use prepared statements with bound parameters. NEVER concatenate user input directly into SQL queries. + * ❌ BAD: + ```sql + String name = userInput.getName(); + String sql = "INSERT INTO users (name) VALUES ('" + name + "');"; // SQL Injection risk! + ``` + * ✅ GOOD (using a typical API pattern): + ```sql + PreparedStatement stmt = connection.prepareStatement("INSERT INTO users (name) VALUES (?);"); + stmt.setString(1, userInput.getName()); + stmt.executeUpdate(); + ``` + +* **Enable Foreign Key Enforcement**: Always enable foreign key constraints at runtime. SQLite defaults to `OFF` for backward compatibility. + * ❌ BAD: Forgetting to enable foreign keys, leading to orphaned records. + * ✅ GOOD (at database open or once per connection): + ```sql + PRAGMA foreign_keys = ON; + ``` + +* **File Permissions**: Store database files in write-protected directories and set restrictive file permissions to limit unauthorized access. This is OS-specific but critical. + +## 5. Common Pitfalls & Gotchas + +Avoid these common mistakes that lead to bugs and performance issues. + +* **Forgetting `PRAGMA foreign_keys = ON;`**: This is the most common pitfall. Always enable it. +* **Selecting `*`**: Only retrieve the columns you actually need. +* **Application-level Filtering/Sorting**: Delegate these operations to SQL for better performance, especially on large datasets. +* **Not Using Transactions**: Leads to slow writes and potential data inconsistencies. +* **Using SQLite for High-Concurrency Writes**: SQLite is a single-writer database. If multiple processes need to write concurrently, consider a client-server RDBMS. + +## 6. Testing Approaches + +Ensure your data access logic is robust and correct. + +* **In-Memory Databases**: Use `:memory:` databases for fast, isolated unit and integration tests of your data access layer. + * ✅ GOOD (example in Python, similar patterns exist in other languages): + ```python + import sqlite3 + conn = sqlite3.connect(':memory:') + cursor = conn.cursor() + cursor.execute("CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)") + # ... run tests ... + conn.close() # Database vanishes + ``` + +* **Seed Data**: Create consistent, reproducible test data for your tests. +* **Mocking**: For higher-level tests, mock your database interactions to focus on business logic. \ No newline at end of file diff --git a/.cursor/rules/vite-tanstack-frontend.mdc b/.cursor/rules/vite-tanstack-frontend.mdc new file mode 100644 index 0000000..f5fdfbb --- /dev/null +++ b/.cursor/rules/vite-tanstack-frontend.mdc @@ -0,0 +1,120 @@ +--- +description: Vite + TanStack Router v1 + TanStack Query v5 — routing, loaders, queries, mutations +globs: apps/web/**/*.{tsx,ts} +alwaysApply: false +--- + +# Vite + TanStack Router + Query + +Фронтенд: **Vite SPA**, не Next.js. Нет Server Components, App Router, `'use client'`. + +## Структура + +``` +apps/web/src/ + routes/ # file-based routes (__root.tsx, _auth/, ...) + queries/ # queryOptions factories + key factories + lib/ # api-client, queryClient, auth, schemas + components/ # domain + layout (UI primitives → @cfdm/ui) + main.tsx +``` + +## Архитектура + +- **Router** — маршрутизация, URL state, navigation, loaders +- **Query** — server state, cache, mutations +- **Loader** — `queryClient.ensureQueryData()` до рендера → без спиннеров на route data +- **Компоненты** — UI; данные из Query cache + +## QueryClient + Router + +```ts +// lib/queryClient.ts +export const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 60_000 } }, +}) + +// lib/router.ts +export const router = createRouter({ + routeTree, + context: { queryClient }, + defaultPreload: 'intent', +}) + +declare module '@tanstack/react-router' { + interface Register { router: typeof router } +} +``` + +## Query definitions + +- `queryOptions` factories в `queries/`, не inline в компонентах +- Key factories: `all` → `lists` / `details` → `list(filters)` / `detail(id)` + +```ts +export const serviceKeys = { + all: ['services'] as const, + list: () => [...serviceKeys.all, 'list'] as const, +} + +export const servicesQueryOptions = () => + queryOptions({ + queryKey: serviceKeys.list(), + queryFn: () => api.get('/api/v1/services'), + }) +``` + +## Loader + component + +```tsx +export const Route = createFileRoute('/_auth/services')({ + loader: ({ context: { queryClient } }) => + queryClient.ensureQueryData(servicesQueryOptions()), + component: ServicesPage, +}) + +function ServicesPage() { + const { data } = useQuery(servicesQueryOptions()) // из cache loader + return ... +} +``` + +## Search params + +- Zod + `validateSearch`; доступ через `Route.useSearch()` +- Search params = source of truth для фильтров/пагинации +- Передавать в `queryOptions` для query key и fetcher + +## Mutations + +```ts +onSuccess: (newItem) => { + queryClient.setQueryData(keys.detail(newItem.id), newItem) + queryClient.invalidateQueries({ queryKey: keys.lists() }) +} +``` + +- `setQueryData` + `invalidateQueries`, не только invalidate +- Навигация после create — когда cache уже тёплый + +## Routing + +- `createFileRoute` для file-based routes +- `` для внутренней навигации, не `` +- Pathless layouts: `_auth/` для protected routes +- Auth guard в `beforeLoad` pathless route + +## Запреты + +- `useEffect` для fetch данных — только loader / `useQuery` +- Inline `queryKey` в компонентах — только factories из `queries/` +- `useQuery` с позиционными аргументами (v5 — только options object) +- `window.location` для search params + +## Prefetch + +`onMouseEnter` на `` → `queryClient.prefetchQuery(detailOptions(id))` + +## DevTools + +Только в dev: `TanStackRouterDevtools`, `ReactQueryDevtools` diff --git a/.cursor/rules/vitest-best-practices.mdc b/.cursor/rules/vitest-best-practices.mdc new file mode 100644 index 0000000..34465d7 --- /dev/null +++ b/.cursor/rules/vitest-best-practices.mdc @@ -0,0 +1,251 @@ +--- +description: Opinionated best practices for fast, reliable Vitest unit and integration tests in JS/TS projects. +globs: **/*.{js,ts,jsx,tsx} +alwaysApply: false +--- + +# Vitest Best Practices + +Vitest is the definitive testing framework for our Vite-powered projects. It offers a fast, Jest-compatible API with deep integration into the Vite ecosystem. Adhering to these guidelines ensures our tests are robust, performant, and easy to maintain. + +## 1. Code Organization & Naming + +**Always co-locate test files with their source.** This improves discoverability and ensures tests are updated alongside their implementation. + +* **File Naming**: Use `*.test.{ts,tsx,js,jsx}`. +* **Location**: Place test files directly next to the component or module they test. + +❌ BAD: +``` +// src/components/Button/Button.tsx +// tests/components/Button.test.tsx +``` + +✅ GOOD: +```typescript +// src/components/Button/Button.tsx +// src/components/Button/Button.test.tsx +``` + +## 2. Test Structure & Isolation + +**Organize tests logically using `describe` and `it` (or `test`) blocks.** Ensure each test is isolated and deterministic. + +* **`describe`**: Group related tests into suites. +* **`it` / `test`**: Define individual test cases. Prefer `it` for consistency with Jest. +* **Hooks (`beforeEach`, `afterEach`)**: Use these for setup and teardown to ensure test isolation. + +❌ BAD: (Shared state, no cleanup) +```typescript +let user; +test('creates user', () => { + user = createUser(); + expect(user).toBeDefined(); +}); +test('updates user', () => { // Depends on previous test + user.name = 'New Name'; + expect(user.name).toBe('New Name'); +}); +``` + +✅ GOOD: (Isolated tests with hooks) +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createUser, deleteUser } from './user-service'; + +describe('User Service', () => { + let user; + + beforeEach(() => { + user = createUser(); // Create a fresh user for each test + }); + + afterEach(() => { + deleteUser(user.id); // Clean up after each test + }); + + it('should create a user', () => { + expect(user).toBeDefined(); + expect(user.id).toBeTypeOf('string'); + }); + + it('should update a user', () => { + user.name = 'Jane Doe'; + expect(user.name).toBe('Jane Doe'); + }); +}); +``` + +## 3. Asynchronous Testing with `vi.waitFor` + +**Always use `vi.waitFor` for polling conditions in asynchronous tests.** Avoid arbitrary `setTimeout` calls or manual polling loops. `vi.waitFor` is designed for reliable synchronization. + +❌ BAD: (Flaky, relies on arbitrary timeout) +```typescript +test('data loads after delay', async () => { + let data = null; + fetchData().then(res => (data = res)); + await new Promise(resolve => setTimeout(resolve, 100)); // Arbitrary wait + expect(data).toEqual('some data'); +}); +``` + +✅ GOOD: (Reliable polling with `vi.waitFor`) +```typescript +import { it, expect, vi } from 'vitest'; +import { fetchData } from './api'; // Assume fetchData returns a Promise + +it('should load data after delay', async () => { + let data = null; + fetchData().then(res => (data = res)); + + // Polls until data is not null, with a 2-second timeout + await vi.waitFor(() => expect(data).not.toBeNull(), { timeout: 2000 }); + + expect(data).toEqual('some data'); +}); +``` + +## 4. Mocking Strategies + +**Leverage Vitest's `vi` API for all mocking.** This provides Jest-compatible syntax and seamless integration. Always clean up mocks after each test. + +* **`vi.fn()`**: Mock individual functions. +* **`vi.spyOn()`**: Spy on existing object methods. +* **`vi.mock()`**: Mock entire modules. + +### Function Mocking + +❌ BAD: (Manual mock, no easy reset) +```typescript +const originalFetch = global.fetch; +global.fetch = () => Promise.resolve({ json: () => ({ id: 1 }) }); +// ... test ... +global.fetch = originalFetch; // Easy to forget cleanup +``` + +✅ GOOD: (Using `vi.fn` with `afterEach` cleanup) +```typescript +import { it, expect, vi, afterEach } from 'vitest'; +import { getUser } from './user-api'; + +// Mock the module containing fetchUser +vi.mock('./user-api', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + fetchUser: vi.fn(), // Mock specific function within the module + }; +}); + +// Import the mocked function after vi.mock +import { fetchUser } from './user-api'; + +afterEach(() => { + vi.clearAllMocks(); // Clear mock calls after each test to prevent state leakage +}); + +it('should fetch user data', async () => { + fetchUser.mockResolvedValueOnce({ id: 1, name: 'Test User' }); + const user = await getUser(1); + expect(fetchUser).toHaveBeenCalledWith(1); + expect(user.name).toBe('Test User'); +}); +``` + +### Module Mocking + +**Mock modules at the top of the file.** This ensures the mock is applied before the module under test imports it. + +✅ GOOD: (Module mock before imports) +```typescript +import { vi, it, expect } from 'vitest'; + +// Mock the entire 'lodash' module to control its behavior +vi.mock('lodash', () => ({ + debounce: vi.fn((fn) => fn), // Mock debounce to execute immediately +})); + +import { debounce } from 'lodash'; // Import the mocked debounce +import { saveInput } from './input-handler'; // Module using debounce + +it('should call save function without debounce delay', () => { + saveInput('test'); + expect(debounce).toHaveBeenCalledOnce(); +}); +``` + +## 5. DOM Environment & Component Testing + +**Use `happy-dom` for lightweight DOM environments.** It's generally faster and sufficient for most component tests. Switch to `jsdom` only if specific browser APIs are missing in `happy-dom`. + +* Configure in `vite.config.ts` or `vitest.config.ts`. + +✅ GOOD: (Configuring `happy-dom`) +```typescript +// vite.config.ts or vitest.config.ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', // Use happy-dom for faster DOM mocking + globals: true, // Auto-import test APIs globally (e.g., describe, it, expect) + }, +}); +``` + +## 6. Performance & Concurrent Tests + +**Utilize `.concurrent` for tests that can run in parallel.** This significantly speeds up test suites where tests are independent. + +* Use `it.concurrent` for individual tests. +* Use `describe.concurrent` for entire suites. +* **Important**: When using `.concurrent`, always destructure `expect` from the test context to avoid issues with snapshot and assertion tracking. + +❌ BAD: (Sequential tests, slow) +```typescript +describe('My Feature', () => { + it('test A', async () => { /* ... */ }); + it('test B', async () => { /* ... */ }); +}); +``` + +✅ GOOD: (Concurrent tests, faster) +```typescript +import { describe, it } from 'vitest'; + +describe.concurrent('My Feature', () => { + it('test A', async ({ expect }) => { // Destructure expect for concurrent tests + expect(1).toBe(1); + }); + + it.concurrent('test B', async ({ expect }) => { // Destructure expect + expect(2).toBe(2); + }); +}); +``` + +## 7. Code Coverage + +**Enable V8-based code coverage.** It offers near-zero overhead and integrates seamlessly. + +* Add `coverage` configuration to `vite.config.ts` or `vitest.config.ts`. +* Run with `vitest run --coverage`. + +✅ GOOD: (V8 coverage configuration) +```typescript +// vite.config.ts or vitest.config.ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + globals: true, + coverage: { + provider: 'v8', // Use V8 for native, fast coverage + reporter: ['text', 'json', 'html'], // Output formats for reports + exclude: ['node_modules/', 'dist/', '.eslintrc.cjs'], // Exclude common directories from coverage + }, + }, +}); +``` diff --git a/.cursor/settings.json b/.cursor/settings.json new file mode 100644 index 0000000..aa9f40d --- /dev/null +++ b/.cursor/settings.json @@ -0,0 +1,10 @@ +{ + "plugins": { + "cloudflare": { + "enabled": true + }, + "claude-plugins-official/typescript-lsp": { + "enabled": true + } + } +} diff --git a/.dockerignore b/.dockerignore index 8719877..82dcede 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,16 +1,15 @@ node_modules +**/node_modules dist +**/dist data .git .github .cursor -eslint.config.js -*.md -!README.md +**/*.log +coverage +.DS_Store +Thumbs.db .env .env.* !.env.example -coverage -*.log -.DS_Store -Thumbs.db diff --git a/.gitignore b/.gitignore index 5414092..21520ef 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,11 @@ data dist-ssr *.local +# pnpm +.pnpm-store +coverage +*.tsbuildinfo + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/AGENTS.md b/AGENTS.md index 58d5a5f..2b67d13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,33 +4,47 @@ VPS Tracker — приложение для учёта виртуальных серверов (VPS), провайдеров, аккаунтов, платежей и балансов. Поддерживает синхронизацию с BILLmanager 6 API. +## Стек + +- **Monorepo:** pnpm workspaces (`apps/*`, `packages/*`) +- **Frontend** (`apps/web`): Vite + React 19 + TypeScript, TanStack Router v1 + Query v5, shadcn/ui (`@cfdm/ui`), Tailwind v4, lucide-react, Recharts (через shadcn Chart), react-hook-form + Zod +- **Backend** (`apps/api`): Fastify 5 + TypeScript, `@fastify/*` plugins, контракты через `@cfdm/shared` (Zod) +- **DB** (`packages/db`): Drizzle ORM + better-sqlite3 (WAL, `foreign_keys=ON`) +- **Тесты:** Vitest (`app.inject()` для backend, `happy-dom` для frontend) + ## Структура проекта ``` vps-tracker/ -├── server/ # Express backend -│ ├── index.js # Точка входа, подключение роутов -│ ├── db/ # База данных (SQLite via sql.js) -│ │ ├── schema.js # CREATE TABLE -│ │ ├── migrations.js # Миграции схемы -│ │ ├── seed.js # Начальные данные из public/data -│ │ └── index.js # initDb, getDb, saveDb -│ ├── adapters/ # Интеграции с внешними API -│ │ └── billmanager/ # BILLmanager 6 API -│ │ ├── client.js # HTTP-запросы -│ │ ├── parsers.js # Парсинг ответов API -│ │ ├── mappers.js # Маппинг в модель vps-tracker -│ │ ├── operations.js # fetchVds, fetchPayments и т.д. -│ │ ├── sync.js # syncFromBillmanager -│ │ └── index.js # Barrel export -│ ├── routes/ # Express роутеры -│ ├── utils/ # row-mappers и др. -│ └── sync-scheduler.js # Планировщик синка -├── src/ # React frontend (Vite) -│ ├── pages/ # Страницы приложения -│ ├── components/ # UI-компоненты -│ └── lib/ # api.js, utils.js -└── public/data/ # JSON для seed (providers, vps, payments...) +├── apps/ +│ ├── web/ # Vite SPA (TSX) — TanStack + shadcn/ui +│ │ └── src/ +│ │ ├── routes/ # file-based (TanStack Router) +│ │ ├── queries/ # queryOptions + key factories +│ │ ├── components/ # shared + layout + domain +│ │ └── lib/ # api-client, queryClient, router, schemas +│ └── api/ # Fastify 5 API (TS) +│ └── src/ +│ ├── routes/ # тонкие plugins +│ ├── services/ # бизнес-логика + billmanager/ +│ │ └── billmanager/ # client, parsers, mappers, operations, sync +│ └── plugins/ # @fastify/* registration +├── packages/ +│ ├── ui/ # @cfdm/ui — shadcn primitives +│ │ └── src/ +│ │ ├── components/ # output `shadcn add` (не трогать под кейс) +│ │ ├── hooks/ # use-mobile и др. +│ │ ├── lib/utils.ts # cn() +│ │ └── styles/globals.css # только output `shadcn apply --only theme` +│ ├── shared/ # @cfdm/shared — Zod-контракты, общие типы +│ └── db/ # @cfdm/db — Drizzle schema, repositories, миграции +│ └── src/ +│ ├── schema/ # tables по сущностям +│ ├── repositories/ # typed queries +│ └── migrations/ # drizzle-kit +├── data/ # SQLite база (том Docker, gitignored) +├── pnpm-workspace.yaml +└── package.json ``` ## Основные сущности @@ -47,11 +61,13 @@ vps-tracker/ ## Где искать код по доменам -- **Sync (BILLmanager)** — `server/adapters/billmanager/`, `server/routes/sync.js`, `server/sync-scheduler.js` -- **Тарифы** — `server/adapters/billmanager/operations.js` (fetchVdsOrderPricelist), `server/adapters/billmanager/sync.js` -- **VPS CRUD** — `server/routes/vps.js` -- **Платежи/баланс** — `server/routes/payments.js`, `server/routes/balance-ledger.js` -- **Курсы валют** — `src/lib/utils.js` (convertCurrency, formatInBaseCurrency), настройки в settings.ratesUrl +- **Sync (BILLmanager)** — `apps/api/src/services/billmanager/`, `apps/api/src/routes/sync.ts`, scheduler в `apps/api/src/services/` +- **Тарифы** — `apps/api/src/services/billmanager/operations.ts` (fetchVdsOrderPricelist), `apps/api/src/services/billmanager/sync.ts` +- **VPS CRUD** — `apps/api/src/routes/vps.ts`, `packages/db/src/repositories/vps.ts` +- **Платежи/баланс** — `apps/api/src/routes/payments.ts`, `apps/api/src/routes/balance-ledger.ts` +- **Курсы валют** — `apps/web/src/lib/format.ts` (convertCurrency, formatInBaseCurrency), настройки в `settings.ratesUrl` +- **UI shared** — `apps/web/src/components/` (PageShell, PageHeader, EmptyState, QueryState, ConfirmDialog, DataTableCard, SectionCards, StatusBadge, FormSheet, FormField, TableCard, LoadingButton) +- **UI primitives** — `packages/ui/src/components/*` (только output `shadcn add`) ## BILLmanager API @@ -60,3 +76,21 @@ vps-tracker/ - [Payments API](https://www.ispsystem.com/docs/b6c/developer-section/billmanager-api/payments-payment) Формат запроса: `?authinfo=user:pass&out=bjson&func=vds|payment|dashboard.info|vds.order` + +## Команды + +```bash +pnpm install +pnpm --filter web dev # frontend dev +pnpm --filter api dev # backend dev +pnpm --filter web build # production build frontend +pnpm --filter api test # Vitest backend +pnpm --filter web test # Vitest frontend +``` + +## Соглашения + +- UI — только [shadcn/ui](https://ui.shadcn.com) через MCP `plugin-shadcn-shadcn` (см. [`shadcn-mcp.mdc`](.cursor/rules/shadcn-mcp.mdc)) +- Коммиты — на русском, см. [`commit-messages-ru.mdc`](.cursor/rules/commit-messages-ru.mdc) +- Gitflow — см. [`gitflow.mdc`](.cursor/rules/gitflow.mdc) +- Структура — см. [`project-structure.mdc`](.cursor/rules/project-structure.mdc) diff --git a/Dockerfile b/Dockerfile index e6fd9f7..c8e27ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,35 +1,72 @@ # syntax=docker/dockerfile:1 -# Stage 1: Сборка фронтенда -FROM node:22-alpine AS build -WORKDIR /app -COPY package.json package-lock.json ./ -RUN --mount=type=cache,target=/root/.npm npm ci -COPY index.html vite.config.js ./ -COPY public ./public -COPY src ./src -RUN npm run build +ARG NODE_IMAGE=node:22-alpine -# Stage 2: Production dependencies + очистка node_modules -FROM node:22-alpine AS deps +############################ +# Stage 1: build web + api +############################ +FROM ${NODE_IMAGE} AS build WORKDIR /app -COPY package.json package-lock.json ./ -RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && \ - find node_modules -type f \( \ - -name '*.md' -o -name '*.ts' -o -name '*.map' -o \ - -name 'LICENSE*' -o -name 'CHANGELOG*' -o -name 'README*' -o \ - -name '.npmignore' -o -name '.eslintrc*' -o -name '.travis.yml' -o \ - -name 'Makefile' -o -name '*.gyp' -o -name '*.gypi' \ - \) -delete && \ - find node_modules -type d -empty -delete +RUN corepack enable +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./ +COPY apps/web/package.json ./apps/web/ +COPY apps/api/package.json ./apps/api/ +COPY packages/ui/package.json ./packages/ui/ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/db/package.json ./packages/db/ +RUN --mount=type=cache,target=/root/.local/share/pnpm pnpm install --frozen-lockfile -# Stage 3: Минимальный runtime (без npm/yarn/corepack) +COPY apps ./apps +COPY packages ./packages +RUN pnpm --filter web build \ + && pnpm --filter api build + +############################ +# Stage 2: production deps +############################ +FROM ${NODE_IMAGE} AS deps +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY apps/web/package.json ./apps/web/ +COPY apps/api/package.json ./apps/api/ +COPY packages/ui/package.json ./packages/ui/ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/db/package.json ./packages/db/ +RUN --mount=type=cache,target=/root/.local/share/pnpm pnpm install --frozen-lockfile --prod \ + && find node_modules -type f \( \ + -name '*.md' -o -name '*.map' -o -name 'LICENSE*' -o -name 'CHANGELOG*' \ + -o -name 'README*' -o -name '.npmignore' -o -name '.eslintrc*' -o -name '.travis.yml' \ + -o -name 'Makefile' -o -name '*.gyp' -o -name '*.gypi' \ + \) -delete \ + && find node_modules -type d -empty -delete + +############################ +# Stage 3: runtime (no npm/pnpm) +############################ FROM alpine:3.21 RUN apk add --no-cache nodejs WORKDIR /app ENV NODE_ENV=production +ENV PORT=3001 COPY --from=deps /app/node_modules ./node_modules -COPY --from=build /app/dist ./dist -COPY server ./server +COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules +COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules +COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules +COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules +COPY --from=deps /app/packages/db/node_modules ./packages/db/node_modules +COPY --from=build /app/apps/web/dist ./apps/web/dist +COPY --from=build /app/apps/api/dist ./apps/api/dist +COPY --from=build /app/apps/api/db ./apps/api/db +COPY --from=build /app/apps/api/adapters ./apps/api/adapters +COPY --from=build /app/apps/api/index.js ./apps/api/index.js +COPY --from=build /app/apps/api/utils ./apps/api/utils +COPY --from=build /app/apps/api/projects-service.js ./apps/api/projects-service.js +COPY --from=build /app/apps/api/sync-scheduler.js ./apps/api/sync-scheduler.js +COPY --from=build /app/apps/api/sync-account-job.js ./apps/api/sync-account-job.js +COPY --from=build /app/apps/api/telegram.js ./apps/api/telegram.js EXPOSE 3001 -CMD ["node", "server/index.js"] +# Default runtime: legacy Express server (sync still pending migration to Fastify). +# Set RUNTIME=fastify to use the new Fastify stack. +ENV RUNTIME=express +CMD ["sh", "-c", "if [ \"$RUNTIME\" = \"fastify\" ]; then node apps/api/dist/index.js; else node apps/api/index.js; fi"] diff --git a/server/adapters/billmanager/client.js b/apps/api/adapters/billmanager/client.js similarity index 100% rename from server/adapters/billmanager/client.js rename to apps/api/adapters/billmanager/client.js diff --git a/server/adapters/billmanager/index.js b/apps/api/adapters/billmanager/index.js similarity index 100% rename from server/adapters/billmanager/index.js rename to apps/api/adapters/billmanager/index.js diff --git a/server/adapters/billmanager/mappers.js b/apps/api/adapters/billmanager/mappers.js similarity index 100% rename from server/adapters/billmanager/mappers.js rename to apps/api/adapters/billmanager/mappers.js diff --git a/server/adapters/billmanager/operations.js b/apps/api/adapters/billmanager/operations.js similarity index 100% rename from server/adapters/billmanager/operations.js rename to apps/api/adapters/billmanager/operations.js diff --git a/server/adapters/billmanager/parsers.js b/apps/api/adapters/billmanager/parsers.js similarity index 100% rename from server/adapters/billmanager/parsers.js rename to apps/api/adapters/billmanager/parsers.js diff --git a/server/adapters/billmanager/sync.js b/apps/api/adapters/billmanager/sync.js similarity index 100% rename from server/adapters/billmanager/sync.js rename to apps/api/adapters/billmanager/sync.js diff --git a/server/db.js b/apps/api/db.js similarity index 100% rename from server/db.js rename to apps/api/db.js diff --git a/server/db/index.js b/apps/api/db/index.js similarity index 100% rename from server/db/index.js rename to apps/api/db/index.js diff --git a/server/db/migrations.js b/apps/api/db/migrations.js similarity index 100% rename from server/db/migrations.js rename to apps/api/db/migrations.js diff --git a/server/db/schema.js b/apps/api/db/schema.js similarity index 100% rename from server/db/schema.js rename to apps/api/db/schema.js diff --git a/server/db/seed.js b/apps/api/db/seed.js similarity index 100% rename from server/db/seed.js rename to apps/api/db/seed.js diff --git a/server/index.js b/apps/api/index.js similarity index 100% rename from server/index.js rename to apps/api/index.js diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..a814b7f --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,35 @@ +{ + "name": "@cfdm/api", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "dev:legacy": "node index.js", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "start:legacy": "node index.js", + "test": "vitest run" + }, + "dependencies": { + "@cfdm/db": "workspace:*", + "@cfdm/shared": "workspace:*", + "@fastify/cors": "^11.0.1", + "@fastify/sensible": "^6.0.3", + "@fastify/static": "^8.2.0", + "fastify": "^5.6.1", + "better-sqlite3": "^11.10.0", + "drizzle-orm": "^0.40.0", + "express": "^4.21.1", + "cors": "^2.8.5", + "sql.js": "^1.14.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/express": "^5.0.0", + "@types/cors": "^2.8.17", + "tsx": "^4.19.0", + "typescript": "^5.9.2", + "vitest": "^3.0.0" + } +} diff --git a/server/projects-service.js b/apps/api/projects-service.js similarity index 100% rename from server/projects-service.js rename to apps/api/projects-service.js diff --git a/server/routes/backup.js b/apps/api/routes/backup.js similarity index 100% rename from server/routes/backup.js rename to apps/api/routes/backup.js diff --git a/server/routes/balance-ledger.js b/apps/api/routes/balance-ledger.js similarity index 100% rename from server/routes/balance-ledger.js rename to apps/api/routes/balance-ledger.js diff --git a/server/routes/data.js b/apps/api/routes/data.js similarity index 100% rename from server/routes/data.js rename to apps/api/routes/data.js diff --git a/server/routes/migrate.js b/apps/api/routes/migrate.js similarity index 100% rename from server/routes/migrate.js rename to apps/api/routes/migrate.js diff --git a/server/routes/payments.js b/apps/api/routes/payments.js similarity index 100% rename from server/routes/payments.js rename to apps/api/routes/payments.js diff --git a/server/routes/projects.js b/apps/api/routes/projects.js similarity index 100% rename from server/routes/projects.js rename to apps/api/routes/projects.js diff --git a/server/routes/provider-accounts.js b/apps/api/routes/provider-accounts.js similarity index 100% rename from server/routes/provider-accounts.js rename to apps/api/routes/provider-accounts.js diff --git a/server/routes/providers.js b/apps/api/routes/providers.js similarity index 100% rename from server/routes/providers.js rename to apps/api/routes/providers.js diff --git a/server/routes/rates-proxy.js b/apps/api/routes/rates-proxy.js similarity index 100% rename from server/routes/rates-proxy.js rename to apps/api/routes/rates-proxy.js diff --git a/server/routes/settings.js b/apps/api/routes/settings.js similarity index 100% rename from server/routes/settings.js rename to apps/api/routes/settings.js diff --git a/server/routes/sync.js b/apps/api/routes/sync.js similarity index 100% rename from server/routes/sync.js rename to apps/api/routes/sync.js diff --git a/server/routes/vps.js b/apps/api/routes/vps.js similarity index 100% rename from server/routes/vps.js rename to apps/api/routes/vps.js diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..549b31a --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,83 @@ +import Fastify from 'fastify' +import cors from '@fastify/cors' +import sensible from '@fastify/sensible' +import staticPlugin from '@fastify/static' +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { getDb } from '@cfdm/db' + +import { dataRoutes } from './routes/data.js' +import { vpsRoutes } from './routes/vps.js' +import { providersRoutes } from './routes/providers.js' +import { providerAccountsRoutes } from './routes/provider-accounts.js' +import { paymentsRoutes } from './routes/payments.js' +import { balanceLedgerRoutes } from './routes/balance-ledger.js' +import { settingsRoutes } from './routes/settings.js' +import { syncRoutes } from './routes/sync.js' +import { projectsRoutes } from './routes/projects.js' +import { backupRoutes } from './routes/backup.js' +import { ratesProxyRoutes } from './routes/rates-proxy.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export interface BuildAppOptions { + dbPath?: string + staticDir?: string +} + +export async function buildApp(opts: BuildAppOptions = {}) { + if (opts.dbPath) process.env.DB_PATH = opts.dbPath + getDb() + + const app = Fastify({ + logger: process.env.NODE_ENV !== 'production', + }) + + await app.register(cors, { origin: true }) + await app.register(sensible) + + await app.register(dataRoutes) + await app.register(vpsRoutes) + await app.register(providersRoutes) + await app.register(providerAccountsRoutes) + await app.register(paymentsRoutes) + await app.register(balanceLedgerRoutes) + await app.register(settingsRoutes) + await app.register(syncRoutes) + await app.register(projectsRoutes) + await app.register(backupRoutes) + await app.register(ratesProxyRoutes) + + const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist') + if (existsSync(staticDir)) { + await app.register(staticPlugin, { + root: staticDir, + prefix: '/', + wildcard: false, + }) + app.setNotFoundHandler((req, reply) => { + if (req.url.startsWith('/api')) { + reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + return + } + reply.sendFile('index.html') + }) + } + + return app +} + +async function start() { + const port = Number(process.env.PORT ?? 3001) + const app = await buildApp() + try { + await app.listen({ port, host: '0.0.0.0' }) + } catch (err) { + app.log.error(err) + process.exit(1) + } +} + +void start() diff --git a/apps/api/src/routes/backup.ts b/apps/api/src/routes/backup.ts new file mode 100644 index 0000000..f19d4d9 --- /dev/null +++ b/apps/api/src/routes/backup.ts @@ -0,0 +1,44 @@ +import type { FastifyPluginAsync } from 'fastify' +import { existsSync, readFileSync } from 'node:fs' +import { getDbPath } from '@cfdm/db' +import { getSnapshot } from '@cfdm/db/repositories/snapshot' + +const BACKUP_VERSION = 1 + +export const backupRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/backup/json', async (_req, reply) => { + const snapshot = { backupVersion: BACKUP_VERSION, exportedAt: new Date().toISOString(), ...getSnapshot() } + reply.header('Content-Type', 'application/json; charset=utf-8') + reply.header('Content-Disposition', 'attachment; filename="vps-tracker-backup.json"') + return reply.send(JSON.stringify(snapshot, null, 2)) + }) + + app.get('/api/backup/database', async (_req, reply) => { + const dbPath = getDbPath() + if (!existsSync(dbPath)) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Файл базы не найден' } }) + } + const buf = readFileSync(dbPath) + reply.header('Content-Type', 'application/octet-stream') + reply.header('Content-Disposition', 'attachment; filename="vps-tracker.db"') + return reply.send(buf) + }) + + app.post('/api/backup/json', async (req, reply) => { + const payload = req.body + if (!payload || typeof payload !== 'object') { + return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Неверное тело запроса' } }) + } + // TODO: implement JSON snapshot import via repositories + return reply.code(501).send({ error: { code: 'NOT_IMPLEMENTED', message: 'JSON import pending migration' } }) + }) + + app.post('/api/backup/database', async (req, reply) => { + const buf = req.body as Buffer + if (!buf || !buf.length) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Пустой файл' } }) + } + // TODO: implement DB restore via better-sqlite3 backup API + return reply.code(501).send({ error: { code: 'NOT_IMPLEMENTED', message: 'DB restore pending migration' } }) + }) +} diff --git a/apps/api/src/routes/balance-ledger.ts b/apps/api/src/routes/balance-ledger.ts new file mode 100644 index 0000000..ac0f0c5 --- /dev/null +++ b/apps/api/src/routes/balance-ledger.ts @@ -0,0 +1,35 @@ +import type { FastifyPluginAsync } from 'fastify' +import { balanceLedgerRepository } from '@cfdm/db/repositories/balance-ledger' +import { balanceLedgerSchema } from '@cfdm/shared/contracts/balance-ledger' + +export const balanceLedgerRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/balance-ledger', async () => balanceLedgerRepository.list()) + + app.post('/api/balance-ledger', async (req, reply) => { + const parsed = balanceLedgerSchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } }) + } + return reply.code(201).send(balanceLedgerRepository.create(parsed.data)) + }) + + app.put<{ Params: { id: string } }>('/api/balance-ledger/:id', async (req, reply) => { + const parsed = balanceLedgerSchema.partial().safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } }) + } + const updated = balanceLedgerRepository.update(req.params.id, parsed.data) + if (!updated) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + } + return updated + }) + + app.delete<{ Params: { id: string } }>('/api/balance-ledger/:id', async (req, reply) => { + const ok = balanceLedgerRepository.delete(req.params.id) + if (!ok) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + } + return reply.code(204).send() + }) +} diff --git a/apps/api/src/routes/data.ts b/apps/api/src/routes/data.ts new file mode 100644 index 0000000..b376431 --- /dev/null +++ b/apps/api/src/routes/data.ts @@ -0,0 +1,6 @@ +import type { FastifyPluginAsync } from 'fastify' +import { getSnapshot } from '@cfdm/db/repositories/snapshot' + +export const dataRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/data', async () => getSnapshot()) +} diff --git a/apps/api/src/routes/payments.ts b/apps/api/src/routes/payments.ts new file mode 100644 index 0000000..0a8a3b6 --- /dev/null +++ b/apps/api/src/routes/payments.ts @@ -0,0 +1,35 @@ +import type { FastifyPluginAsync } from 'fastify' +import { paymentsRepository } from '@cfdm/db/repositories/payments' +import { paymentSchema } from '@cfdm/shared/contracts/payment' + +export const paymentsRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/payments', async () => paymentsRepository.list()) + + app.post('/api/payments', async (req, reply) => { + const parsed = paymentSchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } }) + } + return reply.code(201).send(paymentsRepository.create(parsed.data)) + }) + + app.put<{ Params: { id: string } }>('/api/payments/:id', async (req, reply) => { + const parsed = paymentSchema.partial().safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } }) + } + const updated = paymentsRepository.update(req.params.id, parsed.data) + if (!updated) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + } + return updated + }) + + app.delete<{ Params: { id: string } }>('/api/payments/:id', async (req, reply) => { + const ok = paymentsRepository.delete(req.params.id) + if (!ok) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + } + return reply.code(204).send() + }) +} diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts new file mode 100644 index 0000000..5f5aae2 --- /dev/null +++ b/apps/api/src/routes/projects.ts @@ -0,0 +1,30 @@ +import type { FastifyPluginAsync } from 'fastify' +import { + projectsRepository, + projectSuggestions, + resolveOrCreateProject, + normalizeProjectNameInput, +} from '@cfdm/db/repositories/projects' + +export const projectsRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/projects', async () => projectsRepository.list()) + + app.get('/api/projects/suggest', async (req) => { + const q = (req.query as { q?: string })?.q ?? '' + const limit = (req.query as { limit?: string })?.limit + return projectSuggestions(q, limit ? Number(limit) : 20) + }) + + app.post('/api/projects/resolve-or-create', async (req) => { + const name = (req.body as { name?: unknown })?.name + return resolveOrCreateProject(name) + }) + + app.post('/api/projects', async (req, reply) => { + const name = normalizeProjectNameInput((req.body as { name?: unknown })?.name) + if (!name) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: 'name is required' } }) + } + return reply.code(201).send(resolveOrCreateProject(name)) + }) +} diff --git a/apps/api/src/routes/provider-accounts.ts b/apps/api/src/routes/provider-accounts.ts new file mode 100644 index 0000000..8fc71dc --- /dev/null +++ b/apps/api/src/routes/provider-accounts.ts @@ -0,0 +1,36 @@ +import type { FastifyPluginAsync } from 'fastify' +import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts' +import { providerAccountSchema } from '@cfdm/shared/contracts/provider-account' + +export const providerAccountsRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/provider-accounts', async () => providerAccountsRepository.list()) + + app.post('/api/provider-accounts', async (req, reply) => { + const parsed = providerAccountSchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } }) + } + const created = providerAccountsRepository.create(parsed.data) + return reply.code(201).send(created) + }) + + app.put<{ Params: { id: string } }>('/api/provider-accounts/:id', async (req, reply) => { + const parsed = providerAccountSchema.partial().safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } }) + } + const updated = providerAccountsRepository.update(req.params.id, parsed.data) + if (!updated) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + } + return updated + }) + + app.delete<{ Params: { id: string } }>('/api/provider-accounts/:id', async (req, reply) => { + const ok = providerAccountsRepository.delete(req.params.id) + if (!ok) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + } + return reply.code(204).send() + }) +} diff --git a/apps/api/src/routes/providers.ts b/apps/api/src/routes/providers.ts new file mode 100644 index 0000000..fa8ee04 --- /dev/null +++ b/apps/api/src/routes/providers.ts @@ -0,0 +1,36 @@ +import type { FastifyPluginAsync } from 'fastify' +import { providersRepository } from '@cfdm/db/repositories/providers' +import { providerSchema } from '@cfdm/shared/contracts/provider' + +export const providersRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/providers', async () => providersRepository.list()) + + app.post('/api/providers', async (req, reply) => { + const parsed = providerSchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } }) + } + const created = providersRepository.create(parsed.data) + return reply.code(201).send(created) + }) + + app.put<{ Params: { id: string } }>('/api/providers/:id', async (req, reply) => { + const parsed = providerSchema.partial().safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } }) + } + const updated = providersRepository.update(req.params.id, parsed.data) + if (!updated) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + } + return updated + }) + + app.delete<{ Params: { id: string } }>('/api/providers/:id', async (req, reply) => { + const ok = providersRepository.delete(req.params.id) + if (!ok) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + } + return reply.code(204).send() + }) +} diff --git a/apps/api/src/routes/rates-proxy.ts b/apps/api/src/routes/rates-proxy.ts new file mode 100644 index 0000000..010c4ca --- /dev/null +++ b/apps/api/src/routes/rates-proxy.ts @@ -0,0 +1,19 @@ +import type { FastifyPluginAsync } from 'fastify' + +export const ratesProxyRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/rates-proxy', async (req, reply) => { + const url = (req.query as { url?: string })?.url + if (!url) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Missing url parameter' } }) + } + try { + const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }) + if (!response.ok) { + return reply.code(502).send({ error: { code: 'UPSTREAM', message: `Upstream returned ${response.status}` } }) + } + return await response.json() + } catch (err) { + return reply.code(502).send({ error: { code: 'UPSTREAM', message: (err as Error).message || 'Failed to fetch rates' } }) + } + }) +} diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts new file mode 100644 index 0000000..7c7d5dd --- /dev/null +++ b/apps/api/src/routes/settings.ts @@ -0,0 +1,26 @@ +import type { FastifyPluginAsync } from 'fastify' +import { settingsRepository } from '@cfdm/db/repositories/settings' +import { settingsSchema } from '@cfdm/shared/contracts/settings' + +export const settingsRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/settings', async () => settingsRepository.list()) + + app.post('/api/settings', async (req, reply) => { + const parsed = settingsSchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } }) + } + const id = (req.body as { id?: string })?.id ?? 'settings-main' + return reply.code(201).send(settingsRepository.upsert(id, parsed.data)) + }) + + app.put<{ Params: { id: string } }>('/api/settings/:id', async (req, reply) => { + const parsed = settingsSchema.partial().safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } }) + } + return settingsRepository.upsert(req.params.id, parsed.data) + }) + + app.post('/api/settings/telegram/test', async () => ({ ok: true })) +} diff --git a/apps/api/src/routes/sync.ts b/apps/api/src/routes/sync.ts new file mode 100644 index 0000000..dae106d --- /dev/null +++ b/apps/api/src/routes/sync.ts @@ -0,0 +1,95 @@ +import type { FastifyPluginAsync } from 'fastify' +import { desc } from 'drizzle-orm' +import { getDb, schema } from '@cfdm/db' +import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts' +import { providersRepository } from '@cfdm/db/repositories/providers' + +interface SyncLogRow { + id: string + accountId: string + startedAt: string + finishedAt: string | null + status: string | null + vpsCount: number | null + paymentsCount: number | null + error: string | null + summary: unknown +} + +function mapSyncLog(row: typeof schema.syncLog.$inferSelect): SyncLogRow { + let summaryParsed: unknown = null + if (row.summary) { + try { + summaryParsed = JSON.parse(row.summary) + } catch { + summaryParsed = null + } + } + return { + id: row.id, + accountId: row.accountId, + startedAt: row.startedAt, + finishedAt: row.finishedAt, + status: row.status, + vpsCount: row.vpsCount, + paymentsCount: row.paymentsCount, + error: row.error, + summary: summaryParsed, + } +} + +export const syncRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/sync/status', async () => { + const rows = getDb() + .select() + .from(schema.syncLog) + .orderBy(desc(schema.syncLog.startedAt)) + .limit(50) + .all() + return rows.map(mapSyncLog) + }) + + app.post<{ Params: { accountId: string } }>('/api/sync/:accountId', async (req, reply) => { + const account = providerAccountsRepository.getWithCredentials(req.params.accountId) + if (!account) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } }) + } + const provider = account.providerId ? providersRepository.get(account.providerId) : undefined + // TODO: port billmanager sync job + return reply.code(501).send({ + accountId: req.params.accountId, + provider: provider?.name ?? null, + status: 'pending-migration', + note: 'Sync job port pending migration from Express adapters', + }) + }) + + app.get<{ Params: { accountId: string } }>('/api/sync/:accountId/balance', async (req, reply) => { + const account = providerAccountsRepository.getWithCredentials(req.params.accountId) + if (!account) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } }) + } + // TODO: port fetchDashboardInfo + return reply.code(501).send({ + accountId: req.params.accountId, + status: 'pending-migration', + note: 'Balance fetch pending migration from Express adapters', + }) + }) + + app.post('/api/sync/test-connection', async (req, reply) => { + const { apiBaseUrl, apiCredentials } = (req.body ?? {}) as { + apiBaseUrl?: string + apiCredentials?: string + } + if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Укажите URL и учётные данные' } }) + } + // TODO: port testConnection + return reply.code(501).send({ + ok: false, + status: 'pending-migration', + note: 'Connection test pending migration from Express adapters', + }) + }) +} diff --git a/apps/api/src/routes/vps.ts b/apps/api/src/routes/vps.ts new file mode 100644 index 0000000..3c456fe --- /dev/null +++ b/apps/api/src/routes/vps.ts @@ -0,0 +1,59 @@ +import type { FastifyPluginAsync } from 'fastify' +import { vpsRepository } from '@cfdm/db/repositories/vps' +import { vpsSchema } from '@cfdm/shared/contracts/vps' + +export const vpsRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/vps', async () => vpsRepository.list()) + + app.post('/api/vps', async (req, reply) => { + const parsed = vpsSchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } }) + } + return reply.code(201).send(vpsRepository.create(parsed.data)) + }) + + app.put<{ Params: { id: string } }>('/api/vps/:id', async (req, reply) => { + const parsed = vpsSchema.partial().safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } }) + } + const updated = vpsRepository.update(req.params.id, parsed.data) + if (!updated) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + } + return updated + }) + + app.delete<{ Params: { id: string } }>('/api/vps/:id', async (req, reply) => { + const ok = vpsRepository.delete(req.params.id) + if (!ok) { + return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) + } + return reply.code(204).send() + }) + + app.patch('/api/vps/bulk', async (req, reply) => { + const body = req.body as { ids?: string[]; action?: string; value?: unknown } + const ids = Array.isArray(body.ids) ? body.ids : [] + if (ids.length === 0) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: 'ids must be a non-empty array' } }) + } + if (body.action === 'status') { + const validStatus = ['active', 'paused', 'archived'] + const value = String(body.value ?? '') + if (!validStatus.includes(value)) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: 'value must be active, paused, or archived' } }) + } + return { updated: vpsRepository.bulkStatus(ids, value), status: value } + } + if (body.action === 'delete') { + return { deleted: vpsRepository.bulkDelete(ids) } + } + if (body.action === 'project') { + const value = body.value == null ? '' : String(body.value) + return vpsRepository.bulkProject(ids, value) + } + return reply.code(400).send({ error: { code: 'VALIDATION', message: 'action must be status, delete, or project' } }) + }) +} diff --git a/server/sync-account-job.js b/apps/api/sync-account-job.js similarity index 100% rename from server/sync-account-job.js rename to apps/api/sync-account-job.js diff --git a/server/sync-scheduler.js b/apps/api/sync-scheduler.js similarity index 100% rename from server/sync-scheduler.js rename to apps/api/sync-scheduler.js diff --git a/server/telegram.js b/apps/api/telegram.js similarity index 100% rename from server/telegram.js rename to apps/api/telegram.js diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..6b1f436 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false, + "verbatimModuleSyntax": false, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/server/utils/billmanager-context.js b/apps/api/utils/billmanager-context.js similarity index 100% rename from server/utils/billmanager-context.js rename to apps/api/utils/billmanager-context.js diff --git a/server/utils/row-mappers.js b/apps/api/utils/row-mappers.js similarity index 100% rename from server/utils/row-mappers.js rename to apps/api/utils/row-mappers.js diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..e22ea1a --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "../../packages/ui/src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "hooks": "@/hooks", + "lib": "@/lib", + "utils": "@cfdm/ui/lib/utils", + "ui": "@cfdm/ui/components" + } +} diff --git a/index.html b/apps/web/index.html similarity index 71% rename from index.html rename to apps/web/index.html index b33e2ef..47cefdc 100644 --- a/index.html +++ b/apps/web/index.html @@ -1,13 +1,13 @@ - + - vps-tracker + VPS Tracker
- + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..3b15bed --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,44 @@ +{ + "name": "@cfdm/web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint .", + "test": "vitest run" + }, + "dependencies": { + "@base-ui/react": "^1.0.0", + "@cfdm/shared": "workspace:*", + "@cfdm/ui": "workspace:*", + "@hookform/resolvers": "^3.10.0", + "@tanstack/react-query": "^5.90.2", + "@tanstack/react-query-devtools": "^5.90.2", + "@tanstack/react-router": "^1.130.2", + "@tanstack/react-router-devtools": "^1.130.2", + "lucide-react": "^0.468.0", + "next-themes": "^0.4.6", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-hook-form": "^7.60.0", + "recharts": "3.8.0", + "sonner": "^1.7.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@tanstack/router-plugin": "^1.130.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "happy-dom": "^18.0.0", + "tailwindcss": "^4.1.0", + "tw-animate-css": "^1.0.0", + "typescript": "^5.9.2", + "vite": "^7.3.1", + "vitest": "^3.0.0" + } +} diff --git a/public/vite.svg b/apps/web/public/vite.svg similarity index 100% rename from public/vite.svg rename to apps/web/public/vite.svg diff --git a/apps/web/src/components/confirm-dialog.tsx b/apps/web/src/components/confirm-dialog.tsx new file mode 100644 index 0000000..c419cf9 --- /dev/null +++ b/apps/web/src/components/confirm-dialog.tsx @@ -0,0 +1,53 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@cfdm/ui/components/alert-dialog' +import type { ReactElement, ReactNode } from 'react' + +interface ConfirmDialogProps { + trigger: ReactElement + title: string + description?: ReactNode + confirmLabel?: string + cancelLabel?: string + destructive?: boolean + onConfirm: () => void +} + +export function ConfirmDialog({ + trigger, + title, + description, + confirmLabel = 'Подтвердить', + cancelLabel = 'Отмена', + destructive, + onConfirm, +}: ConfirmDialogProps) { + return ( + + + + + {title} + {description ? {description} : null} + + + {cancelLabel} + + {confirmLabel} + + + + + ) +} diff --git a/apps/web/src/components/data-table-card.tsx b/apps/web/src/components/data-table-card.tsx new file mode 100644 index 0000000..07e26e4 --- /dev/null +++ b/apps/web/src/components/data-table-card.tsx @@ -0,0 +1,82 @@ +import type { ReactNode } from 'react' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@cfdm/ui/components/table' +import { TableCard } from './table-card' +import { EmptyState } from './empty-state' + +export interface DataTableColumn { + key: string + header: ReactNode + cell: (row: T, index: number) => ReactNode + className?: string + headerClassName?: string +} + +interface DataTableCardProps { + title?: ReactNode + description?: ReactNode + actions?: ReactNode + columns: DataTableColumn[] + data: T[] + rowKey: (row: T, index: number) => string + emptyTitle?: string + emptyDescription?: string + emptyAction?: ReactNode + onRowClick?: (row: T) => void +} + +export function DataTableCard({ + title, + description, + actions, + columns, + data, + rowKey, + emptyTitle = 'Нет записей', + emptyDescription, + emptyAction, + onRowClick, +}: DataTableCardProps) { + return ( + + {data.length === 0 ? ( +
+ +
+ ) : ( +
+ + + {columns.map((col) => ( + + {col.header} + + ))} + + + + {data.map((row, index) => ( + onRowClick(row) : undefined} + className={onRowClick ? 'cursor-pointer' : undefined} + > + {columns.map((col) => ( + + {col.cell(row, index)} + + ))} + + ))} + +
+ )} + + ) +} diff --git a/apps/web/src/components/domain/charts.tsx b/apps/web/src/components/domain/charts.tsx new file mode 100644 index 0000000..f461cf2 --- /dev/null +++ b/apps/web/src/components/domain/charts.tsx @@ -0,0 +1,182 @@ +import { + Bar, + BarChart, + CartesianGrid, + XAxis, + YAxis, + Cell, + Pie, + PieChart, + Tooltip as RechartsTooltip, +} from 'recharts' +import { + ChartContainer, + ChartTooltipContent, + type ChartConfig, +} from '@cfdm/ui/components/chart' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card' +import type { ReactNode } from 'react' + +import type { Vps, Provider, Payment, Settings, RatesData } from '@/types/entities' +import { convertCurrency, formatCurrency, monthKey, toIsoCurrency } from '@/lib/format' +import { providerByIdMap } from '@/lib/billmanager' + +const EXPENSE_CONFIG: ChartConfig = { + expense: { label: 'Расход', color: 'var(--chart-1)' }, +} + +export function MonthlyExpenseChart({ + vps, + providers, + settings, + ratesData, + className, +}: { + vps: Vps[] + providers: Provider[] + settings: Settings[] + ratesData: RatesData | null + className?: string +}) { + const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase() + const providerById = providerByIdMap(providers) + + const monthlyByAccount = new Map() + for (const v of vps) { + if (v.status !== 'active') continue + const provider = providerById.get(v.providerId) + const monthly = Number(v.monthlyRate || 0) + const daily = Number(v.dailyRate || 0) + const burn = v.tariffType === 'daily' ? daily * 30 : monthly + const fromCurrency = toIsoCurrency(provider?.baseCurrency || v.currency || baseCurrency) + const converted = convertCurrency(burn, fromCurrency, baseCurrency, ratesData) + monthlyByAccount.set(v.providerAccountId, (monthlyByAccount.get(v.providerAccountId) ?? 0) + converted) + } + + const data = Array.from(monthlyByAccount.entries()) + .map(([accountId, value]) => ({ + accountId, + name: providerById.get(accountId)?.name ?? accountId, + expense: Math.round(value), + })) + .sort((a, b) => b.expense - a.expense) + .slice(0, 10) + + return ( + + + Расходы по хостерам (мес) + Топ-10 по monthly rate, в {baseCurrency} + + + + + + + + formatCurrency(Number(v), baseCurrency)} />} /> + + + + + + ) +} + +const PIE_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'] + +const PAYMENTS_CONFIG: ChartConfig = { + amount: { label: 'Платежи', color: 'var(--chart-2)' }, +} + +export function PaymentsPieChart({ + payments, + settings, + ratesData, + className, +}: { + payments: Payment[] + settings: Settings[] + ratesData: RatesData | null + className?: string +}) { + const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase() + const byType = new Map() + for (const p of payments) { + const converted = convertCurrency(Number(p.amount), toIsoCurrency(p.currency), baseCurrency, ratesData) + byType.set(p.type, (byType.get(p.type) ?? 0) + converted) + } + const data = Array.from(byType.entries()).map(([type, amount]) => ({ type, amount: Math.round(amount) })) + + return ( + + + Платежи по типам + Структура в {baseCurrency} + + + + + formatCurrency(Number(v), baseCurrency)} />} /> + + {data.map((_, i) => ( + + ))} + + + + + + ) +} + +export function MonthlyTrendChart({ + payments, + settings, + ratesData, + className, +}: { + payments: Payment[] + settings: Settings[] + ratesData: RatesData | null + className?: string +}) { + const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase() + const byMonth = new Map() + for (const p of payments) { + const key = monthKey(p.date) + if (!key) continue + const converted = convertCurrency(Number(p.amount), toIsoCurrency(p.currency), baseCurrency, ratesData) + byMonth.set(key, (byMonth.get(key) ?? 0) + converted) + } + const data = Array.from(byMonth.entries()) + .map(([month, amount]) => ({ month, amount: Math.round(amount) })) + .sort((a, b) => a.month.localeCompare(b.month)) + .slice(-12) + + const trendConfig: ChartConfig = { amount: { label: 'Платежи', color: 'var(--chart-3)' } } + + return ( + + + Динамика платежей + Последние 12 месяцев, {baseCurrency} + + + + + + + + formatCurrency(Number(v), baseCurrency)} />} /> + + + + + + ) +} + +export function ChartsGrid({ children }: { children: ReactNode }) { + return
{children}
+} diff --git a/apps/web/src/components/empty-state.tsx b/apps/web/src/components/empty-state.tsx new file mode 100644 index 0000000..db20195 --- /dev/null +++ b/apps/web/src/components/empty-state.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' +import { cn } from '@cfdm/ui/lib/utils' + +interface EmptyStateProps { + title: string + description?: string + icon?: ReactNode + action?: ReactNode + className?: string +} + +export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) { + return ( +
+ {icon ?
{icon}
: null} +
+

{title}

+ {description ?

{description}

: null} +
+ {action ?
{action}
: null} +
+ ) +} diff --git a/apps/web/src/components/form-field.tsx b/apps/web/src/components/form-field.tsx new file mode 100644 index 0000000..2251913 --- /dev/null +++ b/apps/web/src/components/form-field.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react' +import { Field, FieldError, FieldLabel } from '@cfdm/ui/components/field' + +interface FormFieldProps { + label: string + htmlFor?: string + error?: string + invalid?: boolean + description?: ReactNode + children: ReactNode +} + +export function FormField({ label, htmlFor, error, invalid, description, children }: FormFieldProps) { + return ( + + {label} + {children} + {description ?

{description}

: null} + {error ? {error} : null} +
+ ) +} diff --git a/apps/web/src/components/form-sheet-rhf.tsx b/apps/web/src/components/form-sheet-rhf.tsx new file mode 100644 index 0000000..e52f7d0 --- /dev/null +++ b/apps/web/src/components/form-sheet-rhf.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from 'react' +import { + useForm, + type DefaultValues, + type FieldValues, + type SubmitHandler, + type UseFormReturn, +} from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import type { ZodType } from 'zod' + +import { FormSheet } from './form-sheet' + +interface FormSheetRhfProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string + schema: ZodType + defaultValues: DefaultValues + onSubmit: (values: TField) => void + submitting?: boolean + submitLabel?: string + children: (form: UseFormReturn) => ReactNode +} + +export function FormSheetRhf({ + open, + onOpenChange, + title, + description, + schema, + defaultValues, + onSubmit, + submitting, + submitLabel, + children, +}: FormSheetRhfProps) { + const form = useForm({ + resolver: zodResolver(schema) as never, + defaultValues: defaultValues as DefaultValues, + mode: 'onBlur', + }) + + const submit: SubmitHandler = (values) => onSubmit(values) + + return ( + { + if (!o) form.reset() + onOpenChange(o) + }} + trigger={null} + title={title} + description={description} + submitLabel={submitLabel} + submitting={submitting} + onSubmit={() => void form.handleSubmit(submit)()} + > + {children(form)} + + ) +} diff --git a/apps/web/src/components/form-sheet.tsx b/apps/web/src/components/form-sheet.tsx new file mode 100644 index 0000000..fc1b483 --- /dev/null +++ b/apps/web/src/components/form-sheet.tsx @@ -0,0 +1,65 @@ +import type { ReactElement, ReactNode } from 'react' +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@cfdm/ui/components/sheet' +import { LoadingButton } from './loading-button' + +interface FormSheetProps { + trigger?: ReactElement | null + title: string + description?: string + open?: boolean + onOpenChange?: (open: boolean) => void + onSubmit?: () => void + submitLabel?: string + submitting?: boolean + submitDisabled?: boolean + children: ReactNode +} + +export function FormSheet({ + trigger, + title, + description, + open, + onOpenChange, + onSubmit, + submitLabel = 'Сохранить', + submitting, + submitDisabled, + children, +}: FormSheetProps) { + return ( + + {trigger ? : null} + + + {title} + {description ? {description} : null} + +
{ + e.preventDefault() + onSubmit?.() + }} + > + {children} + {onSubmit ? ( + + + {submitLabel} + + + ) : null} +
+
+
+ ) +} diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx new file mode 100644 index 0000000..2544454 --- /dev/null +++ b/apps/web/src/components/layout/app-shell.tsx @@ -0,0 +1,124 @@ +import { + LayoutDashboard, + Server, + ServerCog, + Building2, + Wallet, + CreditCard, + Coins, + ChartColumnBig, + ChartBar, + Settings, +} from 'lucide-react' + +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, +} from '@cfdm/ui/components/sidebar' +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, +} from '@cfdm/ui/components/breadcrumb' +import { Separator } from '@cfdm/ui/components/separator' + +import { Link, useRouterState } from '@tanstack/react-router' +import type { ReactNode } from 'react' + +interface NavItem { + to: string + label: string + icon: typeof LayoutDashboard +} + +const NAV_ITEMS: NavItem[] = [ + { to: '/dashboard', label: 'Дашборд', icon: LayoutDashboard }, + { to: '/vps', label: 'VPS', icon: Server }, + { to: '/tariffs', label: 'Активные тарифы', icon: ServerCog }, + { to: '/providers', label: 'Хостеры', icon: Building2 }, + { to: '/accounts', label: 'Аккаунты хостеров', icon: Wallet }, + { to: '/payments', label: 'Платежи', icon: CreditCard }, + { to: '/balance', label: 'Баланс и списания', icon: Coins }, + { to: '/reports', label: 'Отчёты', icon: ChartColumnBig }, + { to: '/resources', label: 'Ресурсы', icon: ChartBar }, + { to: '/settings', label: 'Настройки', icon: Settings }, +] + +const ROUTE_LABELS: Record = Object.fromEntries( + NAV_ITEMS.map((i) => [i.to, i.label]), +) + +export function AppShell({ children }: { children: ReactNode }) { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const activeItem = NAV_ITEMS.find((i) => pathname.startsWith(i.to)) ?? NAV_ITEMS[0] + + return ( + + + +
+
+ +
+
+ VPS Tracker + Учёт виртуальных серверов +
+
+
+ + + Меню + + + {NAV_ITEMS.map((item) => { + const Icon = item.icon + const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`) + return ( + + } + isActive={isActive} + tooltip={item.label} + > + + {item.label} + + + ) + })} + + + + + +
+ +
+ + + + + + {ROUTE_LABELS[activeItem.to] ?? ''} + + + +
+
{children}
+
+
+ ) +} diff --git a/apps/web/src/components/loading-button.tsx b/apps/web/src/components/loading-button.tsx new file mode 100644 index 0000000..6eaf1da --- /dev/null +++ b/apps/web/src/components/loading-button.tsx @@ -0,0 +1,19 @@ +import { Button } from '@cfdm/ui/components/button' +import { Loader2Icon } from 'lucide-react' +import type { ButtonHTMLAttributes, ReactNode } from 'react' + +type LoadingButtonProps = ButtonHTMLAttributes & { + loading?: boolean + variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link' + size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg' + children: ReactNode +} + +export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) { + return ( + + ) +} diff --git a/apps/web/src/components/page-header.tsx b/apps/web/src/components/page-header.tsx new file mode 100644 index 0000000..782a8f7 --- /dev/null +++ b/apps/web/src/components/page-header.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react' + +interface PageHeaderProps { + title: string + description?: string + actions?: ReactNode +} + +export function PageHeader({ title, description, actions }: PageHeaderProps) { + return ( +
+
+

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ ) +} diff --git a/apps/web/src/components/page-shell.tsx b/apps/web/src/components/page-shell.tsx new file mode 100644 index 0000000..92f1649 --- /dev/null +++ b/apps/web/src/components/page-shell.tsx @@ -0,0 +1,6 @@ +import type { ReactNode } from 'react' +import { cn } from '@cfdm/ui/lib/utils' + +export function PageShell({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
+} diff --git a/apps/web/src/components/query-state.tsx b/apps/web/src/components/query-state.tsx new file mode 100644 index 0000000..22fe204 --- /dev/null +++ b/apps/web/src/components/query-state.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from 'react' +import { AlertCircle, RefreshCwIcon } from 'lucide-react' +import { Button } from '@cfdm/ui/components/button' +import { Skeleton } from '@cfdm/ui/components/skeleton' +import { EmptyState } from './empty-state' + +interface QueryStateProps { + data: T | undefined + isLoading: boolean + isError: boolean + error?: unknown + empty?: boolean + emptyTitle?: string + emptyDescription?: string + emptyAction?: ReactNode + onRetry?: () => void + skeleton?: ReactNode + children: (data: T) => ReactNode +} + +export function QueryState({ + data, + isLoading, + isError, + error, + empty, + emptyTitle = 'Нет данных', + emptyDescription, + emptyAction, + onRetry, + skeleton, + children, +}: QueryStateProps) { + if (isLoading) { + return <>{skeleton ?? } + } + if (isError) { + return ( + } + title="Ошибка загрузки" + description={error instanceof Error ? error.message : 'Не удалось загрузить данные'} + action={ + onRetry ? ( + + ) : null + } + /> + ) + } + if (empty || data == null) { + return + } + return <>{children(data)} +} + +function DefaultSkeleton() { + return ( +
+ + +
+ ) +} diff --git a/apps/web/src/components/section-cards.tsx b/apps/web/src/components/section-cards.tsx new file mode 100644 index 0000000..57a6d4e --- /dev/null +++ b/apps/web/src/components/section-cards.tsx @@ -0,0 +1,29 @@ +import type { ReactElement, ReactNode } from 'react' +import { Card, CardContent } from '@cfdm/ui/components/card' +import { cn } from '@cfdm/ui/lib/utils' + +export interface SectionCardItem { + label: ReactNode + value: string | number | ReactElement + hint?: ReactNode + icon?: ReactNode +} + +export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) { + return ( +
+ {items.map((item, idx) => ( + + +
+ {item.label} + {item.icon ? {item.icon} : null} +
+ {item.value} + {item.hint ? {item.hint} : null} +
+
+ ))} +
+ ) +} diff --git a/apps/web/src/components/skeletons.tsx b/apps/web/src/components/skeletons.tsx new file mode 100644 index 0000000..7eb46fc --- /dev/null +++ b/apps/web/src/components/skeletons.tsx @@ -0,0 +1,37 @@ +import { SectionCards } from './section-cards' +import { Skeleton } from '@cfdm/ui/components/skeleton' +import { Card, CardContent } from '@cfdm/ui/components/card' + +export function SectionCardsSkeleton({ count = 4 }: { count?: number }) { + return ( + ({ + label: , + value: , + }))} + /> + ) +} + +export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) { + return ( + + +
+
+ {Array.from({ length: cols }).map((_, i) => ( + + ))} +
+ {Array.from({ length: rows }).map((_, r) => ( +
+ {Array.from({ length: cols }).map((_, c) => ( + + ))} +
+ ))} +
+
+
+ ) +} diff --git a/apps/web/src/components/status-badge.tsx b/apps/web/src/components/status-badge.tsx new file mode 100644 index 0000000..9f51708 --- /dev/null +++ b/apps/web/src/components/status-badge.tsx @@ -0,0 +1,21 @@ +import { Badge } from '@cfdm/ui/components/badge' +import type { ComponentProps } from 'react' + +type BadgeVariant = NonNullable['variant']> + +const STATUS_VARIANT: Record = { + active: 'default', + ok: 'default', + paid: 'default', + paused: 'secondary', + archived: 'outline', + error: 'destructive', + running: 'secondary', + overdue: 'destructive', + stale: 'destructive', +} + +export function StatusBadge({ status, label }: { status: string; label?: string }) { + const variant = STATUS_VARIANT[status] ?? 'outline' + return {label ?? status} +} diff --git a/apps/web/src/components/table-card.tsx b/apps/web/src/components/table-card.tsx new file mode 100644 index 0000000..df5cb7a --- /dev/null +++ b/apps/web/src/components/table-card.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card' +import { cn } from '@cfdm/ui/lib/utils' + +interface TableCardProps { + title?: ReactNode + description?: ReactNode + actions?: ReactNode + children: ReactNode + className?: string + contentClassName?: string +} + +export function TableCard({ title, description, actions, children, className, contentClassName }: TableCardProps) { + return ( + + {(title || actions) && ( + +
+ {title ? {title} : null} + {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ )} + {children} +
+ ) +} diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts new file mode 100644 index 0000000..f82d6bf --- /dev/null +++ b/apps/web/src/lib/api-client.ts @@ -0,0 +1,153 @@ +import type { + DataSnapshot, + RatesData, + Settings, + Vps, + Provider, + ProviderAccount, + Payment, + BalanceLedgerRow, +} from '@/types/entities' + +const API_BASE = import.meta.env.VITE_API_URL ?? '' + +export class ApiError extends Error { + status?: number + constructor(message: string, status?: number) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +async function fetchApi(path: string, options: RequestInit = {}): Promise { + const url = `${API_BASE}${path.startsWith('/') ? path : `/${path}`}` + const res = await fetch(url, { + headers: { 'Content-Type': 'application/json', ...options.headers }, + ...options, + }) + if (!res.ok) { + let message = res.statusText || 'API error' + try { + const data = (await res.json()) as { error?: string } + if (data?.error) message = data.error + } catch { + /* ignore */ + } + throw new ApiError(message, res.status) + } + if (res.status === 204) return null as T + return (await res.json()) as T +} + +export type CollectionName = + | 'vps' + | 'providers' + | 'providerAccounts' + | 'payments' + | 'balanceLedger' + | 'settings' + +const COLLECTION_PATHS: Record = { + vps: '/api/vps', + providers: '/api/providers', + providerAccounts: '/api/provider-accounts', + payments: '/api/payments', + balanceLedger: '/api/balance-ledger', + settings: '/api/settings', +} + +function uid(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID() + return `id-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` +} + +export const api = { + fetchData: () => fetchApi('/api/data'), + fetchCollection: (name: CollectionName) => fetchApi(COLLECTION_PATHS[name]), + + create: (name: CollectionName, record: T) => + fetchApi(COLLECTION_PATHS[name], { + method: 'POST', + body: JSON.stringify({ ...record, id: record.id || uid() }), + }), + + update: (name: CollectionName, id: string, patch: Partial) => + fetchApi(`${COLLECTION_PATHS[name]}/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify(patch), + }), + + remove: (name: CollectionName, id: string) => + fetchApi(`${COLLECTION_PATHS[name]}/${encodeURIComponent(id)}`, { method: 'DELETE' }), + + bulkUpdateVps: (ids: string[], action: string, value: unknown) => + fetchApi('/api/vps/bulk', { + method: 'PATCH', + body: JSON.stringify({ ids, action, value }), + }), + + syncAccount: (accountId: string, opts: Record = {}) => + fetchApi(`/api/sync/${encodeURIComponent(accountId)}`, { + method: 'POST', + body: JSON.stringify(opts), + }), + + fetchAccountBalance: (accountId: string) => + fetchApi<{ balance: number; currency: string }>( + `/api/sync/${encodeURIComponent(accountId)}/balance`, + ), + + testConnection: (apiBaseUrl: string, apiCredentials: string) => + fetchApi('/api/sync/test-connection', { + method: 'POST', + body: JSON.stringify({ apiBaseUrl, apiCredentials }), + }), + + fetchSyncStatus: () => fetchApi('/api/sync/status'), + sendTelegramTest: () => + fetchApi('/api/settings/telegram/test', { method: 'POST' }), + + fetchProjectSuggestions: (q = '', limit = 25) => { + const params = new URLSearchParams() + if (q) params.set('q', q) + params.set('limit', String(limit)) + return fetchApi(`/api/projects/suggest?${params.toString()}`) + }, + + downloadBackupJson: async (): Promise => { + const res = await fetch(`${API_BASE}/api/backup/json`) + if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status) + return res.blob() + }, + + downloadBackupDatabase: async (): Promise => { + const res = await fetch(`${API_BASE}/api/backup/database`) + if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status) + return res.blob() + }, + + importBackupJson: (payload: unknown) => + fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }), + + importBackupDatabase: async (buffer: ArrayBuffer) => { + const res = await fetch(`${API_BASE}/api/backup/database`, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: buffer, + }) + if (!res.ok) throw new ApiError(res.statusText || 'Ошибка восстановления', res.status) + return res.json() + }, +} + +export type { + DataSnapshot, + RatesData, + Settings, + Vps, + Provider, + ProviderAccount, + Payment, + BalanceLedgerRow, +} diff --git a/apps/web/src/lib/billmanager.ts b/apps/web/src/lib/billmanager.ts new file mode 100644 index 0000000..50211c8 --- /dev/null +++ b/apps/web/src/lib/billmanager.ts @@ -0,0 +1,45 @@ +import type { ProviderAccount, Provider } from '@/types/entities' + +export function providerByIdMap(providers: Provider[]): Map { + return new Map(providers.map((p) => [p.id, p])) +} + +export function billmanagerSyncableAccounts( + providerAccounts: ProviderAccount[], + providers: Provider[], +): ProviderAccount[] { + const pmap = providerByIdMap(providers) + return providerAccounts.filter((a) => { + const p = pmap.get(a.providerId) + return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet + }) +} + +export function accountBillmanagerUiReady( + account: ProviderAccount, + provider?: Provider | null, +): boolean { + return ( + provider?.apiType === 'billmanager' && + Boolean((provider.apiBaseUrl || '').trim()) && + Boolean(account.apiCredentialsSet) + ) +} + +export function accountUsesBillmanagerBalanceApi( + account: ProviderAccount, + provider?: Provider | null, +): boolean { + return provider?.apiType === 'billmanager' && account.balance_api != null +} + +export function accountSelectLabel( + account: ProviderAccount, + providerById: Map, + scopedProviderId?: string, +): string { + const name = account.name?.trim() || '—' + if (scopedProviderId) return name + const providerName = providerById.get(account.providerId)?.name ?? '—' + return `${providerName} / ${name}` +} diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts new file mode 100644 index 0000000..bf9349a --- /dev/null +++ b/apps/web/src/lib/format.ts @@ -0,0 +1,264 @@ +import type { Settings, RatesData, Vps, Provider } from '@/types/entities' + +export function uid(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID() + return `id-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` +} + +export function normalizeWebsiteUrl(website?: string): string { + if (!website) return '' + if (website.startsWith('http://') || website.startsWith('https://')) return website + return `https://${website}` +} + +export function faviconUrlFromWebsite(website?: string): string { + const normalized = normalizeWebsiteUrl(website) + if (!normalized) return '' + try { + const { hostname } = new URL(normalized) + return `https://www.google.com/s2/favicons?domain=${hostname}&sz=32` + } catch { + return '' + } +} + +const COUNTRY_CODE_BY_NAME: Record = { + germany: 'DE', netherlands: 'NL', russia: 'RU', usa: 'US', 'united states': 'US', + ukraine: 'UA', poland: 'PL', france: 'FR', spain: 'ES', italy: 'IT', + estonia: 'EE', finland: 'FI', sweden: 'SE', norway: 'NO', latvia: 'LV', + lithuania: 'LT', czechia: 'CZ', czech: 'CZ', singapore: 'SG', japan: 'JP', + canada: 'CA', brazil: 'BR', turkey: 'TR', georgia: 'GE', kazakhstan: 'KZ', +} + +export function getCountryFlagEmoji(country?: string): string { + if (!country) return '🌐' + const code = COUNTRY_CODE_BY_NAME[country.trim().toLowerCase()] + if (!code) return '🌐' + return code.toUpperCase().split('').map((c) => String.fromCodePoint(127397 + c.charCodeAt(0))).join('') +} + +const PAYMENT_TYPE_LABELS: Record = { + direct_vps_payment: 'Прямой платеж за VPS', + provider_balance_topup: 'Пополнение баланса хостера', + daily_debit: 'Ежедневное списание', + monthly_debit: 'Ежемесячное списание', +} + +export function paymentTypeLabel(type: string): string { + return PAYMENT_TYPE_LABELS[type] ?? type +} + +const VPS_STATUS_LABELS: Record = { + active: 'Активен', paused: 'Приостановлен', archived: 'Архив', +} + +export function vpsStatusLabel(status: string): string { + return VPS_STATUS_LABELS[status] ?? status +} + +const BILLING_MODE_LABELS: Record = { + daily: 'Ежедневно', monthly: 'Ежемесячно', +} + +export function billingModeLabel(mode: string): string { + return BILLING_MODE_LABELS[mode] ?? mode +} + +const TARIFF_TYPE_LABELS: Record = { + daily: 'Суточный', monthly: 'Месячный', +} + +export function tariffTypeLabel(type: string): string { + return TARIFF_TYPE_LABELS[type] ?? type +} + +const CURRENCY_SYMBOL_MAP: Record = { + '€': 'EUR', '$': 'USD', '₽': 'RUB', '£': 'GBP', '¥': 'JPY', '₴': 'UAH', '₸': 'KZT', +} + +export function toIsoCurrency(currency?: string | null): string { + if (!currency || typeof currency !== 'string') return 'USD' + const trimmed = currency.trim() + if (CURRENCY_SYMBOL_MAP[trimmed]) return CURRENCY_SYMBOL_MAP[trimmed] + const upper = trimmed.toUpperCase() + if (upper === 'RUR') return 'RUB' + if (trimmed.length === 3 && /^[A-Z]{3}$/i.test(trimmed)) return upper + return 'USD' +} + +export function effectiveVpsTariffCurrency(vps: Vps, provider?: Provider | null): string { + const provRaw = (provider?.baseCurrency || '').trim() + if (provRaw) return toIsoCurrency(provRaw) + const ownRaw = (vps?.currency || '').trim() + if (ownRaw) return toIsoCurrency(ownRaw) + return 'RUB' +} + +export function formatCurrency(amount: number, currency = 'USD'): string { + const safeAmount = Number.isFinite(Number(amount)) ? Number(amount) : 0 + const isoCurrency = toIsoCurrency(currency) + return new Intl.NumberFormat('ru-RU', { + style: 'currency', currency: isoCurrency, minimumFractionDigits: 2, + }).format(safeAmount) +} + +export function parseProviderFxRate(raw: unknown): number { + if (raw == null) return NaN + const s = String(raw).trim() + if (!s) return NaN + if (s.toLowerCase() === 'auto') return NaN + const n = Number(s.replace(',', '.')) + if (!Number.isFinite(n) || n <= 0) return NaN + return n +} + +export function normalizeRatesPayload(payload: unknown): RatesData | null { + if (!payload || typeof payload !== 'object') return null + const p = payload as Record + if (p.base && p.rates && typeof p.rates === 'object') return p as unknown as RatesData + const valute = p.Valute as Record | undefined + if (valute && typeof valute === 'object') { + const rates: Record = {} + for (const v of Object.values(valute)) { + const code = v?.CharCode + const val = Number(v?.Value) + const nom = Number(v?.Nominal) || 1 + if (typeof code === 'string' && code.length === 3 && Number.isFinite(val) && val > 0 && nom > 0) { + rates[code] = nom / val + } + } + if (Object.keys(rates).length === 0) return null + return { base: 'RUB', rates, date: typeof p.Date === 'string' ? p.Date : '' } + } + return null +} + +export function convertCurrency( + amount: number, + fromCurrency: string, + toCurrency: string, + ratesData: RatesData | null, +): number { + const safeAmount = Number(amount) + if (!Number.isFinite(safeAmount)) return 0 + const from = toIsoCurrency(fromCurrency) + const to = toIsoCurrency(toCurrency) + if (!from || !to || from === to) return safeAmount + if (!ratesData || !ratesData.rates || !ratesData.base) return safeAmount + const apiBase = ratesData.base.toUpperCase() + const rates: Record = { ...ratesData.rates, [apiBase]: 1 } + const rateFrom = rates[from] + const rateTo = rates[to] + if (!Number.isFinite(rateFrom) || rateFrom <= 0 || !Number.isFinite(rateTo) || rateTo <= 0) { + return safeAmount + } + return (safeAmount / rateFrom) * rateTo +} + +export function formatInBaseCurrency( + amount: number, + currency: string, + appSettings: Settings[] | Settings | null | undefined, + ratesData: RatesData | null, +): string { + const settings: Partial = Array.isArray(appSettings) + ? (appSettings[0] ?? {}) + : (appSettings ?? {}) + const baseCurrency = settings.baseCurrency || 'RUB' + const autoConvert = settings.autoConvert !== false + if (!autoConvert) return formatCurrency(amount, currency) + const converted = convertCurrency(amount, currency, baseCurrency, ratesData) + return formatCurrency(converted, baseCurrency) +} + +export interface ConvertedWithProvider { + value: number + currency: string + source: 'native' | 'provider' | 'global' | 'no-rates' +} + +export function convertWithProviderRate( + amount: number, + currency: string, + provider: Provider | null | undefined, + appSettings: Settings[] | Settings | null, + ratesData: RatesData | null, +): ConvertedWithProvider { + const safeAmount = Number(amount) + const settings: Partial = Array.isArray(appSettings) + ? (appSettings[0] ?? {}) + : (appSettings ?? {}) + const appBase = (settings.baseCurrency || 'RUB').toUpperCase() + if (!Number.isFinite(safeAmount)) return { value: 0, currency: appBase, source: 'global' } + const fromCurrency = toIsoCurrency(currency || appBase) + if (fromCurrency === appBase) return { value: safeAmount, currency: appBase, source: 'native' } + + const usdRate = parseProviderFxRate(provider?.usdRate) + const eurRate = parseProviderFxRate(provider?.eurRate) + if (fromCurrency === 'USD' && Number.isFinite(usdRate)) { + return { value: safeAmount * usdRate, currency: appBase, source: 'provider' } + } + if (fromCurrency === 'EUR' && Number.isFinite(eurRate)) { + return { value: safeAmount * eurRate, currency: appBase, source: 'provider' } + } + if (!ratesData || !ratesData.rates || !ratesData.base) { + return { value: safeAmount, currency: fromCurrency, source: 'no-rates' } + } + const converted = convertCurrency(safeAmount, fromCurrency, appBase, ratesData) + const oneConverted = convertCurrency(1, fromCurrency, appBase, ratesData) + const globalRatesWork = Number.isFinite(oneConverted) && Math.abs(oneConverted - 1) > 1e-8 + return { value: converted, currency: appBase, source: globalRatesWork ? 'global' : 'no-rates' } +} + +export function formatInProviderCurrency( + amount: number, + currency: string, + provider: Provider | null | undefined, + appSettings: Settings[] | Settings | null, + ratesData: RatesData | null, +): string { + const converted = convertWithProviderRate(amount, currency, provider, appSettings, ratesData) + return formatCurrency(converted.value, converted.currency) +} + +export function monthKey(dateString: string): string { + const date = new Date(dateString) + if (Number.isNaN(date.getTime())) return '' + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` +} + +export function toCsv(rows: Record[]): string { + if (!rows.length) return '' + const headers = Object.keys(rows[0]) + const escapeValue = (value: unknown) => { + const str = `${value ?? ''}` + if (str.includes('"') || str.includes(',') || str.includes('\n')) { + return `"${str.replaceAll('"', '""')}"` + } + return str + } + const lines = [headers.join(',')] + for (const row of rows) { + lines.push(headers.map((h) => escapeValue(row[h])).join(',')) + } + return lines.join('\n') +} + +export function downloadTextFile(fileName: string, content: string): void { + const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = fileName + anchor.click() + URL.revokeObjectURL(url) +} + +export function downloadBlob(fileName: string, blob: Blob): void { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = fileName + anchor.click() + URL.revokeObjectURL(url) +} diff --git a/src/lib/inventory-health.js b/apps/web/src/lib/inventory-health.ts similarity index 58% rename from src/lib/inventory-health.js rename to apps/web/src/lib/inventory-health.ts index 48ae7c3..3efff1e 100644 --- a/src/lib/inventory-health.js +++ b/apps/web/src/lib/inventory-health.ts @@ -1,119 +1,119 @@ +import type { + Vps, + ProviderAccount, + Provider, + Payment, + BalanceLedgerRow, + SyncLogRow, + SyncSummary, +} from '@/types/entities' import { getPaidUntilDate } from './paid-until' const STALE_SYNC_HOURS = 48 -function ledgerRowsInAccountCurrency(account, balanceLedger) { +function ledgerRowsInAccountCurrency( + account: ProviderAccount, + balanceLedger: BalanceLedgerRow[], +): BalanceLedgerRow[] { const cur = (account.balance_currency || account.currency || '').trim() const rows = balanceLedger.filter((row) => row.providerAccountId === account.id) if (!cur) return rows return rows.filter((row) => !row.currency || row.currency === cur) } -function ledgerBalanceInCurrency(account, balanceLedger) { +function ledgerBalanceInCurrency( + account: ProviderAccount, + balanceLedger: BalanceLedgerRow[], +): number { const filtered = ledgerRowsInAccountCurrency(account, balanceLedger) - const credits = filtered - .filter((row) => row.direction === 'credit') - .reduce((acc, row) => acc + Number(row.amount || 0), 0) - const debits = filtered - .filter((row) => row.direction === 'debit') - .reduce((acc, row) => acc + Number(row.amount || 0), 0) + const credits = filtered.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0) + const debits = filtered.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0) return credits - debits } -/** - * Сравниваем баланс из API с суммой по balance_ledger. - * Если в ledger нет ни одной строки по аккаунту (в валюте баланса) — не считаем расхождением: - * пользователь видит только API, а «0 из ledger» — не противоречие, а отсутствие учёта. - */ -export function accountHasApiLedgerMismatch(account, balanceLedger) { +export function accountHasApiLedgerMismatch( + account: ProviderAccount, + balanceLedger: BalanceLedgerRow[], +): boolean { if (account.balance_api == null || !Number.isFinite(Number(account.balance_api))) return false const rows = ledgerRowsInAccountCurrency(account, balanceLedger) if (rows.length === 0) return false const ledger = ledgerBalanceInCurrency(account, balanceLedger) if (!Number.isFinite(ledger)) return false - const api = Number(account.balance_api) - const diff = Math.abs(api - ledger) - const tol = Math.max(10, Math.abs(api) * 0.05) + const apiBalance = Number(account.balance_api) + const diff = Math.abs(apiBalance - ledger) + const tol = Math.max(10, Math.abs(apiBalance) * 0.05) return diff > tol } -export function lastOkSyncFinishedAt(accountId, syncLog) { - const rows = (syncLog || []).filter( - (r) => r.accountId === accountId && r.status === 'ok' && r.finishedAt, - ) - let best = null +export function lastOkSyncFinishedAt( + accountId: string, + syncLog: SyncLogRow[] = [], +): number | null { + const rows = syncLog.filter((r) => r.accountId === accountId && r.status === 'ok' && r.finishedAt) + let best: number | null = null for (const r of rows) { - const t = new Date(r.finishedAt).getTime() - if (!Number.isNaN(t) && (!best || t > best)) best = t + const t = new Date(r.finishedAt as string).getTime() + if (!Number.isNaN(t) && (best == null || t > best)) best = t } return best } -/** - * @param {{ - * vps: object[], - * providerAccounts: object[], - * providers: object[], - * payments: object[], - * balanceLedger: object[], - * syncLog?: object[], - * }} input - */ -export function computeInventoryHealth(input) { +export interface InventoryIssue { + key: string + title: string + count: number + to: string + hint?: string +} + +export interface InventoryHealthInput { + vps: Vps[] + providerAccounts: ProviderAccount[] + providers?: Provider[] + payments: Payment[] + balanceLedger: BalanceLedgerRow[] + syncLog?: SyncLogRow[] +} + +export function computeInventoryHealth(input: InventoryHealthInput): InventoryIssue[] { const { vps, providerAccounts, providers = [], payments, balanceLedger, syncLog = [] } = input const providerById = new Map(providers.map((p) => [p.id, p])) const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const ctx = { vps, providerAccounts, payments, balanceLedger, now } - /** @type {{ key: string, title: string, count: number, to: string, hint?: string }[]} */ - const issues = [] + const issues: InventoryIssue[] = [] const noProject = vps.filter((v) => v.status === 'active' && !(v.project || '').trim()) if (noProject.length) { - issues.push({ - key: 'no-project', - title: 'Активные VPS без проекта', - count: noProject.length, - to: '/vps?health=no-project', - }) + issues.push({ key: 'no-project', title: 'Активные VPS без проекта', count: noProject.length, to: '/vps?health=no-project' }) } const noRate = vps.filter((v) => { if (v.status !== 'active') return false const dr = Number(v.dailyRate || 0) const mr = Number(v.monthlyRate || 0) - const noMoney = - (!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0) + const noMoney = (!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0) const noCur = !(v.currency || '').trim() return noMoney || noCur }) if (noRate.length) { - issues.push({ - key: 'no-rate', - title: 'Нет ставки или валюты', - count: noRate.length, - to: '/vps?health=no-rate', - }) + issues.push({ key: 'no-rate', title: 'Нет ставки или валюты', count: noRate.length, to: '/vps?health=no-rate' }) } const paidOverdue = vps.filter((v) => { if (v.status !== 'active') return false const d = getPaidUntilDate(v, ctx) - return d && d < todayStart + return d != null && d < todayStart }) if (paidOverdue.length) { - issues.push({ - key: 'paid-overdue', - title: 'Просрочена оплата (оценка)', - count: paidOverdue.length, - to: '/vps?health=paid-overdue', - }) + issues.push({ key: 'paid-overdue', title: 'Просрочена оплата (оценка)', count: paidOverdue.length, to: '/vps?health=paid-overdue' }) } const bmAccounts = providerAccounts.filter((a) => { const p = providerById.get(a.providerId) - return p?.apiType === 'billmanager' && (p.apiBaseUrl || '').trim() && a.apiCredentialsSet + return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet }) const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000 const staleAccounts = bmAccounts.filter((a) => { @@ -145,16 +145,16 @@ export function computeInventoryHealth(input) { return issues } -/** - * @param {object[]} providerAccounts - * @param {object[]} providers - * @param {object[]} syncLog - */ -export function getStaleSyncAccountIds(providerAccounts, providers, syncLog, now = new Date()) { +export function getStaleSyncAccountIds( + providerAccounts: ProviderAccount[], + providers: Provider[], + syncLog: SyncLogRow[] = [], + now = new Date(), +): string[] { const providerById = new Map(providers.map((p) => [p.id, p])) const bmAccounts = providerAccounts.filter((a) => { const p = providerById.get(a.providerId) - return p?.apiType === 'billmanager' && (p.apiBaseUrl || '').trim() && a.apiCredentialsSet + return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet }) const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000 return bmAccounts @@ -166,21 +166,17 @@ export function getStaleSyncAccountIds(providerAccounts, providers, syncLog, now .map((a) => a.id) } -/** - * @param {object[]} providerAccounts - * @param {object[]} balanceLedger - */ -export function getBalanceMismatchAccountIds(providerAccounts, balanceLedger) { +export function getBalanceMismatchAccountIds( + providerAccounts: ProviderAccount[], + balanceLedger: BalanceLedgerRow[], +): string[] { return providerAccounts.filter((a) => accountHasApiLedgerMismatch(a, balanceLedger)).map((a) => a.id) } -/** - * @param {object|null} summary - */ -export function formatSyncSummaryLine(summary) { +export function formatSyncSummaryLine(summary: SyncSummary | null | undefined): string { if (!summary || typeof summary !== 'object') return '' if (summary.error) return String(summary.error) - const parts = [] + const parts: string[] = [] if (summary.added?.length) parts.push(`+${summary.added.length} VPS`) if (summary.updated?.length) parts.push(`~${summary.updated.length} изм.`) if (summary.paymentsAdded) parts.push(`+${summary.paymentsAdded} платежей`) diff --git a/apps/web/src/lib/no-browser-suggest.ts b/apps/web/src/lib/no-browser-suggest.ts new file mode 100644 index 0000000..379c660 --- /dev/null +++ b/apps/web/src/lib/no-browser-suggest.ts @@ -0,0 +1,13 @@ +import type { HTMLInputProps } from '@/types/dom' + +export const noBrowserSuggestProps: HTMLInputProps = Object.freeze({ + autoComplete: 'off', + 'data-lpignore': 'true', + 'data-1p-ignore': 'true', + 'data-bwignore': 'true', + 'data-form-type': 'other', +} as HTMLInputProps) + +export const passwordCredentialInputProps: HTMLInputProps = Object.freeze({ + autoComplete: 'new-password', +} as HTMLInputProps) diff --git a/src/lib/paid-until.js b/apps/web/src/lib/paid-until.ts similarity index 71% rename from src/lib/paid-until.js rename to apps/web/src/lib/paid-until.ts index eb61a61..3908bea 100644 --- a/src/lib/paid-until.js +++ b/apps/web/src/lib/paid-until.ts @@ -1,28 +1,29 @@ -/** - * Дата «оплачено до» для VPS (как на дашборде). - */ +import type { Vps, ProviderAccount, Payment, BalanceLedgerRow } from '@/types/entities' -function getAccountBalance(accountId, providerAccounts, balanceLedger) { +function getAccountBalance( + accountId: string, + providerAccounts: ProviderAccount[], + balanceLedger: BalanceLedgerRow[], +): number { const account = providerAccounts.find((a) => a.id === accountId) if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) { return Number(account.balance_api) } const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId) - const credits = ledgerRows - .filter((row) => row.direction === 'credit') - .reduce((acc, row) => acc + Number(row.amount || 0), 0) - const debits = ledgerRows - .filter((row) => row.direction === 'debit') - .reduce((acc, row) => acc + Number(row.amount || 0), 0) + const credits = ledgerRows.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0) + const debits = ledgerRows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0) return credits - debits } -/** - * @param {object} item - VPS - * @param {{ vps: object[], providerAccounts: object[], payments: object[], balanceLedger: object[], now?: Date }} ctx - * @returns {Date|null} - */ -export function getPaidUntilDate(item, ctx) { +export interface PaidUntilContext { + vps: Vps[] + providerAccounts: ProviderAccount[] + payments: Payment[] + balanceLedger: BalanceLedgerRow[] + now?: Date +} + +export function getPaidUntilDate(item: Vps, ctx: PaidUntilContext): Date | null { const { vps, providerAccounts, payments, balanceLedger, now = new Date() } = ctx if (item.status !== 'active') return null const account = providerAccounts.find((a) => a.id === item.providerAccountId) @@ -37,19 +38,16 @@ export function getPaidUntilDate(item, ctx) { : null const isPaidUntilNextDay = - paidUntilFromApi && + paidUntilFromApi != null && (() => { const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - const diffMs = paidUntilFromApi - today + const diffMs = paidUntilFromApi.getTime() - today.getTime() const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000)) return diffDays >= 0 && diffDays <= 2 })() const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay - - if (!shouldCalculateFromBalance && paidUntilFromApi) { - return paidUntilFromApi - } + if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi const dailyRate = Number(item.dailyRate || 0) const monthlyRate = Number(item.monthlyRate || 0) diff --git a/apps/web/src/lib/queryClient.ts b/apps/web/src/lib/queryClient.ts new file mode 100644 index 0000000..d624f31 --- /dev/null +++ b/apps/web/src/lib/queryClient.ts @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/react-query' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}) diff --git a/apps/web/src/lib/router.ts b/apps/web/src/lib/router.ts new file mode 100644 index 0000000..f4ebc66 --- /dev/null +++ b/apps/web/src/lib/router.ts @@ -0,0 +1,25 @@ +import { QueryClient } from '@tanstack/react-query' +import { + createRouter as tanstackCreateRouter, + rootRouteId, +} from '@tanstack/react-router' + +import { routeTree } from '../routeTree.gen' +import { queryClient } from './queryClient' + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} + +export function createRouter(opts?: { context?: { queryClient: QueryClient } }) { + return tanstackCreateRouter({ + routeTree, + context: opts?.context ?? { queryClient }, + defaultPreload: 'intent', + scrollRestoration: true, + }) +} + +export { rootRouteId } diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts new file mode 100644 index 0000000..989bc21 --- /dev/null +++ b/apps/web/src/lib/schemas.ts @@ -0,0 +1,92 @@ +import { z } from 'zod' + +export const vpsStatusSchema = z.enum(['active', 'paused', 'archived']) +export const tariffTypeSchema = z.enum(['daily', 'monthly']) +export const billingModeSchema = z.enum(['daily', 'monthly']) +export const paymentTypeSchema = z.enum([ + 'direct_vps_payment', + 'provider_balance_topup', + 'daily_debit', + 'monthly_debit', +]) +export const ledgerDirectionSchema = z.enum(['credit', 'debit']) +export const apiTypeSchema = z.enum(['billmanager', 'none']) + +export const providerSchema = z.object({ + id: z.string().min(1).optional(), + name: z.string().min(1, 'Название обязательно'), + website: z.string().url('Невалидный URL').or(z.literal('')).optional(), + apiType: apiTypeSchema, + apiBaseUrl: z.string().optional().default(''), + baseCurrency: z.string().min(1).default('RUB'), + usdRate: z.string().optional().default(''), + eurRate: z.string().optional().default(''), + supportPhone: z.string().optional().default(''), + supportUrl: z.string().optional().default(''), + notes: z.string().optional().default(''), +}) + +export const providerAccountSchema = z.object({ + id: z.string().min(1).optional(), + providerId: z.string().min(1, 'Выберите хостера'), + name: z.string().min(1, 'Название обязательно'), + login: z.string().optional().default(''), + apiCredentials: z.string().optional().default(''), + billingMode: billingModeSchema.default('monthly'), + notes: z.string().optional().default(''), +}) + +export const vpsSchema = z.object({ + id: z.string().min(1).optional(), + ip: z.string().min(1, 'IP обязателен'), + dns: z.string().optional().default(''), + providerId: z.string().min(1, 'Выберите хостера'), + providerAccountId: z.string().min(1, 'Выберите аккаунт'), + vcpu: z.coerce.number().int().min(0).default(1), + ramGb: z.coerce.number().min(0).default(1), + diskGb: z.coerce.number().min(0).default(10), + status: vpsStatusSchema.default('active'), + tariffType: tariffTypeSchema.default('monthly'), + currency: z.string().min(1, 'Валюта обязательна').default('RUB'), + monthlyRate: z.coerce.number().min(0).default(0), + dailyRate: z.coerce.number().min(0).default(0), + paidUntil: z.string().optional().default(''), + project: z.string().optional().default(''), + notes: z.string().optional().default(''), +}) + +export const paymentSchema = z.object({ + id: z.string().min(1).optional(), + type: paymentTypeSchema, + date: z.string().min(1, 'Дата обязательна'), + amount: z.coerce.number().min(0, 'Сумма должна быть ≥ 0'), + currency: z.string().min(1).default('RUB'), + providerAccountId: z.string().min(1, 'Выберите аккаунт'), + note: z.string().optional().default(''), +}) + +export const balanceLedgerSchema = z.object({ + providerAccountId: z.string().min(1, 'Выберите аккаунт'), + direction: ledgerDirectionSchema, + amount: z.coerce.number().min(0, 'Сумма должна быть ≥ 0'), + currency: z.string().min(1).default('RUB'), + date: z.string().min(1, 'Дата обязательна'), + note: z.string().optional().default(''), +}) + +export const settingsSchema = z.object({ + id: z.string().optional(), + baseCurrency: z.string().min(1).default('RUB'), + ratesUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(), + autoConvert: z.boolean().default(true), + syncEnabled: z.boolean().optional().default(true), + telegramChatId: z.string().optional().default(''), + telegramBotToken: z.string().optional().default(''), +}) + +export type ProviderFormValues = z.infer +export type ProviderAccountFormValues = z.infer +export type VpsFormValues = z.infer +export type PaymentFormValues = z.infer +export type BalanceLedgerFormValues = z.infer +export type SettingsFormValues = z.infer diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..c38d918 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,24 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { QueryClientProvider } from '@tanstack/react-query' +import { RouterProvider } from '@tanstack/react-router' +import { Toaster } from '@cfdm/ui/components/sonner' + +import '@cfdm/ui/globals.css' + +import { queryClient } from '@/lib/queryClient' +import { createRouter } from '@/lib/router' + +const router = createRouter({ context: { queryClient } }) + +const rootEl = document.getElementById('root') +if (!rootEl) throw new Error('Root element #root not found') + +createRoot(rootEl).render( + + + + + + , +) diff --git a/apps/web/src/queries/snapshot.ts b/apps/web/src/queries/snapshot.ts new file mode 100644 index 0000000..6af5906 --- /dev/null +++ b/apps/web/src/queries/snapshot.ts @@ -0,0 +1,47 @@ +import { queryClient } from '../lib/queryClient' +import { api } from '../lib/api-client' + +export const snapshotKeys = { + all: ['snapshot'] as const, +} + +export const snapshotQueryOptions = () => ({ + queryKey: snapshotKeys.all, + queryFn: () => api.fetchData(), + staleTime: 30_000, +}) + +export const ratesKeys = { + all: ['rates'] as const, +} + +export const ratesQueryOptions = (ratesUrl?: string) => ({ + queryKey: ratesKeys.all, + queryFn: async () => { + if (!ratesUrl) return null + const proxyUrl = `/api/rates-proxy?url=${encodeURIComponent(ratesUrl)}` + try { + const res = await fetch(proxyUrl) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return await res.json() + } catch { + const direct = await fetch(ratesUrl) + if (!direct.ok) throw new Error(`HTTP ${direct.status}`) + return await direct.json() + } + }, + enabled: Boolean(ratesUrl), +}) + +export const projectsKeys = { + suggest: (q: string) => ['projects', 'suggest', q] as const, +} + +export const projectsSuggestQueryOptions = (q: string) => ({ + queryKey: projectsKeys.suggest(q), + queryFn: () => api.fetchProjectSuggestions(q), + enabled: q.length >= 2, +}) + +export type SnapshotFromQuery = Awaited> +export { queryClient } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts new file mode 100644 index 0000000..029e4fe --- /dev/null +++ b/apps/web/src/routeTree.gen.ts @@ -0,0 +1,297 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as AuthRouteImport } from './routes/_auth' +import { Route as IndexRouteImport } from './routes/index' +import { Route as AuthVpsRouteImport } from './routes/_auth/vps' +import { Route as AuthTariffsRouteImport } from './routes/_auth/tariffs' +import { Route as AuthSettingsRouteImport } from './routes/_auth/settings' +import { Route as AuthResourcesRouteImport } from './routes/_auth/resources' +import { Route as AuthReportsRouteImport } from './routes/_auth/reports' +import { Route as AuthProvidersRouteImport } from './routes/_auth/providers' +import { Route as AuthPaymentsRouteImport } from './routes/_auth/payments' +import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard' +import { Route as AuthBalanceRouteImport } from './routes/_auth/balance' +import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts' + +const AuthRoute = AuthRouteImport.update({ + id: '/_auth', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const AuthVpsRoute = AuthVpsRouteImport.update({ + id: '/vps', + path: '/vps', + getParentRoute: () => AuthRoute, +} as any) +const AuthTariffsRoute = AuthTariffsRouteImport.update({ + id: '/tariffs', + path: '/tariffs', + getParentRoute: () => AuthRoute, +} as any) +const AuthSettingsRoute = AuthSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => AuthRoute, +} as any) +const AuthResourcesRoute = AuthResourcesRouteImport.update({ + id: '/resources', + path: '/resources', + getParentRoute: () => AuthRoute, +} as any) +const AuthReportsRoute = AuthReportsRouteImport.update({ + id: '/reports', + path: '/reports', + getParentRoute: () => AuthRoute, +} as any) +const AuthProvidersRoute = AuthProvidersRouteImport.update({ + id: '/providers', + path: '/providers', + getParentRoute: () => AuthRoute, +} as any) +const AuthPaymentsRoute = AuthPaymentsRouteImport.update({ + id: '/payments', + path: '/payments', + getParentRoute: () => AuthRoute, +} as any) +const AuthDashboardRoute = AuthDashboardRouteImport.update({ + id: '/dashboard', + path: '/dashboard', + getParentRoute: () => AuthRoute, +} as any) +const AuthBalanceRoute = AuthBalanceRouteImport.update({ + id: '/balance', + path: '/balance', + getParentRoute: () => AuthRoute, +} as any) +const AuthAccountsRoute = AuthAccountsRouteImport.update({ + id: '/accounts', + path: '/accounts', + getParentRoute: () => AuthRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/accounts': typeof AuthAccountsRoute + '/balance': typeof AuthBalanceRoute + '/dashboard': typeof AuthDashboardRoute + '/payments': typeof AuthPaymentsRoute + '/providers': typeof AuthProvidersRoute + '/reports': typeof AuthReportsRoute + '/resources': typeof AuthResourcesRoute + '/settings': typeof AuthSettingsRoute + '/tariffs': typeof AuthTariffsRoute + '/vps': typeof AuthVpsRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/accounts': typeof AuthAccountsRoute + '/balance': typeof AuthBalanceRoute + '/dashboard': typeof AuthDashboardRoute + '/payments': typeof AuthPaymentsRoute + '/providers': typeof AuthProvidersRoute + '/reports': typeof AuthReportsRoute + '/resources': typeof AuthResourcesRoute + '/settings': typeof AuthSettingsRoute + '/tariffs': typeof AuthTariffsRoute + '/vps': typeof AuthVpsRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/_auth': typeof AuthRouteWithChildren + '/_auth/accounts': typeof AuthAccountsRoute + '/_auth/balance': typeof AuthBalanceRoute + '/_auth/dashboard': typeof AuthDashboardRoute + '/_auth/payments': typeof AuthPaymentsRoute + '/_auth/providers': typeof AuthProvidersRoute + '/_auth/reports': typeof AuthReportsRoute + '/_auth/resources': typeof AuthResourcesRoute + '/_auth/settings': typeof AuthSettingsRoute + '/_auth/tariffs': typeof AuthTariffsRoute + '/_auth/vps': typeof AuthVpsRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/accounts' + | '/balance' + | '/dashboard' + | '/payments' + | '/providers' + | '/reports' + | '/resources' + | '/settings' + | '/tariffs' + | '/vps' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/accounts' + | '/balance' + | '/dashboard' + | '/payments' + | '/providers' + | '/reports' + | '/resources' + | '/settings' + | '/tariffs' + | '/vps' + id: + | '__root__' + | '/' + | '/_auth' + | '/_auth/accounts' + | '/_auth/balance' + | '/_auth/dashboard' + | '/_auth/payments' + | '/_auth/providers' + | '/_auth/reports' + | '/_auth/resources' + | '/_auth/settings' + | '/_auth/tariffs' + | '/_auth/vps' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AuthRoute: typeof AuthRouteWithChildren +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/_auth': { + id: '/_auth' + path: '' + fullPath: '/' + preLoaderRoute: typeof AuthRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/_auth/vps': { + id: '/_auth/vps' + path: '/vps' + fullPath: '/vps' + preLoaderRoute: typeof AuthVpsRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/tariffs': { + id: '/_auth/tariffs' + path: '/tariffs' + fullPath: '/tariffs' + preLoaderRoute: typeof AuthTariffsRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/settings': { + id: '/_auth/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof AuthSettingsRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/resources': { + id: '/_auth/resources' + path: '/resources' + fullPath: '/resources' + preLoaderRoute: typeof AuthResourcesRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/reports': { + id: '/_auth/reports' + path: '/reports' + fullPath: '/reports' + preLoaderRoute: typeof AuthReportsRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/providers': { + id: '/_auth/providers' + path: '/providers' + fullPath: '/providers' + preLoaderRoute: typeof AuthProvidersRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/payments': { + id: '/_auth/payments' + path: '/payments' + fullPath: '/payments' + preLoaderRoute: typeof AuthPaymentsRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/dashboard': { + id: '/_auth/dashboard' + path: '/dashboard' + fullPath: '/dashboard' + preLoaderRoute: typeof AuthDashboardRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/balance': { + id: '/_auth/balance' + path: '/balance' + fullPath: '/balance' + preLoaderRoute: typeof AuthBalanceRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/accounts': { + id: '/_auth/accounts' + path: '/accounts' + fullPath: '/accounts' + preLoaderRoute: typeof AuthAccountsRouteImport + parentRoute: typeof AuthRoute + } + } +} + +interface AuthRouteChildren { + AuthAccountsRoute: typeof AuthAccountsRoute + AuthBalanceRoute: typeof AuthBalanceRoute + AuthDashboardRoute: typeof AuthDashboardRoute + AuthPaymentsRoute: typeof AuthPaymentsRoute + AuthProvidersRoute: typeof AuthProvidersRoute + AuthReportsRoute: typeof AuthReportsRoute + AuthResourcesRoute: typeof AuthResourcesRoute + AuthSettingsRoute: typeof AuthSettingsRoute + AuthTariffsRoute: typeof AuthTariffsRoute + AuthVpsRoute: typeof AuthVpsRoute +} + +const AuthRouteChildren: AuthRouteChildren = { + AuthAccountsRoute: AuthAccountsRoute, + AuthBalanceRoute: AuthBalanceRoute, + AuthDashboardRoute: AuthDashboardRoute, + AuthPaymentsRoute: AuthPaymentsRoute, + AuthProvidersRoute: AuthProvidersRoute, + AuthReportsRoute: AuthReportsRoute, + AuthResourcesRoute: AuthResourcesRoute, + AuthSettingsRoute: AuthSettingsRoute, + AuthTariffsRoute: AuthTariffsRoute, + AuthVpsRoute: AuthVpsRoute, +} + +const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AuthRoute: AuthRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx new file mode 100644 index 0000000..4dbb9ef --- /dev/null +++ b/apps/web/src/routes/__root.tsx @@ -0,0 +1,18 @@ +import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' +import { AppShell } from '@/components/layout/app-shell' + +interface RouterContext { + queryClient: import('@tanstack/react-query').QueryClient +} + +export const Route = createRootRouteWithContext()({ + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + ) +} diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx new file mode 100644 index 0000000..ff87a27 --- /dev/null +++ b/apps/web/src/routes/_auth.tsx @@ -0,0 +1,12 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' +import { snapshotQueryOptions } from '@/queries/snapshot' + +export const Route = createFileRoute('/_auth')({ + loader: ({ context: { queryClient } }) => + queryClient.ensureQueryData(snapshotQueryOptions()), + component: AuthLayout, +}) + +function AuthLayout() { + return +} diff --git a/apps/web/src/routes/_auth/accounts.tsx b/apps/web/src/routes/_auth/accounts.tsx new file mode 100644 index 0000000..07f30ad --- /dev/null +++ b/apps/web/src/routes/_auth/accounts.tsx @@ -0,0 +1,229 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { PlusIcon, PencilIcon, Trash2Icon, RefreshCwIcon } from 'lucide-react' +import { toast } from 'sonner' + +import { snapshotQueryOptions } from '@/queries/snapshot' +import { api, ApiError } from '@/lib/api-client' +import { PageShell } from '@/components/page-shell' +import { PageHeader } from '@/components/page-header' +import { Button } from '@cfdm/ui/components/button' +import { Badge } from '@cfdm/ui/components/badge' +import { DataTableCard, type DataTableColumn } from '@/components/data-table-card' +import { QueryState } from '@/components/query-state' +import { TableSkeleton } from '@/components/skeletons' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { FormSheet } from '@/components/form-sheet' +import { FormField } from '@/components/form-field' +import { Input } from '@cfdm/ui/components/input' +import { Textarea } from '@cfdm/ui/components/textarea' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@cfdm/ui/components/select' +import { LoadingButton } from '@/components/loading-button' + +import type { ProviderAccount, BillingMode } from '@/types/entities' +import { providerByIdMap, accountBillmanagerUiReady } from '@/lib/billmanager' +import { billingModeLabel, formatCurrency } from '@/lib/format' + +export const Route = createFileRoute('/_auth/accounts')({ + loader: ({ context: { queryClient } }) => + queryClient.ensureQueryData(snapshotQueryOptions()), + component: AccountsPage, +}) + +interface FormState { + id?: string + providerId: string + name: string + login: string + apiCredentials: string + billingMode: BillingMode + notes: string +} + +const EMPTY: FormState = { providerId: '', name: '', login: '', apiCredentials: '', billingMode: 'monthly', notes: '' } + +function AccountsPage() { + const queryClient = useQueryClient() + const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions()) + const [open, setOpen] = useState(false) + const [form, setForm] = useState(EMPTY) + + const saveMut = useMutation({ + mutationFn: (r: FormState) => { + const { apiCredentials, ...rest } = r + const payload = apiCredentials ? { ...rest, apiCredentials } : rest + return r.id + ? api.update('providerAccounts', r.id, payload as unknown as Partial) + : api.create('providerAccounts', payload as unknown as ProviderAccount) + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['snapshot'] }) + toast.success('Аккаунт сохранён') + setOpen(false) + }, + onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'), + }) + const delMut = useMutation({ + mutationFn: (id: string) => api.remove('providerAccounts', id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['snapshot'] }) + toast.success('Аккаунт удалён') + }, + onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'), + }) + const syncMut = useMutation({ + mutationFn: (id: string) => api.syncAccount(id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['snapshot'] }) + toast.success('Синк запущен') + }, + onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка синка'), + }) + + const openCreate = () => { setForm({ ...EMPTY, providerId: snapshot?.providers[0]?.id ?? '' }); setOpen(true) } + const openEdit = (a: ProviderAccount) => { + setForm({ + id: a.id, providerId: a.providerId, name: a.name, login: a.login ?? '', + apiCredentials: '', billingMode: a.billingMode ?? 'monthly', notes: a.notes ?? '', + }) + setOpen(true) + } + + const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map() + + const columns: DataTableColumn[] = [ + { + key: 'name', + header: 'Аккаунт', + cell: (a) => ( +
+ {a.name} + {providerById.get(a.providerId)?.name ?? '—'} +
+ ), + }, + { key: 'login', header: 'Логин', cell: (a) => {a.login || '—'} }, + { + key: 'creds', + header: 'API-доступ', + cell: (a) => {a.apiCredentialsSet ? 'установлены' : 'нет'}, + }, + { + key: 'mode', + header: 'Биллинг', + cell: (a) => {billingModeLabel(a.billingMode ?? 'monthly')}, + }, + { + key: 'balance', + header: 'Баланс (API)', + cell: (a) => { + const provider = providerById.get(a.providerId) + if (!accountBillmanagerUiReady(a, provider)) return + const cur = a.balance_currency || a.currency || provider?.baseCurrency || 'USD' + return {formatCurrency(Number(a.balance_api ?? 0), cur)} + }, + }, + { + key: 'actions', + header: '', + className: 'w-32 text-right', + cell: (a) => { + const provider = providerById.get(a.providerId) + const canSync = accountBillmanagerUiReady(a, provider) + return ( +
+ syncMut.mutate(a.id)} + > + + Синк + + + } + title="Удалить аккаунт?" + description={`«${a.name}» будет удалён.`} + destructive + confirmLabel="Удалить" + onConfirm={() => delMut.mutate(a.id)} + /> +
+ ) + }, + }, + ] + + return ( + + Добавить} + /> + refetch()} + skeleton={} + empty={snapshot?.providerAccounts.length === 0} + emptyTitle="Аккаунты не найдены" + emptyAction={} + > + {(snap) => a.id} />} + + + saveMut.mutate(form)} + submitting={saveMut.isPending} + > + + + + + setForm({ ...form, name: e.target.value })} /> + + + setForm({ ...form, login: e.target.value })} /> + + + setForm({ ...form, apiCredentials: e.target.value })} /> + + + + + +