refactor(repo): переход на pnpm monorepo с shadcn/ui и Fastify+Drizzle
Frontend:
- apps/web (Vite+TS, TanStack Router/Query, shadcn/ui @cfdm/ui base-nova)
- 10 страниц в routes/_auth/, Recharts через shadcn Chart, lucide-react
- формы на RHF + Zod (FormSheet/FormField)
- удалены Tabler, Chart.js, react-router-dom
Backend (параллельный трек):
- apps/api (Fastify 5 + Drizzle + better-sqlite3)
- packages/db: Drizzle-схема и repositories по сущностям
- packages/shared: Zod-контракты
- роуты с валидацией и единым форматом ошибок { error: { code, message } }
- sync/backup — заглушки 501 (billmanager-адаптеры переносятся отдельно)
- legacy Express оставлен как runtime по умолчанию (RUNTIME=express)
Infra:
- Dockerfile multi-stage под pnpm workspaces
- .dockerignore и docker-compose обновлены под monorepo
Rules:
- удалены нерелевантные правила (rust, cloudflare, server/frontend-conventions)
- project-structure.mdc и AGENTS.md переписаны под monorepo
- frontend-shadcn.mdc, shadcn-ui-production.mdc, sqlite.mdc обновлены
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"codegraph": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "codegraph",
|
||||||
|
"args": [
|
||||||
|
"serve",
|
||||||
|
"--mcp",
|
||||||
|
"--path",
|
||||||
|
"C:\\Users\\shats\\Dev\\cloudflare-domain-manager"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 `<table>` / `<select>` / кастомные badge-цвета
|
||||||
|
- Дублирование Zod schemas в `apps/web` — только re-export из `@cfdm/shared`
|
||||||
|
- Самописные формы без `Field` + RHF + Zod
|
||||||
|
|
||||||
|
## shadcn-паттерны
|
||||||
|
|
||||||
|
| API-данные | UI |
|
||||||
|
|------------|-----|
|
||||||
|
| Список | `Table` / `DataTableCard` |
|
||||||
|
| Создание | `Card` + `FieldGroup` + RHF |
|
||||||
|
| Статус | `Badge` variants |
|
||||||
|
| Ошибка | `sonner` `toast.error` |
|
||||||
|
|
||||||
|
## Согласованность
|
||||||
|
|
||||||
|
- JSON поля — snake_case как в существующем API
|
||||||
|
- Новый endpoint → `queryOptions` в `apps/web/src/queries/`
|
||||||
|
|
||||||
|
MCP backend: [`backend-mcp.mdc`](backend-mcp.mdc) · Fastify: [`backend-fastify.mdc`](backend-fastify.mdc)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
description: Drizzle ORM + SQLite — schema, migrations, queries
|
||||||
|
globs: packages/db/**/*,apps/api/src/services/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backend Drizzle
|
||||||
|
|
||||||
|
MCP Context7 (`drizzle-orm`, `drizzle-kit`) — [`backend-mcp.mdc`](backend-mcp.mdc).
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
- `packages/db/src/schema/` — source of truth
|
||||||
|
- Migrations: `drizzle-kit generate` / `migrate`
|
||||||
|
- WAL + `foreign_keys` при открытии SQLite
|
||||||
|
- Индекс `idx_dns_records_domain_cf_id` на `(domain_id, cf_record_id)`
|
||||||
|
|
||||||
|
## Queries
|
||||||
|
|
||||||
|
- Batch queries (`inArray`, JOINs) — не N+1 loops
|
||||||
|
- UNIQUE violations → `CONFLICT` (409)
|
||||||
|
- Multi-step → `db.transaction()`
|
||||||
|
|
||||||
|
## SQLite
|
||||||
|
|
||||||
|
См. [`sqlite.mdc`](sqlite.mdc).
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
description: Fastify API — слои, плагины, контракт ошибок
|
||||||
|
globs: apps/api/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backend Fastify
|
||||||
|
|
||||||
|
Стек: **Node.js 22**, **Fastify 5**, `@fastify/*` plugins, `@cfdm/shared`, `@cfdm/db`.
|
||||||
|
|
||||||
|
MCP — [`backend-mcp.mdc`](backend-mcp.mdc).
|
||||||
|
|
||||||
|
## Слои
|
||||||
|
|
||||||
|
```
|
||||||
|
routes/ → services/ → @cfdm/db (repositories)
|
||||||
|
↘ lib/cf-client.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- Routes — тонкие Fastify plugins (`fastify-plugin`)
|
||||||
|
- **Запрещено:** SQL в routes, `fetch` к CF вне `cf-client`
|
||||||
|
|
||||||
|
## Плагины (официальные)
|
||||||
|
|
||||||
|
`@fastify/jwt`, `@fastify/cors`, `@fastify/sensible`, `@fastify/static`, `@fastify/helmet`, `@fastify/rate-limit`, `@fastify/type-provider-zod`, `fastify-plugin`
|
||||||
|
|
||||||
|
## Ошибки
|
||||||
|
|
||||||
|
Формат: `{ error: { code, message } }`
|
||||||
|
|
||||||
|
Коды: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `CONFLICT`, `CLOUDFLARE_ERROR`, `INTERNAL_ERROR`
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
- Zod schemas только из `@cfdm/shared`
|
||||||
|
- `db.transaction()` для multi-step writes
|
||||||
|
- Операции >2s → async job (`sync_jobs` + `p-queue`)
|
||||||
|
- Env через Zod в `config.ts`; prod fail-fast на dev `JWT_SECRET`
|
||||||
|
- TypeScript strict; `function` для handlers/services
|
||||||
|
|
||||||
|
## API + UI
|
||||||
|
|
||||||
|
[`backend-api-ui.mdc`](backend-api-ui.mdc)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
description: Обязательный порядок MCP перед backend-кодом (Fastify, Drizzle, Cloudflare API)
|
||||||
|
globs: apps/api/**/*,packages/db/**/*,packages/shared/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backend MCP — обязательно
|
||||||
|
|
||||||
|
Перед **любой** задачей в `apps/api`, `packages/db`, `packages/shared` — сначала MCP, не training data.
|
||||||
|
|
||||||
|
## Порядок
|
||||||
|
|
||||||
|
| Задача | MCP |
|
||||||
|
|--------|-----|
|
||||||
|
| Fastify plugins, routes, hooks | **Context7** `resolve-library-id` → `query-docs` (`fastify`, `@fastify/jwt`, `@fastify/type-provider-zod`) |
|
||||||
|
| Drizzle schema, queries, migrations | **Context7** (`drizzle-orm`, `drizzle-kit`, `better-sqlite3`) |
|
||||||
|
| Cloudflare DNS/Zones API | **`plugin-cloudflare-cloudflare-docs`** `search_cloudflare_documentation` |
|
||||||
|
| API + UI | `packages/shared` → **shadcn MCP** ([`shadcn-mcp.mdc`](shadcn-mcp.mdc)) |
|
||||||
|
| E2E / cutover | **cursor-ide-browser** |
|
||||||
|
| Неизвестный инструмент | **user-mcp-on-demand** `search_tools` |
|
||||||
|
|
||||||
|
## Запрещено
|
||||||
|
|
||||||
|
- Угадывать API Fastify/Drizzle/CF из памяти
|
||||||
|
- Самописные аналоги `@fastify/*` (CORS, static, JWT, rate-limit)
|
||||||
|
- Дублировать Zod schemas вне `@cfdm/shared`
|
||||||
|
|
||||||
|
Связанные: [`backend-fastify.mdc`](backend-fastify.mdc), [`backend-drizzle.mdc`](backend-drizzle.mdc), [`backend-testing.mdc`](backend-testing.mdc).
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
description: Backend Vitest + Fastify inject
|
||||||
|
globs: apps/api/**/*,packages/db/**/*,packages/shared/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backend Testing
|
||||||
|
|
||||||
|
Vitest + `app.inject()` (встроено в Fastify).
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Каждый route plugin → минимум 1 integration test
|
||||||
|
- Sync/DNS → parity fixtures
|
||||||
|
- `:memory:` SQLite для unit; file DB для integration
|
||||||
|
- `beforeEach` — fresh schema migrate
|
||||||
|
|
||||||
|
## Паттерн
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const app = await buildApp({ db: testDb })
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/health' })
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
```
|
||||||
|
|
||||||
|
См. [`vitest-best-practices.mdc`](vitest-best-practices.mdc).
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
description: Conventional commits на русском языке
|
||||||
|
globs: "**/*"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Commit messages (русский)
|
||||||
|
|
||||||
|
Формат: `<type>[optional scope]: <описание>`
|
||||||
|
|
||||||
|
## Типы
|
||||||
|
|
||||||
|
- `feat` — только новая UX-фича для пользователя
|
||||||
|
- `fix` — исправление бага
|
||||||
|
- `chore` — конфиг, зависимости, правила, CI
|
||||||
|
- `refactor` — рефакторинг без изменения поведения
|
||||||
|
- `docs` — документация
|
||||||
|
- `test` — тесты
|
||||||
|
- `perf` — производительность
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
- Subject в **императиве**, без точки в конце
|
||||||
|
- Subject и body — **на русском**
|
||||||
|
- Body (опционально) — что и зачем, не как
|
||||||
|
- Scope в скобках при необходимости: `feat(domains): добавить фильтр по статусу`
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(frontend): заменить raw table на shadcn Table на странице доменов
|
||||||
|
|
||||||
|
feat(certificates): добавить предупреждение об истечении срока
|
||||||
|
|
||||||
|
chore(rules): консолидировать правила shadcn/ui для Cursor
|
||||||
|
```
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
---
|
|
||||||
description: Паттерны для React, api, utils
|
|
||||||
globs: src/**/*.{js,jsx}
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# Frontend conventions
|
|
||||||
|
|
||||||
## React
|
|
||||||
|
|
||||||
- Функциональные компоненты
|
|
||||||
- Страницы в `pages/`, общие компоненты в `components/`
|
|
||||||
- Данные загружаются в App.jsx через `loadDataSet()`, передаются в страницы как `db` и `actions`
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
- `src/lib/api.js` — fetchApi, loadDataSet, createRecord, updateRecord, deleteRecord
|
|
||||||
- Коллекции: vps, providers, providerAccounts, payments, balanceLedger, settings
|
|
||||||
- Дополнительно: syncAccount, fetchAccountBalance, testApiConnection
|
|
||||||
|
|
||||||
## Utils
|
|
||||||
|
|
||||||
- `src/lib/utils.js` — форматирование (formatCurrency), конвертация валют, лейблы (paymentTypeLabel), CSV
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
description: shadcn/ui Monorepo — структура apps/web + packages/ui, CLI workflow, импорты @cfdm/ui
|
||||||
|
globs: apps/web/**/*,packages/ui/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend Monorepo (shadcn/ui)
|
||||||
|
|
||||||
|
**Обязательный стандарт структуры** — [Monorepo docs](https://ui.shadcn.com/docs/monorepo).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web/ # Vite SPA (routes, queries, domain components)
|
||||||
|
apps/api/ # Fastify API + static SPA in prod
|
||||||
|
packages/ui/ # @cfdm/ui — shadcn primitives
|
||||||
|
packages/shared/ # @cfdm/shared — Zod schemas, parse-fqdn
|
||||||
|
packages/db/ # @cfdm/db — Drizzle schema, repositories
|
||||||
|
```
|
||||||
|
|
||||||
|
## Два components.json
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|------|------------|
|
||||||
|
| [`apps/web/components.json`](apps/web/components.json) | App aliases; `ui` → `@cfdm/ui/components` |
|
||||||
|
| [`packages/ui/components.json`](packages/ui/components.json) | UI package aliases |
|
||||||
|
|
||||||
|
**Синхронизировать:** `style`, `iconLibrary`, `baseColor` в обоих файлах.
|
||||||
|
|
||||||
|
## CLI — только из apps/web
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
pnpm dlx shadcn@latest docs button
|
||||||
|
pnpm dlx shadcn@latest add button
|
||||||
|
pnpm dlx shadcn@latest add sidebar-07
|
||||||
|
pnpm dlx shadcn@latest add login-03
|
||||||
|
pnpm dlx shadcn@latest apply b2fA --only theme -y
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед обновлением существующих компонентов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dlx shadcn@latest add button --dry-run
|
||||||
|
pnpm dlx shadcn@latest add button --diff
|
||||||
|
pnpm dlx shadcn@latest info --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Куда CLI кладёт файлы
|
||||||
|
|
||||||
|
| Команда | Куда |
|
||||||
|
|---------|------|
|
||||||
|
| `add button` | `packages/ui/src/components/button.tsx` |
|
||||||
|
| `add login-03` | примитивы → `packages/ui`, block → `apps/web/src/components/` |
|
||||||
|
|
||||||
|
## Импорты
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
|
||||||
|
import '@cfdm/ui/globals.css' // только в main.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
| Запрещено | Разрешено |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `@/components/ui/*` | `@cfdm/ui/components/*` |
|
||||||
|
| `apps/web/src/components/ui/` | `packages/ui/src/components/` |
|
||||||
|
| Ручное редактирование `globals.css` | `pnpm dlx shadcn@latest apply b2fA --only theme` |
|
||||||
|
|
||||||
|
Community registry: переписывать импорты на `@cfdm/ui/...`.
|
||||||
|
|
||||||
|
## Разделение ответственности
|
||||||
|
|
||||||
|
- **`packages/ui`** — только output `shadcn add` (примитивы, registry hooks, `cn`)
|
||||||
|
- **`apps/web/src/components`** — blocks, layout, domain (`login-form`, `app-shell`, `PageHeader`)
|
||||||
|
|
||||||
|
## Стили (Tailwind v4 monorepo)
|
||||||
|
|
||||||
|
`packages/ui/src/styles/globals.css` — единственный CSS-файл. **Обязательно** `@source` для обоих workspace:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@source "../"; /* packages/ui/src */
|
||||||
|
@source "../../../apps/web/src"; /* apps/web/src */
|
||||||
|
```
|
||||||
|
|
||||||
|
Без `@source` Tailwind не видит классы из `packages/ui` и `apps/web` — UI ломается (нет sidebar, card, и т.д.).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm --filter web dev
|
||||||
|
pnpm --filter web build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Чеклист
|
||||||
|
|
||||||
|
- [ ] Два `components.json` согласованы
|
||||||
|
- [ ] `shadcn add` из `apps/web`
|
||||||
|
- [ ] UI-импорты через `@cfdm/ui/components/*`
|
||||||
|
- [ ] Нет `apps/web/src/components/ui/`
|
||||||
|
- [ ] `pnpm --filter web build` без ошибок
|
||||||
|
|
||||||
|
См. также: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`vite-tanstack-frontend.mdc`](vite-tanstack-frontend.mdc).
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
---
|
||||||
|
description: Frontend — ТОЛЬКО shadcn/ui docs (Components, Blocks, Installation); best practices, CLI-first
|
||||||
|
globs: apps/web/**/*,packages/ui/**/*
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend — shadcn/ui (обязательно)
|
||||||
|
|
||||||
|
**Источник истины — MCP shadcn + официальная документация.** Не выдумывать UI, не писать кастомный CSS, не обходить MCP и CLI.
|
||||||
|
|
||||||
|
Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workflow — [`shadcn-mcp.mdc`](shadcn-mcp.mdc). UI patterns — [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
|
||||||
|
|
||||||
|
| Документ | URL |
|
||||||
|
|----------|-----|
|
||||||
|
| **Components (primary catalog)** | https://ui.shadcn.com/docs/components |
|
||||||
|
| **Blocks** | https://ui.shadcn.com/blocks |
|
||||||
|
| **Installation** | https://ui.shadcn.com/docs/installation |
|
||||||
|
| **Monorepo** | https://ui.shadcn.com/docs/monorepo |
|
||||||
|
| **Theming** | https://ui.shadcn.com/docs/theming |
|
||||||
|
| **Dark Mode** | https://ui.shadcn.com/docs/dark-mode |
|
||||||
|
| **Forms (RHF)** | https://ui.shadcn.com/docs/forms/react-hook-form |
|
||||||
|
|
||||||
|
## Шаг 0 — перед любым UI-кодом
|
||||||
|
|
||||||
|
0. **Codegraph** `codegraph_explore` — найти существующие реализации
|
||||||
|
1. **MCP `plugin-shadcn-shadcn`:** `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items` ([`shadcn-mcp.mdc`](shadcn-mcp.mdc))
|
||||||
|
2. Открыть **Components** или **Blocks** — найти готовое решение
|
||||||
|
3. `cd apps/web && pnpm dlx shadcn@latest docs <component>` — сверить API с [Components](https://ui.shadcn.com/docs/components)
|
||||||
|
4. Сверить MCP examples ↔ docs API — только потом писать код
|
||||||
|
5. `cd apps/web && pnpm dlx shadcn@latest search "@shadcn/<query>"` — если MCP не дал результат
|
||||||
|
|
||||||
|
**Новая страница** → сначала [Blocks](https://ui.shadcn.com/blocks), потом `pnpm dlx shadcn@latest add <block-id>`.
|
||||||
|
|
||||||
|
## Docs workflow
|
||||||
|
|
||||||
|
1. MCP `plugin-shadcn-shadcn` — search → examples → add command
|
||||||
|
2. `pnpm dlx shadcn@latest docs <component>` — fetch URLs, сверить API
|
||||||
|
3. Skill `.agents/skills/shadcn/SKILL.md` — critical rules
|
||||||
|
4. Context7 — только TanStack / Recharts
|
||||||
|
|
||||||
|
## Shared components (обязательно)
|
||||||
|
|
||||||
|
| Component | Файл |
|
||||||
|
|-----------|------|
|
||||||
|
| `PageShell` | `page-shell.tsx` |
|
||||||
|
| `PageHeader` | `page-header.tsx` |
|
||||||
|
| `EmptyState` | `empty-state.tsx` |
|
||||||
|
| `QueryState` | `query-state.tsx` |
|
||||||
|
| `ConfirmDialog` | `confirm-dialog.tsx` |
|
||||||
|
| `DataTableCard` | `data-table-card.tsx` |
|
||||||
|
| `SectionCards` | `section-cards.tsx` |
|
||||||
|
| `StatusBadge` | `status-badge.tsx` |
|
||||||
|
| `FormSheet` | `form-sheet.tsx` |
|
||||||
|
| `FormField` | `form-field.tsx` |
|
||||||
|
| `TableCard` | `table-card.tsx` |
|
||||||
|
| `LoadingButton` | `loading-button.tsx` |
|
||||||
|
| `SectionCardsSkeleton` | `section-cards-skeleton.tsx` |
|
||||||
|
| `TableSkeleton` | `table-skeleton.tsx` |
|
||||||
|
|
||||||
|
**Overlay:** Sheet — forms; AlertDialog — destructive confirm.
|
||||||
|
|
||||||
|
## Шаг 1 — CLI (обязательно)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
pnpm dlx shadcn@latest add table select badge card field input button ...
|
||||||
|
pnpm dlx shadcn@latest add sidebar-07 # layout
|
||||||
|
pnpm dlx shadcn@latest add dashboard-01 # dashboard
|
||||||
|
pnpm dlx shadcn@latest add login-03 # auth
|
||||||
|
pnpm dlx shadcn@latest apply b2fA --only theme -y # тема — ТОЛЬКО так
|
||||||
|
```
|
||||||
|
|
||||||
|
- Копипаст с сайта **без** CLI — запрещено
|
||||||
|
- `packages/ui/src/components/*` — только registry; domain-логика → `apps/web/src/components/<name>.tsx`
|
||||||
|
|
||||||
|
## Шаг 2 — композиция (best practices)
|
||||||
|
|
||||||
|
### Приоритет
|
||||||
|
|
||||||
|
1. Установленный `@cfdm/ui/components/*`
|
||||||
|
2. Block из registry (адаптация под TanStack Router)
|
||||||
|
3. Shared проекта: `PageShell`, `PageHeader`, `EmptyState`, `QueryState`, `ConfirmDialog`, `DataTableCard`, `SectionCards`, `StatusBadge`
|
||||||
|
4. Domain-обёртка — последний уровень кастомизации
|
||||||
|
|
||||||
|
### Запрещено в apps/web
|
||||||
|
|
||||||
|
| ❌ | ✅ |
|
||||||
|
|----|---|
|
||||||
|
| `<table>`, `<select>`, `<hr>` | `Table`, `Select`, `Separator` из [Components](https://ui.shadcn.com/docs/components) |
|
||||||
|
| `bg-emerald-*`, `text-blue-500`, hex в className | `bg-primary`, `text-muted-foreground`, `Badge variant` |
|
||||||
|
| Ручной `globals.css`, `.css` модули | CLI `apply b2fA --only theme` |
|
||||||
|
| `space-y-*` / `space-x-*` | `flex` + `gap-*` |
|
||||||
|
| `w-10 h-10` | `size-10` |
|
||||||
|
| `className` для цветов Button/Badge | `variant`, `size` |
|
||||||
|
| `useState` для полей формы | `FieldGroup` + RHF + Zod |
|
||||||
|
| Styled `<Link>` | `Button variant="link"` + `render={<Link />}` |
|
||||||
|
| `inline style={{}}` в routes | layout Tailwind |
|
||||||
|
| `animate-pulse` div | `Skeleton` |
|
||||||
|
| кастомный toast | `sonner` → `toast()` |
|
||||||
|
| `@/components/ui/*` | `@cfdm/ui/components/*` |
|
||||||
|
|
||||||
|
### Устаревшие библиотеки (миграция с Tabler)
|
||||||
|
|
||||||
|
| ❌ удалить | ✅ заменить на |
|
||||||
|
|-----------|----------------|
|
||||||
|
| `@tabler/core` (CSS-фреймворк) | Tailwind v4 + shadcn tokens |
|
||||||
|
| `import '@tabler/core/dist/css/tabler.min.css'` в `main.tsx` | `import '@cfdm/ui/globals.css'` |
|
||||||
|
| `@tabler/icons-react` (`Icon*`) | `lucide-react` (`<PlusIcon />` и т.п.) |
|
||||||
|
| `chart.js` | `recharts` через shadcn `Chart`/`ChartContainer` |
|
||||||
|
| `react-router-dom` (`<BrowserRouter>`, `<Routes>`, `useNavigate`) | TanStack Router (`createFileRoute`, `<Link>`, `useNavigate`) |
|
||||||
|
| Bootstrap-классы Tabler (`page`, `navbar-vertical`, `nav-link`, `container-tight`, `spinner-border`, `d-lg-none`) | shadcn `AppShell` (sidebar-07), `Button`, `Skeleton` |
|
||||||
|
| Prop-drilling `db` + `actions` из `App.jsx` | `useQuery`/`useMutation` + key factories в `queries/` |
|
||||||
|
| `useState` + `loadDataSet()` в корне | `QueryClient` + route loaders (`ensureQueryData`) |
|
||||||
|
|
||||||
|
### Формы
|
||||||
|
|
||||||
|
По https://ui.shadcn.com/docs/forms/react-hook-form:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={!!errors.name}>
|
||||||
|
<FieldLabel htmlFor="name">Имя</FieldLabel>
|
||||||
|
<Input id="name" aria-invalid={!!errors.name} {...register('name')} />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<Button>
|
||||||
|
<PlusIcon data-icon="inline-start" />
|
||||||
|
Создать
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Без `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).
|
||||||
@@ -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 <component>` → сверить 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`
|
||||||
@@ -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
|
||||||
@@ -1,26 +1,81 @@
|
|||||||
---
|
---
|
||||||
description: Структура проекта vps-tracker и соглашения по именованию
|
description: Структура vps-tracker — pnpm monorepo (apps/web, apps/api, packages/ui, packages/shared, packages/db)
|
||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Структура проекта vps-tracker
|
# Структура проекта vps-tracker
|
||||||
|
|
||||||
## Папки
|
pnpm workspaces monorepo. Frontend — shadcn/ui + TanStack Router/Query + TS. Backend — Fastify + Drizzle + better-sqlite3 + TS.
|
||||||
|
|
||||||
- `server/` — Express backend, SQLite (sql.js)
|
## Layout
|
||||||
- `src/` — React frontend (Vite)
|
|
||||||
- `server/adapters/` — один адаптер на провайдера API (billmanager)
|
```
|
||||||
- `server/routes/` — Express роутеры по сущностям
|
vps-tracker/
|
||||||
- `server/db/` — схема, миграции, seed
|
├── 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)
|
- Файлы: kebab-case (`provider-accounts.ts`, `row-mappers.ts`)
|
||||||
- Роуты: `/api/vps`, `/api/provider-accounts`, `/api/sync/:accountId`
|
- Компоненты: PascalCase (`PageHeader.tsx`)
|
||||||
|
- Роуты API: `/api/vps`, `/api/provider-accounts`, `/api/sync/:accountId`
|
||||||
- ID записей: `vps-bm-{accountId}-{externalId}`, `pay-bm-{accountId}-{externalId}`
|
- ID записей: `vps-bm-{accountId}-{externalId}`, `pay-bm-{accountId}-{externalId}`
|
||||||
|
|
||||||
## Barrel exports
|
## Barrel exports
|
||||||
|
|
||||||
Модули с несколькими файлами экспортируют через `index.js`:
|
- `packages/ui` — `@cfdm/ui/components/*`, `@cfdm/ui/lib/utils`, `@cfdm/ui/hooks/*`, `@cfdm/ui/globals.css`
|
||||||
- `server/adapters/billmanager/index.js` — testConnection, syncFromBillmanager, fetchDashboardInfo
|
- `packages/shared` — `@cfdm/shared/contracts/*` (Zod), `@cfdm/shared/types/*`
|
||||||
- `server/db/index.js` — initDb, getDb, saveDb
|
- `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)
|
||||||
|
|||||||
@@ -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`
|
|
||||||
@@ -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 <component>` — сверить 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).
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
- `<Link>` для внутренней навигации, не `<a href>`
|
||||||
|
- 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` на `<Link>` → `queryClient.prefetchQuery(detailOptions(id))`
|
||||||
|
|
||||||
|
## DevTools
|
||||||
|
|
||||||
|
Только в dev: `TanStackRouterDevtools`, `ReactQueryDevtools`
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"cloudflare": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"claude-plugins-official/typescript-lsp": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-7
@@ -1,16 +1,15 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
**/node_modules
|
||||||
dist
|
dist
|
||||||
|
**/dist
|
||||||
data
|
data
|
||||||
.git
|
.git
|
||||||
.github
|
.github
|
||||||
.cursor
|
.cursor
|
||||||
eslint.config.js
|
**/*.log
|
||||||
*.md
|
coverage
|
||||||
!README.md
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
coverage
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ data
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
.pnpm-store
|
||||||
|
coverage
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
@@ -4,33 +4,47 @@
|
|||||||
|
|
||||||
VPS Tracker — приложение для учёта виртуальных серверов (VPS), провайдеров, аккаунтов, платежей и балансов. Поддерживает синхронизацию с BILLmanager 6 API.
|
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/
|
vps-tracker/
|
||||||
├── server/ # Express backend
|
├── apps/
|
||||||
│ ├── index.js # Точка входа, подключение роутов
|
│ ├── web/ # Vite SPA (TSX) — TanStack + shadcn/ui
|
||||||
│ ├── db/ # База данных (SQLite via sql.js)
|
│ │ └── src/
|
||||||
│ │ ├── schema.js # CREATE TABLE
|
│ │ ├── routes/ # file-based (TanStack Router)
|
||||||
│ │ ├── migrations.js # Миграции схемы
|
│ │ ├── queries/ # queryOptions + key factories
|
||||||
│ │ ├── seed.js # Начальные данные из public/data
|
│ │ ├── components/ # shared + layout + domain
|
||||||
│ │ └── index.js # initDb, getDb, saveDb
|
│ │ └── lib/ # api-client, queryClient, router, schemas
|
||||||
│ ├── adapters/ # Интеграции с внешними API
|
│ └── api/ # Fastify 5 API (TS)
|
||||||
│ │ └── billmanager/ # BILLmanager 6 API
|
│ └── src/
|
||||||
│ │ ├── client.js # HTTP-запросы
|
│ ├── routes/ # тонкие plugins
|
||||||
│ │ ├── parsers.js # Парсинг ответов API
|
│ ├── services/ # бизнес-логика + billmanager/
|
||||||
│ │ ├── mappers.js # Маппинг в модель vps-tracker
|
│ │ └── billmanager/ # client, parsers, mappers, operations, sync
|
||||||
│ │ ├── operations.js # fetchVds, fetchPayments и т.д.
|
│ └── plugins/ # @fastify/* registration
|
||||||
│ │ ├── sync.js # syncFromBillmanager
|
├── packages/
|
||||||
│ │ └── index.js # Barrel export
|
│ ├── ui/ # @cfdm/ui — shadcn primitives
|
||||||
│ ├── routes/ # Express роутеры
|
│ │ └── src/
|
||||||
│ ├── utils/ # row-mappers и др.
|
│ │ ├── components/ # output `shadcn add` (не трогать под кейс)
|
||||||
│ └── sync-scheduler.js # Планировщик синка
|
│ │ ├── hooks/ # use-mobile и др.
|
||||||
├── src/ # React frontend (Vite)
|
│ │ ├── lib/utils.ts # cn()
|
||||||
│ ├── pages/ # Страницы приложения
|
│ │ └── styles/globals.css # только output `shadcn apply --only theme`
|
||||||
│ ├── components/ # UI-компоненты
|
│ ├── shared/ # @cfdm/shared — Zod-контракты, общие типы
|
||||||
│ └── lib/ # api.js, utils.js
|
│ └── db/ # @cfdm/db — Drizzle schema, repositories, миграции
|
||||||
└── public/data/ # JSON для seed (providers, vps, payments...)
|
│ └── 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`
|
- **Sync (BILLmanager)** — `apps/api/src/services/billmanager/`, `apps/api/src/routes/sync.ts`, scheduler в `apps/api/src/services/`
|
||||||
- **Тарифы** — `server/adapters/billmanager/operations.js` (fetchVdsOrderPricelist), `server/adapters/billmanager/sync.js`
|
- **Тарифы** — `apps/api/src/services/billmanager/operations.ts` (fetchVdsOrderPricelist), `apps/api/src/services/billmanager/sync.ts`
|
||||||
- **VPS CRUD** — `server/routes/vps.js`
|
- **VPS CRUD** — `apps/api/src/routes/vps.ts`, `packages/db/src/repositories/vps.ts`
|
||||||
- **Платежи/баланс** — `server/routes/payments.js`, `server/routes/balance-ledger.js`
|
- **Платежи/баланс** — `apps/api/src/routes/payments.ts`, `apps/api/src/routes/balance-ledger.ts`
|
||||||
- **Курсы валют** — `src/lib/utils.js` (convertCurrency, formatInBaseCurrency), настройки в settings.ratesUrl
|
- **Курсы валют** — `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
|
## BILLmanager API
|
||||||
|
|
||||||
@@ -60,3 +76,21 @@ vps-tracker/
|
|||||||
- [Payments API](https://www.ispsystem.com/docs/b6c/developer-section/billmanager-api/payments-payment)
|
- [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`
|
Формат запроса: `?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)
|
||||||
|
|||||||
+61
-24
@@ -1,35 +1,72 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# Stage 1: Сборка фронтенда
|
ARG NODE_IMAGE=node:22-alpine
|
||||||
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
|
|
||||||
|
|
||||||
# Stage 2: Production dependencies + очистка node_modules
|
############################
|
||||||
FROM node:22-alpine AS deps
|
# Stage 1: build web + api
|
||||||
|
############################
|
||||||
|
FROM ${NODE_IMAGE} AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
RUN corepack enable
|
||||||
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && \
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
|
||||||
find node_modules -type f \( \
|
COPY apps/web/package.json ./apps/web/
|
||||||
-name '*.md' -o -name '*.ts' -o -name '*.map' -o \
|
COPY apps/api/package.json ./apps/api/
|
||||||
-name 'LICENSE*' -o -name 'CHANGELOG*' -o -name 'README*' -o \
|
COPY packages/ui/package.json ./packages/ui/
|
||||||
-name '.npmignore' -o -name '.eslintrc*' -o -name '.travis.yml' -o \
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
-name 'Makefile' -o -name '*.gyp' -o -name '*.gypi' \
|
COPY packages/db/package.json ./packages/db/
|
||||||
\) -delete && \
|
RUN --mount=type=cache,target=/root/.local/share/pnpm pnpm install --frozen-lockfile
|
||||||
find node_modules -type d -empty -delete
|
|
||||||
|
|
||||||
# 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
|
FROM alpine:3.21
|
||||||
RUN apk add --no-cache nodejs
|
RUN apk add --no-cache nodejs
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3001
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||||
COPY server ./server
|
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
|
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"]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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' } })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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' } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 }))
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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' } })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>vps-tracker</title>
|
<title>VPS Tracker</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -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 (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger render={trigger} />
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant={destructive ? 'destructive' : 'default'}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
key: string
|
||||||
|
header: ReactNode
|
||||||
|
cell: (row: T, index: number) => ReactNode
|
||||||
|
className?: string
|
||||||
|
headerClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableCardProps<T> {
|
||||||
|
title?: ReactNode
|
||||||
|
description?: ReactNode
|
||||||
|
actions?: ReactNode
|
||||||
|
columns: DataTableColumn<T>[]
|
||||||
|
data: T[]
|
||||||
|
rowKey: (row: T, index: number) => string
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
|
emptyAction?: ReactNode
|
||||||
|
onRowClick?: (row: T) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableCard<T>({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
rowKey,
|
||||||
|
emptyTitle = 'Нет записей',
|
||||||
|
emptyDescription,
|
||||||
|
emptyAction,
|
||||||
|
onRowClick,
|
||||||
|
}: DataTableCardProps<T>) {
|
||||||
|
return (
|
||||||
|
<TableCard title={title} description={description} actions={actions}>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<TableHead key={col.key} className={col.headerClassName}>
|
||||||
|
{col.header}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((row, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={rowKey(row, index)}
|
||||||
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
|
className={onRowClick ? 'cursor-pointer' : undefined}
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<TableCell key={col.key} className={col.className}>
|
||||||
|
{col.cell(row, index)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</TableCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, number>()
|
||||||
|
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 (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Расходы по хостерам (мес)</CardTitle>
|
||||||
|
<CardDescription>Топ-10 по monthly rate, в {baseCurrency}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={EXPENSE_CONFIG} className="h-72 w-full">
|
||||||
|
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
|
<YAxis tickLine={false} axisLine={false} width={48} />
|
||||||
|
<RechartsTooltip cursor={false} content={<ChartTooltipContent formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
|
||||||
|
<Bar dataKey="expense" fill="var(--color-expense)" radius={4} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, number>()
|
||||||
|
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 (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Платежи по типам</CardTitle>
|
||||||
|
<CardDescription>Структура в {baseCurrency}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={PAYMENTS_CONFIG} className="mx-auto h-72 w-full">
|
||||||
|
<PieChart>
|
||||||
|
<RechartsTooltip content={<ChartTooltipContent nameKey="type" formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
|
||||||
|
<Pie data={data} dataKey="amount" nameKey="type" innerRadius={50} outerRadius={90} strokeWidth={2}>
|
||||||
|
{data.map((_, i) => (
|
||||||
|
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, number>()
|
||||||
|
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 (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Динамика платежей</CardTitle>
|
||||||
|
<CardDescription>Последние 12 месяцев, {baseCurrency}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={trendConfig} className="h-72 w-full">
|
||||||
|
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
|
<YAxis tickLine={false} axisLine={false} width={48} />
|
||||||
|
<RechartsTooltip cursor={false} content={<ChartTooltipContent formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
|
||||||
|
<Bar dataKey="amount" fill="var(--color-amount)" radius={4} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartsGrid({ children }: { children: ReactNode }) {
|
||||||
|
return <div className="grid gap-4 lg:grid-cols-2">{children}</div>
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">{title}</p>
|
||||||
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{action ? <div className="mt-2">{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Field data-invalid={invalid || Boolean(error)}>
|
||||||
|
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
|
||||||
|
{children}
|
||||||
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
|
{error ? <FieldError>{error}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<TField extends FieldValues> {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
schema: ZodType<TField>
|
||||||
|
defaultValues: DefaultValues<TField>
|
||||||
|
onSubmit: (values: TField) => void
|
||||||
|
submitting?: boolean
|
||||||
|
submitLabel?: string
|
||||||
|
children: (form: UseFormReturn<TField>) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormSheetRhf<TField extends FieldValues>({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
schema,
|
||||||
|
defaultValues,
|
||||||
|
onSubmit,
|
||||||
|
submitting,
|
||||||
|
submitLabel,
|
||||||
|
children,
|
||||||
|
}: FormSheetRhfProps<TField>) {
|
||||||
|
const form = useForm<TField>({
|
||||||
|
resolver: zodResolver(schema) as never,
|
||||||
|
defaultValues: defaultValues as DefaultValues<TField>,
|
||||||
|
mode: 'onBlur',
|
||||||
|
})
|
||||||
|
|
||||||
|
const submit: SubmitHandler<TField> = (values) => onSubmit(values)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) form.reset()
|
||||||
|
onOpenChange(o)
|
||||||
|
}}
|
||||||
|
trigger={null}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
submitLabel={submitLabel}
|
||||||
|
submitting={submitting}
|
||||||
|
onSubmit={() => void form.handleSubmit(submit)()}
|
||||||
|
>
|
||||||
|
{children(form)}
|
||||||
|
</FormSheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
{trigger ? <SheetTrigger render={trigger} /> : null}
|
||||||
|
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{title}</SheetTitle>
|
||||||
|
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||||
|
</SheetHeader>
|
||||||
|
<form
|
||||||
|
className="flex flex-1 flex-col gap-4 overflow-y-auto p-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{onSubmit ? (
|
||||||
|
<SheetFooter className="mt-auto pt-4">
|
||||||
|
<LoadingButton type="submit" loading={submitting} disabled={submitDisabled}>
|
||||||
|
{submitLabel}
|
||||||
|
</LoadingButton>
|
||||||
|
</SheetFooter>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, string> = 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 (
|
||||||
|
<SidebarProvider>
|
||||||
|
<Sidebar collapsible="icon">
|
||||||
|
<SidebarHeader>
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||||
|
<Server className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||||
|
<span className="font-semibold">VPS Tracker</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Учёт виртуальных серверов</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Меню</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={item.to}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
render={<Link to={item.to} />}
|
||||||
|
isActive={isActive}
|
||||||
|
tooltip={item.label}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter />
|
||||||
|
</Sidebar>
|
||||||
|
<SidebarInset>
|
||||||
|
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-supports">
|
||||||
|
<SidebarTrigger />
|
||||||
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? ''}</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
</header>
|
||||||
|
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<HTMLButtonElement> & {
|
||||||
|
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 (
|
||||||
|
<Button disabled={disabled || loading} {...props}>
|
||||||
|
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
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<T>({
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
empty,
|
||||||
|
emptyTitle = 'Нет данных',
|
||||||
|
emptyDescription,
|
||||||
|
emptyAction,
|
||||||
|
onRetry,
|
||||||
|
skeleton,
|
||||||
|
children,
|
||||||
|
}: QueryStateProps<T>) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <>{skeleton ?? <DefaultSkeleton />}</>
|
||||||
|
}
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<AlertCircle className="size-8" />}
|
||||||
|
title="Ошибка загрузки"
|
||||||
|
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
||||||
|
action={
|
||||||
|
onRetry ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||||
|
<RefreshCwIcon data-icon="inline-start" />
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (empty || data == null) {
|
||||||
|
return <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||||
|
}
|
||||||
|
return <>{children(data)}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DefaultSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-4', className)}>
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<Card key={typeof item.label === 'string' ? item.label : idx} className="gap-0">
|
||||||
|
<CardContent className="flex flex-col gap-1 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">{item.label}</span>
|
||||||
|
{item.icon ? <span className="text-muted-foreground">{item.icon}</span> : null}
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-semibold tabular-nums">{item.value}</span>
|
||||||
|
{item.hint ? <span className="text-xs text-muted-foreground">{item.hint}</span> : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<SectionCards
|
||||||
|
items={Array.from({ length: count }, (_, i) => ({
|
||||||
|
label: <Skeleton className="h-4 w-24" key={`label-${i}`} />,
|
||||||
|
value: <Skeleton className="h-7 w-20" key={`value-${i}`} />,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||||
|
return (
|
||||||
|
<Card className="gap-0">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex gap-2 border-b p-3">
|
||||||
|
{Array.from({ length: cols }).map((_, i) => (
|
||||||
|
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: rows }).map((_, r) => (
|
||||||
|
<div className="flex gap-2 border-b p-3" key={`r-${r}`}>
|
||||||
|
{Array.from({ length: cols }).map((_, c) => (
|
||||||
|
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
|
||||||
|
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||||
|
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 <Badge variant={variant}>{label ?? status}</Badge>
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card className={cn('gap-0', className)}>
|
||||||
|
{(title || actions) && (
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||||
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent className={cn('p-0', contentClassName)}>{children}</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
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<CollectionName, string> = {
|
||||||
|
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<DataSnapshot>('/api/data'),
|
||||||
|
fetchCollection: <T>(name: CollectionName) => fetchApi<T[]>(COLLECTION_PATHS[name]),
|
||||||
|
|
||||||
|
create: <T extends { id?: string }>(name: CollectionName, record: T) =>
|
||||||
|
fetchApi<T[]>(COLLECTION_PATHS[name], {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ...record, id: record.id || uid() }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: <T>(name: CollectionName, id: string, patch: Partial<T>) =>
|
||||||
|
fetchApi<T[]>(`${COLLECTION_PATHS[name]}/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
}),
|
||||||
|
|
||||||
|
remove: <T>(name: CollectionName, id: string) =>
|
||||||
|
fetchApi<T[]>(`${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<string, unknown> = {}) =>
|
||||||
|
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<string[]>(`/api/projects/suggest?${params.toString()}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadBackupJson: async (): Promise<Blob> => {
|
||||||
|
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<Blob> => {
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ProviderAccount, Provider } from '@/types/entities'
|
||||||
|
|
||||||
|
export function providerByIdMap(providers: Provider[]): Map<string, Provider> {
|
||||||
|
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<string, Provider>,
|
||||||
|
scopedProviderId?: string,
|
||||||
|
): string {
|
||||||
|
const name = account.name?.trim() || '—'
|
||||||
|
if (scopedProviderId) return name
|
||||||
|
const providerName = providerById.get(account.providerId)?.name ?? '—'
|
||||||
|
return `${providerName} / ${name}`
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
active: 'Активен', paused: 'Приостановлен', archived: 'Архив',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vpsStatusLabel(status: string): string {
|
||||||
|
return VPS_STATUS_LABELS[status] ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
const BILLING_MODE_LABELS: Record<string, string> = {
|
||||||
|
daily: 'Ежедневно', monthly: 'Ежемесячно',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function billingModeLabel(mode: string): string {
|
||||||
|
return BILLING_MODE_LABELS[mode] ?? mode
|
||||||
|
}
|
||||||
|
|
||||||
|
const TARIFF_TYPE_LABELS: Record<string, string> = {
|
||||||
|
daily: 'Суточный', monthly: 'Месячный',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tariffTypeLabel(type: string): string {
|
||||||
|
return TARIFF_TYPE_LABELS[type] ?? type
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENCY_SYMBOL_MAP: Record<string, string> = {
|
||||||
|
'€': '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<string, unknown>
|
||||||
|
if (p.base && p.rates && typeof p.rates === 'object') return p as unknown as RatesData
|
||||||
|
const valute = p.Valute as Record<string, { CharCode?: string; Value?: number; Nominal?: number }> | undefined
|
||||||
|
if (valute && typeof valute === 'object') {
|
||||||
|
const rates: Record<string, number> = {}
|
||||||
|
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<string, number> = { ...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<Settings> = 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<Settings> = 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, unknown>[]): 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)
|
||||||
|
}
|
||||||
@@ -1,119 +1,119 @@
|
|||||||
|
import type {
|
||||||
|
Vps,
|
||||||
|
ProviderAccount,
|
||||||
|
Provider,
|
||||||
|
Payment,
|
||||||
|
BalanceLedgerRow,
|
||||||
|
SyncLogRow,
|
||||||
|
SyncSummary,
|
||||||
|
} from '@/types/entities'
|
||||||
import { getPaidUntilDate } from './paid-until'
|
import { getPaidUntilDate } from './paid-until'
|
||||||
|
|
||||||
const STALE_SYNC_HOURS = 48
|
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 cur = (account.balance_currency || account.currency || '').trim()
|
||||||
const rows = balanceLedger.filter((row) => row.providerAccountId === account.id)
|
const rows = balanceLedger.filter((row) => row.providerAccountId === account.id)
|
||||||
if (!cur) return rows
|
if (!cur) return rows
|
||||||
return rows.filter((row) => !row.currency || row.currency === cur)
|
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 filtered = ledgerRowsInAccountCurrency(account, balanceLedger)
|
||||||
const credits = filtered
|
const credits = filtered.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||||
.filter((row) => row.direction === 'credit')
|
const debits = filtered.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||||
.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)
|
|
||||||
return credits - debits
|
return credits - debits
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function accountHasApiLedgerMismatch(
|
||||||
* Сравниваем баланс из API с суммой по balance_ledger.
|
account: ProviderAccount,
|
||||||
* Если в ledger нет ни одной строки по аккаунту (в валюте баланса) — не считаем расхождением:
|
balanceLedger: BalanceLedgerRow[],
|
||||||
* пользователь видит только API, а «0 из ledger» — не противоречие, а отсутствие учёта.
|
): boolean {
|
||||||
*/
|
|
||||||
export function accountHasApiLedgerMismatch(account, balanceLedger) {
|
|
||||||
if (account.balance_api == null || !Number.isFinite(Number(account.balance_api))) return false
|
if (account.balance_api == null || !Number.isFinite(Number(account.balance_api))) return false
|
||||||
const rows = ledgerRowsInAccountCurrency(account, balanceLedger)
|
const rows = ledgerRowsInAccountCurrency(account, balanceLedger)
|
||||||
if (rows.length === 0) return false
|
if (rows.length === 0) return false
|
||||||
const ledger = ledgerBalanceInCurrency(account, balanceLedger)
|
const ledger = ledgerBalanceInCurrency(account, balanceLedger)
|
||||||
if (!Number.isFinite(ledger)) return false
|
if (!Number.isFinite(ledger)) return false
|
||||||
const api = Number(account.balance_api)
|
const apiBalance = Number(account.balance_api)
|
||||||
const diff = Math.abs(api - ledger)
|
const diff = Math.abs(apiBalance - ledger)
|
||||||
const tol = Math.max(10, Math.abs(api) * 0.05)
|
const tol = Math.max(10, Math.abs(apiBalance) * 0.05)
|
||||||
return diff > tol
|
return diff > tol
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lastOkSyncFinishedAt(accountId, syncLog) {
|
export function lastOkSyncFinishedAt(
|
||||||
const rows = (syncLog || []).filter(
|
accountId: string,
|
||||||
(r) => r.accountId === accountId && r.status === 'ok' && r.finishedAt,
|
syncLog: SyncLogRow[] = [],
|
||||||
)
|
): number | null {
|
||||||
let best = null
|
const rows = syncLog.filter((r) => r.accountId === accountId && r.status === 'ok' && r.finishedAt)
|
||||||
|
let best: number | null = null
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const t = new Date(r.finishedAt).getTime()
|
const t = new Date(r.finishedAt as string).getTime()
|
||||||
if (!Number.isNaN(t) && (!best || t > best)) best = t
|
if (!Number.isNaN(t) && (best == null || t > best)) best = t
|
||||||
}
|
}
|
||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface InventoryIssue {
|
||||||
* @param {{
|
key: string
|
||||||
* vps: object[],
|
title: string
|
||||||
* providerAccounts: object[],
|
count: number
|
||||||
* providers: object[],
|
to: string
|
||||||
* payments: object[],
|
hint?: string
|
||||||
* balanceLedger: object[],
|
}
|
||||||
* syncLog?: object[],
|
|
||||||
* }} input
|
export interface InventoryHealthInput {
|
||||||
*/
|
vps: Vps[]
|
||||||
export function computeInventoryHealth(input) {
|
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 { vps, providerAccounts, providers = [], payments, balanceLedger, syncLog = [] } = input
|
||||||
const providerById = new Map(providers.map((p) => [p.id, p]))
|
const providerById = new Map(providers.map((p) => [p.id, p]))
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
const ctx = { vps, providerAccounts, payments, balanceLedger, now }
|
const ctx = { vps, providerAccounts, payments, balanceLedger, now }
|
||||||
|
|
||||||
/** @type {{ key: string, title: string, count: number, to: string, hint?: string }[]} */
|
const issues: InventoryIssue[] = []
|
||||||
const issues = []
|
|
||||||
|
|
||||||
const noProject = vps.filter((v) => v.status === 'active' && !(v.project || '').trim())
|
const noProject = vps.filter((v) => v.status === 'active' && !(v.project || '').trim())
|
||||||
if (noProject.length) {
|
if (noProject.length) {
|
||||||
issues.push({
|
issues.push({ key: 'no-project', title: 'Активные VPS без проекта', count: noProject.length, to: '/vps?health=no-project' })
|
||||||
key: 'no-project',
|
|
||||||
title: 'Активные VPS без проекта',
|
|
||||||
count: noProject.length,
|
|
||||||
to: '/vps?health=no-project',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const noRate = vps.filter((v) => {
|
const noRate = vps.filter((v) => {
|
||||||
if (v.status !== 'active') return false
|
if (v.status !== 'active') return false
|
||||||
const dr = Number(v.dailyRate || 0)
|
const dr = Number(v.dailyRate || 0)
|
||||||
const mr = Number(v.monthlyRate || 0)
|
const mr = Number(v.monthlyRate || 0)
|
||||||
const noMoney =
|
const noMoney = (!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0)
|
||||||
(!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0)
|
|
||||||
const noCur = !(v.currency || '').trim()
|
const noCur = !(v.currency || '').trim()
|
||||||
return noMoney || noCur
|
return noMoney || noCur
|
||||||
})
|
})
|
||||||
if (noRate.length) {
|
if (noRate.length) {
|
||||||
issues.push({
|
issues.push({ key: 'no-rate', title: 'Нет ставки или валюты', count: noRate.length, to: '/vps?health=no-rate' })
|
||||||
key: 'no-rate',
|
|
||||||
title: 'Нет ставки или валюты',
|
|
||||||
count: noRate.length,
|
|
||||||
to: '/vps?health=no-rate',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const paidOverdue = vps.filter((v) => {
|
const paidOverdue = vps.filter((v) => {
|
||||||
if (v.status !== 'active') return false
|
if (v.status !== 'active') return false
|
||||||
const d = getPaidUntilDate(v, ctx)
|
const d = getPaidUntilDate(v, ctx)
|
||||||
return d && d < todayStart
|
return d != null && d < todayStart
|
||||||
})
|
})
|
||||||
if (paidOverdue.length) {
|
if (paidOverdue.length) {
|
||||||
issues.push({
|
issues.push({ key: 'paid-overdue', title: 'Просрочена оплата (оценка)', count: paidOverdue.length, to: '/vps?health=paid-overdue' })
|
||||||
key: 'paid-overdue',
|
|
||||||
title: 'Просрочена оплата (оценка)',
|
|
||||||
count: paidOverdue.length,
|
|
||||||
to: '/vps?health=paid-overdue',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bmAccounts = providerAccounts.filter((a) => {
|
const bmAccounts = providerAccounts.filter((a) => {
|
||||||
const p = providerById.get(a.providerId)
|
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 staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
||||||
const staleAccounts = bmAccounts.filter((a) => {
|
const staleAccounts = bmAccounts.filter((a) => {
|
||||||
@@ -145,16 +145,16 @@ export function computeInventoryHealth(input) {
|
|||||||
return issues
|
return issues
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function getStaleSyncAccountIds(
|
||||||
* @param {object[]} providerAccounts
|
providerAccounts: ProviderAccount[],
|
||||||
* @param {object[]} providers
|
providers: Provider[],
|
||||||
* @param {object[]} syncLog
|
syncLog: SyncLogRow[] = [],
|
||||||
*/
|
now = new Date(),
|
||||||
export function getStaleSyncAccountIds(providerAccounts, providers, syncLog, now = new Date()) {
|
): string[] {
|
||||||
const providerById = new Map(providers.map((p) => [p.id, p]))
|
const providerById = new Map(providers.map((p) => [p.id, p]))
|
||||||
const bmAccounts = providerAccounts.filter((a) => {
|
const bmAccounts = providerAccounts.filter((a) => {
|
||||||
const p = providerById.get(a.providerId)
|
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 staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
||||||
return bmAccounts
|
return bmAccounts
|
||||||
@@ -166,21 +166,17 @@ export function getStaleSyncAccountIds(providerAccounts, providers, syncLog, now
|
|||||||
.map((a) => a.id)
|
.map((a) => a.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function getBalanceMismatchAccountIds(
|
||||||
* @param {object[]} providerAccounts
|
providerAccounts: ProviderAccount[],
|
||||||
* @param {object[]} balanceLedger
|
balanceLedger: BalanceLedgerRow[],
|
||||||
*/
|
): string[] {
|
||||||
export function getBalanceMismatchAccountIds(providerAccounts, balanceLedger) {
|
|
||||||
return providerAccounts.filter((a) => accountHasApiLedgerMismatch(a, balanceLedger)).map((a) => a.id)
|
return providerAccounts.filter((a) => accountHasApiLedgerMismatch(a, balanceLedger)).map((a) => a.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function formatSyncSummaryLine(summary: SyncSummary | null | undefined): string {
|
||||||
* @param {object|null} summary
|
|
||||||
*/
|
|
||||||
export function formatSyncSummaryLine(summary) {
|
|
||||||
if (!summary || typeof summary !== 'object') return ''
|
if (!summary || typeof summary !== 'object') return ''
|
||||||
if (summary.error) return String(summary.error)
|
if (summary.error) return String(summary.error)
|
||||||
const parts = []
|
const parts: string[] = []
|
||||||
if (summary.added?.length) parts.push(`+${summary.added.length} VPS`)
|
if (summary.added?.length) parts.push(`+${summary.added.length} VPS`)
|
||||||
if (summary.updated?.length) parts.push(`~${summary.updated.length} изм.`)
|
if (summary.updated?.length) parts.push(`~${summary.updated.length} изм.`)
|
||||||
if (summary.paymentsAdded) parts.push(`+${summary.paymentsAdded} платежей`)
|
if (summary.paymentsAdded) parts.push(`+${summary.paymentsAdded} платежей`)
|
||||||
@@ -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)
|
||||||
@@ -1,28 +1,29 @@
|
|||||||
/**
|
import type { Vps, ProviderAccount, Payment, BalanceLedgerRow } from '@/types/entities'
|
||||||
* Дата «оплачено до» для VPS (как на дашборде).
|
|
||||||
*/
|
|
||||||
|
|
||||||
function getAccountBalance(accountId, providerAccounts, balanceLedger) {
|
function getAccountBalance(
|
||||||
|
accountId: string,
|
||||||
|
providerAccounts: ProviderAccount[],
|
||||||
|
balanceLedger: BalanceLedgerRow[],
|
||||||
|
): number {
|
||||||
const account = providerAccounts.find((a) => a.id === accountId)
|
const account = providerAccounts.find((a) => a.id === accountId)
|
||||||
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
|
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
|
||||||
return Number(account.balance_api)
|
return Number(account.balance_api)
|
||||||
}
|
}
|
||||||
const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId)
|
const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId)
|
||||||
const credits = ledgerRows
|
const credits = ledgerRows.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||||
.filter((row) => row.direction === 'credit')
|
const debits = ledgerRows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||||
.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)
|
|
||||||
return credits - debits
|
return credits - debits
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface PaidUntilContext {
|
||||||
* @param {object} item - VPS
|
vps: Vps[]
|
||||||
* @param {{ vps: object[], providerAccounts: object[], payments: object[], balanceLedger: object[], now?: Date }} ctx
|
providerAccounts: ProviderAccount[]
|
||||||
* @returns {Date|null}
|
payments: Payment[]
|
||||||
*/
|
balanceLedger: BalanceLedgerRow[]
|
||||||
export function getPaidUntilDate(item, ctx) {
|
now?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaidUntilDate(item: Vps, ctx: PaidUntilContext): Date | null {
|
||||||
const { vps, providerAccounts, payments, balanceLedger, now = new Date() } = ctx
|
const { vps, providerAccounts, payments, balanceLedger, now = new Date() } = ctx
|
||||||
if (item.status !== 'active') return null
|
if (item.status !== 'active') return null
|
||||||
const account = providerAccounts.find((a) => a.id === item.providerAccountId)
|
const account = providerAccounts.find((a) => a.id === item.providerAccountId)
|
||||||
@@ -37,19 +38,16 @@ export function getPaidUntilDate(item, ctx) {
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
const isPaidUntilNextDay =
|
const isPaidUntilNextDay =
|
||||||
paidUntilFromApi &&
|
paidUntilFromApi != null &&
|
||||||
(() => {
|
(() => {
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
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))
|
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
|
||||||
return diffDays >= 0 && diffDays <= 2
|
return diffDays >= 0 && diffDays <= 2
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
|
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
|
||||||
|
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
|
||||||
if (!shouldCalculateFromBalance && paidUntilFromApi) {
|
|
||||||
return paidUntilFromApi
|
|
||||||
}
|
|
||||||
|
|
||||||
const dailyRate = Number(item.dailyRate || 0)
|
const dailyRate = Number(item.dailyRate || 0)
|
||||||
const monthlyRate = Number(item.monthlyRate || 0)
|
const monthlyRate = Number(item.monthlyRate || 0)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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<typeof createRouter>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRouter(opts?: { context?: { queryClient: QueryClient } }) {
|
||||||
|
return tanstackCreateRouter({
|
||||||
|
routeTree,
|
||||||
|
context: opts?.context ?? { queryClient },
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
scrollRestoration: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { rootRouteId }
|
||||||
@@ -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<typeof providerSchema>
|
||||||
|
export type ProviderAccountFormValues = z.infer<typeof providerAccountSchema>
|
||||||
|
export type VpsFormValues = z.infer<typeof vpsSchema>
|
||||||
|
export type PaymentFormValues = z.infer<typeof paymentSchema>
|
||||||
|
export type BalanceLedgerFormValues = z.infer<typeof balanceLedgerSchema>
|
||||||
|
export type SettingsFormValues = z.infer<typeof settingsSchema>
|
||||||
@@ -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(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -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<ReturnType<typeof api.fetchData>>
|
||||||
|
export { queryClient }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user