init commit
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
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,26 @@
|
|||||||
|
---
|
||||||
|
description: Структура проекта vps-tracker и соглашения по именованию
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Структура проекта vps-tracker
|
||||||
|
|
||||||
|
## Папки
|
||||||
|
|
||||||
|
- `server/` — Express backend, SQLite (sql.js)
|
||||||
|
- `src/` — React frontend (Vite)
|
||||||
|
- `server/adapters/` — один адаптер на провайдера API (billmanager)
|
||||||
|
- `server/routes/` — Express роутеры по сущностям
|
||||||
|
- `server/db/` — схема, миграции, seed
|
||||||
|
|
||||||
|
## Именование
|
||||||
|
|
||||||
|
- Файлы: kebab-case (provider-accounts.js, row-mappers.js)
|
||||||
|
- Роуты: `/api/vps`, `/api/provider-accounts`, `/api/sync/:accountId`
|
||||||
|
- ID записей: `vps-bm-{accountId}-{externalId}`, `pay-bm-{accountId}-{externalId}`
|
||||||
|
|
||||||
|
## Barrel exports
|
||||||
|
|
||||||
|
Модули с несколькими файлами экспортируют через `index.js`:
|
||||||
|
- `server/adapters/billmanager/index.js` — testConnection, syncFromBillmanager, fetchDashboardInfo
|
||||||
|
- `server/db/index.js` — initDb, getDb, saveDb
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
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`
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# VPS Tracker — Руководство для ИИ
|
||||||
|
|
||||||
|
## Описание проекта
|
||||||
|
|
||||||
|
VPS Tracker — приложение для учёта виртуальных серверов (VPS), провайдеров, аккаунтов, платежей и балансов. Поддерживает синхронизацию с BILLmanager 6 API.
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
vps-tracker/
|
||||||
|
├── server/ # Express backend
|
||||||
|
│ ├── index.js # Точка входа, подключение роутов
|
||||||
|
│ ├── db/ # База данных (SQLite via sql.js)
|
||||||
|
│ │ ├── schema.js # CREATE TABLE
|
||||||
|
│ │ ├── migrations.js # Миграции схемы
|
||||||
|
│ │ ├── seed.js # Начальные данные из public/data
|
||||||
|
│ │ └── index.js # initDb, getDb, saveDb
|
||||||
|
│ ├── adapters/ # Интеграции с внешними API
|
||||||
|
│ │ └── billmanager/ # BILLmanager 6 API
|
||||||
|
│ │ ├── client.js # HTTP-запросы
|
||||||
|
│ │ ├── parsers.js # Парсинг ответов API
|
||||||
|
│ │ ├── mappers.js # Маппинг в модель vps-tracker
|
||||||
|
│ │ ├── operations.js # fetchVds, fetchPayments и т.д.
|
||||||
|
│ │ ├── sync.js # syncFromBillmanager
|
||||||
|
│ │ └── index.js # Barrel export
|
||||||
|
│ ├── routes/ # Express роутеры
|
||||||
|
│ ├── utils/ # row-mappers и др.
|
||||||
|
│ └── sync-scheduler.js # Планировщик синка
|
||||||
|
├── src/ # React frontend (Vite)
|
||||||
|
│ ├── pages/ # Страницы приложения
|
||||||
|
│ ├── components/ # UI-компоненты
|
||||||
|
│ └── lib/ # api.js, utils.js
|
||||||
|
└── public/data/ # JSON для seed (providers, vps, payments...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Основные сущности
|
||||||
|
|
||||||
|
| Сущность | Описание |
|
||||||
|
|----------|----------|
|
||||||
|
| **providers** | Хостинг-провайдеры (Selectel, Firstbyte и т.д.) |
|
||||||
|
| **provider_accounts** | Аккаунты у провайдера, могут иметь apiType=billmanager для синка |
|
||||||
|
| **vps** | Виртуальные серверы (ip, ram, disk, tariffType, paidUntil) |
|
||||||
|
| **payments** | Платежи (пополнение баланса, оплата VPS) |
|
||||||
|
| **balance_ledger** | Движения по балансу |
|
||||||
|
| **active_tariffs** | Тарифы, загруженные из BILLmanager vds.order |
|
||||||
|
| **settings** | Настройки приложения (baseCurrency, ratesUrl, syncEnabled) |
|
||||||
|
|
||||||
|
## Где искать код по доменам
|
||||||
|
|
||||||
|
- **Sync (BILLmanager)** — `server/adapters/billmanager/`, `server/routes/sync.js`, `server/sync-scheduler.js`
|
||||||
|
- **Тарифы** — `server/adapters/billmanager/operations.js` (fetchVdsOrderPricelist), `server/adapters/billmanager/sync.js`
|
||||||
|
- **VPS CRUD** — `server/routes/vps.js`
|
||||||
|
- **Платежи/баланс** — `server/routes/payments.js`, `server/routes/balance-ledger.js`
|
||||||
|
- **Курсы валют** — `src/lib/utils.js` (convertCurrency, formatInBaseCurrency), настройки в settings.ratesUrl
|
||||||
|
|
||||||
|
## BILLmanager API
|
||||||
|
|
||||||
|
- [Guide to ISPsystem API](https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api)
|
||||||
|
- [VDS API](https://www.ispsystem.com/docs/b6c/developer-section/billmanager-api/virtual-private-servers-vds)
|
||||||
|
- [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`
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['server/**/*.js'],
|
||||||
|
extends: [js.configs.recommended],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.node,
|
||||||
|
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
ignores: ['server/**/*.js'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>vps-tracker</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+4185
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "vps-tracker",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm run server\" \"vite\"",
|
||||||
|
"server": "node server/index.js",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tabler/core": "^1.4.0",
|
||||||
|
"@tabler/icons-react": "^3.40.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
|
"sql.js": "^1.14.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.21.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"concurrently": "^9.1.0",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* BILLmanager 6 API HTTP client
|
||||||
|
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} baseUrl - e.g. https://bill.example.com:1500/billmgr
|
||||||
|
* @param {string} authinfo - username:password
|
||||||
|
* @param {string} func - API function name (vds, payment, dedic)
|
||||||
|
* @param {Record<string, string>} [params] - additional query params
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
export async function billmanagerRequest(baseUrl, authinfo, func, params = {}) {
|
||||||
|
const url = new URL(baseUrl)
|
||||||
|
url.searchParams.set('authinfo', authinfo)
|
||||||
|
url.searchParams.set('out', 'bjson')
|
||||||
|
url.searchParams.set('func', func)
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v != null && v !== '') url.searchParams.set(k, String(v))
|
||||||
|
}
|
||||||
|
const res = await fetch(url.toString(), { method: 'GET' })
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`BILLmanager API HTTP ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error.msg || data.error.$t || 'BILLmanager API error')
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* BILLmanager 6 API adapter
|
||||||
|
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
|
||||||
|
* @see https://www.ispsystem.com/docs/b6c/developer-section/billmanager-api/virtual-private-servers-vds
|
||||||
|
* @see https://www.ispsystem.com/docs/b6c/developer-section/billmanager-api/payments-payment
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { testConnection, fetchVds, fetchDashboardInfo, fetchPayments, fetchVdsOrderPricelist, fetchVdsOrderPricelistAllDatacenters } from './operations.js'
|
||||||
|
export { syncFromBillmanager } from './sync.js'
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* BILLmanager API response → vps-tracker model mappers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parsePricelist } from './parsers.js'
|
||||||
|
|
||||||
|
/** BILLmanager VDS status: 1=ordered, 2=active, 3=suspended, 4=deleted, 5=processing */
|
||||||
|
const VDS_STATUS_MAP = {
|
||||||
|
1: 'active',
|
||||||
|
2: 'active',
|
||||||
|
3: 'paused',
|
||||||
|
4: 'archived',
|
||||||
|
5: 'active',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} item - BILLmanager vds item
|
||||||
|
* @returns {object} vps-tracker vps shape
|
||||||
|
*/
|
||||||
|
export function mapVdsToVps(item, providerId, providerAccountId) {
|
||||||
|
const status = VDS_STATUS_MAP[Number(item.item_status_orig ?? item.item_status)] ?? 'active'
|
||||||
|
// cost: "100.00 RUB / Месяц" — приоритет для ежемесячного платежа; item_cost — запасной
|
||||||
|
const costStr = String(item.cost || '').replace(/[^\d.-]/g, '')
|
||||||
|
const cost = parseFloat(costStr) || parseFloat(item.item_cost) || 0
|
||||||
|
const createdate = item.createdate || ''
|
||||||
|
const expiredate = item.real_expiredate || item.expiredate || ''
|
||||||
|
const ip = (item.ip || '').trim()
|
||||||
|
const domain = (item.domain || '').trim()
|
||||||
|
const datacenter = (item.datacentername || item.datacenter || '').trim()
|
||||||
|
const ostempl = (item.ostempl || '').trim()
|
||||||
|
const currency = (item.currency_str || 'RUB').toString().trim() || 'RUB'
|
||||||
|
|
||||||
|
const pricelist = item.pricelist || item.tariff || item.plan || ''
|
||||||
|
const parsed = parsePricelist(pricelist)
|
||||||
|
|
||||||
|
return {
|
||||||
|
externalId: String(item.id || ''),
|
||||||
|
ip: ip || domain || `bm-${item.id}`,
|
||||||
|
dns: domain || ip || '',
|
||||||
|
ipv6: '',
|
||||||
|
additionalIps: [],
|
||||||
|
providerId,
|
||||||
|
providerAccountId,
|
||||||
|
country: '',
|
||||||
|
city: '',
|
||||||
|
datacenter,
|
||||||
|
os: ostempl,
|
||||||
|
vcpu: parsed.vcpu || 0,
|
||||||
|
ramGb: parsed.ramGb || 0,
|
||||||
|
diskGb: parsed.diskGb || 0,
|
||||||
|
diskType: parsed.diskType || 'NVMe',
|
||||||
|
virtualization: parsed.virtualization || 'KVM',
|
||||||
|
bandwidthTb: 0,
|
||||||
|
sshPort: 22,
|
||||||
|
rootUser: 'root',
|
||||||
|
purpose: '',
|
||||||
|
environment: 'prod',
|
||||||
|
project: '',
|
||||||
|
monitoringEnabled: false,
|
||||||
|
backupEnabled: false,
|
||||||
|
status,
|
||||||
|
tariffType: 'monthly',
|
||||||
|
currency: currency || 'RUB',
|
||||||
|
dailyRate: null,
|
||||||
|
monthlyRate: cost || null,
|
||||||
|
createdAt: createdate ? createdate.slice(0, 10) : new Date().toISOString().slice(0, 10),
|
||||||
|
paidUntil: expiredate ? expiredate.slice(0, 10) : '',
|
||||||
|
notes: `bm-${item.id}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} item - BILLmanager payment item
|
||||||
|
* @returns {object|null} vps-tracker payment shape or null if not credited
|
||||||
|
*/
|
||||||
|
export function mapPaymentToPayment(item, providerAccountId) {
|
||||||
|
const rawAmount = item.subaccountamount_iso || item.paymethodamount_iso || '0'
|
||||||
|
const amount = parseFloat(String(rawAmount).replace(/[^\d.-]/g, '')) || 0
|
||||||
|
const createDate = item.create_date || item.createdate || ''
|
||||||
|
const dateStr = createDate ? String(createDate).slice(0, 10) : new Date().toISOString().slice(0, 10)
|
||||||
|
// status_orig / real_status = "4" (credited), item.status может быть "Зачислен"
|
||||||
|
const statusNum = Number(item.status_orig ?? item.real_status ?? item.status)
|
||||||
|
if (statusNum !== 4) return null
|
||||||
|
return {
|
||||||
|
externalId: String(item.id || ''),
|
||||||
|
type: 'provider_balance_topup',
|
||||||
|
date: dateStr,
|
||||||
|
amount,
|
||||||
|
currency: (String(item.subaccountamount_iso || item.paymethodamount_iso || '').match(/([A-Z]{3})\b/) || [])[1] || 'USD',
|
||||||
|
providerAccountId,
|
||||||
|
vpsId: null,
|
||||||
|
note: `BILLmanager #${item.number || item.id}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* BILLmanager API operations — fetch VDS, payments, dashboard, tariffs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { billmanagerRequest } from './client.js'
|
||||||
|
import { extractList, elemToObject, parseTariffDesc, parseDatacenterName, extractTariflist } from './parsers.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} baseUrl
|
||||||
|
* @param {string} authinfo
|
||||||
|
* @returns {Promise<object[]>} list of vps objects (raw BILLmanager format)
|
||||||
|
*/
|
||||||
|
export async function fetchVds(baseUrl, authinfo) {
|
||||||
|
const data = await billmanagerRequest(baseUrl, authinfo, 'vds')
|
||||||
|
const elems = extractList(data, 'vds')
|
||||||
|
return elems.map((e) => elemToObject(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить текущий баланс аккаунта (dashboard.info)
|
||||||
|
* @param {string} baseUrl - e.g. https://billing.example.com/billmgr
|
||||||
|
* @param {string} authinfo - username:password
|
||||||
|
* @param {object} [opts] - { fallbackCurrency } — валюта аккаунта, если API не возвращает
|
||||||
|
* @returns {Promise<{ balance: number, currency: string, enoughmoneyto?: string, realbalance?: string }>}
|
||||||
|
*/
|
||||||
|
export async function fetchDashboardInfo(baseUrl, authinfo, opts = {}) {
|
||||||
|
const data = await billmanagerRequest(baseUrl, authinfo, 'dashboard.info', {
|
||||||
|
dashboard: 'info',
|
||||||
|
sfrom: 'ajax',
|
||||||
|
})
|
||||||
|
const elems = extractList(data, 'dashboard') || (Array.isArray(data.elem) ? data.elem : [])
|
||||||
|
const item = elems.length > 0 ? elemToObject(elems[0]) : {}
|
||||||
|
const balanceStr = String(item.realbalance || item.balance || item.available || '0')
|
||||||
|
const amount = parseFloat(balanceStr.replace(/[^\d.,-]/g, '').replace(',', '.')) || 0
|
||||||
|
let currency = (balanceStr.match(/([A-Z]{3})\b/) || [])[1]
|
||||||
|
if (!currency) {
|
||||||
|
if (balanceStr.includes('€') || balanceStr.includes('EUR')) currency = 'EUR'
|
||||||
|
else if (balanceStr.includes('$') || balanceStr.includes('USD')) currency = 'USD'
|
||||||
|
else if (balanceStr.includes('₽') || balanceStr.includes('RUB')) currency = 'RUB'
|
||||||
|
else if (balanceStr.includes('£')) currency = 'GBP'
|
||||||
|
else currency = opts.fallbackCurrency || 'RUB'
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
balance: amount,
|
||||||
|
currency: currency || opts.fallbackCurrency || 'RUB',
|
||||||
|
enoughmoneyto: item.enoughmoneyto || '',
|
||||||
|
realbalance: item.realbalance || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} baseUrl - e.g. https://billing.example.com/billmgr
|
||||||
|
* @param {string} authinfo - username:password
|
||||||
|
* @param {object} [opts] - { createdatestart, createdateend } — опционально, не все API поддерживают
|
||||||
|
* @returns {Promise<object[]>} list of payment objects
|
||||||
|
*/
|
||||||
|
export async function fetchPayments(baseUrl, authinfo, opts = {}) {
|
||||||
|
const params = {}
|
||||||
|
if (opts.createdatestart) params.createdatestart = opts.createdatestart
|
||||||
|
if (opts.createdateend) params.createdateend = opts.createdateend
|
||||||
|
if (opts.createdate === 'other') params.createdate = 'other'
|
||||||
|
if (opts.filter === 'on') params.filter = 'on'
|
||||||
|
if (opts.status != null) params.status = String(opts.status)
|
||||||
|
const data = await billmanagerRequest(baseUrl, authinfo, 'payment', params)
|
||||||
|
const elems = extractList(data, 'payment')
|
||||||
|
return elems.map((e) => elemToObject(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить список активных тарифов для заказа VDS (vds.order) для одного датацентра
|
||||||
|
* @param {string} baseUrl
|
||||||
|
* @param {string} authinfo
|
||||||
|
* @param {object} [opts] - { plid, period, datacenter }
|
||||||
|
* @returns {Promise<{ tariffItems: object[], slist: object }>}
|
||||||
|
*/
|
||||||
|
export async function fetchVdsOrderPricelist(baseUrl, authinfo, opts = {}) {
|
||||||
|
const params = {
|
||||||
|
plid: opts.plid || '',
|
||||||
|
sfrom: 'ajax',
|
||||||
|
}
|
||||||
|
if (opts.period) params.period = opts.period
|
||||||
|
if (opts.datacenter) params.datacenter = opts.datacenter
|
||||||
|
|
||||||
|
const data = await billmanagerRequest(baseUrl, authinfo, 'vds.order', params)
|
||||||
|
|
||||||
|
const tariflist = extractTariflist(data)
|
||||||
|
const slist = data?.list?.slist ?? data?.slist ?? {}
|
||||||
|
if (tariflist.length === 0) return { tariffItems: [], slist }
|
||||||
|
|
||||||
|
const tariffItems = tariflist.map((rawItem) => {
|
||||||
|
const item = elemToObject(rawItem)
|
||||||
|
const parsed = parseTariffDesc(item.desc || '')
|
||||||
|
const descClean = (item.desc || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
const name = parsed.name || parsed.cpuModel || item.price?.split(' ')[0] || '—'
|
||||||
|
return {
|
||||||
|
externalId: String(item.pricelist || ''),
|
||||||
|
name,
|
||||||
|
desc: descClean,
|
||||||
|
vcpu: parsed.vcpu,
|
||||||
|
ramGb: parsed.ramGb,
|
||||||
|
diskGb: parsed.diskGb,
|
||||||
|
diskType: parsed.diskType,
|
||||||
|
virtualization: parsed.virtualization,
|
||||||
|
channel: parsed.channel,
|
||||||
|
location: parsed.location,
|
||||||
|
cpuModel: parsed.cpuModel,
|
||||||
|
orderAvailable: (item.order_available || '').toLowerCase() === 'on',
|
||||||
|
price: item.price || '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { tariffItems, slist }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить тарифы по всем датацентрам — отдельный запрос на каждый ДЦ.
|
||||||
|
* @param {string} baseUrl
|
||||||
|
* @param {string} authinfo
|
||||||
|
* @returns {Promise<{ tariffItems: object[], slist: object }>}
|
||||||
|
*/
|
||||||
|
export async function fetchVdsOrderPricelistAllDatacenters(baseUrl, authinfo) {
|
||||||
|
const initial = await fetchVdsOrderPricelist(baseUrl, authinfo)
|
||||||
|
const slist = initial.slist || {}
|
||||||
|
const datacenters = Array.isArray(slist.datacenter) ? slist.datacenter : []
|
||||||
|
|
||||||
|
if (datacenters.length === 0) {
|
||||||
|
return { tariffItems: initial.tariffItems, slist }
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTariffItems = []
|
||||||
|
|
||||||
|
for (let i = 0; i < datacenters.length; i++) {
|
||||||
|
const dc = datacenters[i]
|
||||||
|
const dcKey = String(dc.k ?? dc.key ?? '')
|
||||||
|
const dcName = String(dc.v ?? dc.value ?? dc.name ?? '')
|
||||||
|
const { country, location } = parseDatacenterName(dcName)
|
||||||
|
|
||||||
|
const result = i === 0 ? initial : await fetchVdsOrderPricelist(baseUrl, authinfo, { datacenter: dcKey })
|
||||||
|
for (const t of result.tariffItems) {
|
||||||
|
allTariffItems.push({
|
||||||
|
...t,
|
||||||
|
datacenterKey: dcKey,
|
||||||
|
datacenterName: dcName,
|
||||||
|
country,
|
||||||
|
location: location || dcName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tariffItems: allTariffItems, slist }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test API connection — запрос списка VDS
|
||||||
|
* @param {string} baseUrl
|
||||||
|
* @param {string} authinfo - username:password
|
||||||
|
* @returns {Promise<{ ok: boolean, error?: string, vdsCount?: number }>}
|
||||||
|
*/
|
||||||
|
export async function testConnection(baseUrl, authinfo) {
|
||||||
|
if (!baseUrl?.trim() || !authinfo?.trim()) {
|
||||||
|
return { ok: false, error: 'Укажите URL и учётные данные' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const items = await fetchVds(baseUrl.trim(), authinfo.trim())
|
||||||
|
return { ok: true, vdsCount: items?.length ?? 0 }
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err.message || 'Ошибка подключения' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* BILLmanager API response parsers
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse BILLmanager JSON list response.
|
||||||
|
* bjson: { elem: [ {...}, {...} ] } или { data: { elem: [...] } }
|
||||||
|
* xml/json: { doc: { vds: { elem: [...] } } }
|
||||||
|
*/
|
||||||
|
export function extractList(data, key) {
|
||||||
|
if (Array.isArray(data.elem)) return data.elem
|
||||||
|
if (data.data?.elem) return Array.isArray(data.data.elem) ? data.data.elem : [data.data.elem]
|
||||||
|
const doc = data.doc || data
|
||||||
|
let list = doc[key]
|
||||||
|
if (!list) return []
|
||||||
|
if (Array.isArray(list)) return list
|
||||||
|
if (list.elem) {
|
||||||
|
const elems = Array.isArray(list.elem) ? list.elem : [list.elem]
|
||||||
|
return elems
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract flat object from BILLmanager elem (can be array of { $name, $t } or already flat object)
|
||||||
|
*/
|
||||||
|
export function elemToObject(elem) {
|
||||||
|
if (!elem) return {}
|
||||||
|
if (Array.isArray(elem)) {
|
||||||
|
const obj = {}
|
||||||
|
for (const e of elem) {
|
||||||
|
const name = e.$name || e.name
|
||||||
|
const val = e.$t ?? e.$ ?? e
|
||||||
|
if (name) obj[name] = typeof val === 'object' && val !== null ? (val.$t ?? val.$ ?? JSON.stringify(val)) : val
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
return typeof elem === 'object' ? elem : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse pricelist string: "KVM SSD Start (1 CPU/768 MB RAM/7 GB SSD)"
|
||||||
|
* @returns {{ vcpu: number, ramGb: number, diskGb: number, diskType: string, virtualization: string }}
|
||||||
|
*/
|
||||||
|
export function parsePricelist(pricelist) {
|
||||||
|
const s = String(pricelist || '')
|
||||||
|
let vcpu = 0
|
||||||
|
let ramGb = 0
|
||||||
|
let diskGb = 0
|
||||||
|
let diskType = 'NVMe'
|
||||||
|
let virtualization = 'KVM'
|
||||||
|
|
||||||
|
const cpuMatch = s.match(/(\d+)\s*(?:CPU|СPU)/i)
|
||||||
|
if (cpuMatch) vcpu = parseInt(cpuMatch[1], 10) || 0
|
||||||
|
|
||||||
|
const ramMbMatch = s.match(/(\d+)\s*MB\s*RAM/i)
|
||||||
|
if (ramMbMatch) ramGb = Math.max(1, Math.round((parseInt(ramMbMatch[1], 10) || 0) / 1024))
|
||||||
|
else {
|
||||||
|
const ramGbMatch = s.match(/(\d+)\s*GB\s*RAM/i)
|
||||||
|
if (ramGbMatch) ramGb = parseInt(ramGbMatch[1], 10) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const diskMatch = s.match(/(\d+)\s*GB\s*(SSD|NVMe|HDD)/i)
|
||||||
|
if (diskMatch) {
|
||||||
|
diskGb = parseInt(diskMatch[1], 10) || 0
|
||||||
|
diskType = diskMatch[2] || 'NVMe'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\bKVM\b/i.test(s)) virtualization = 'KVM'
|
||||||
|
else if (/\bOpenVZ\b/i.test(s)) virtualization = 'OpenVZ'
|
||||||
|
else if (/\bLXC\b/i.test(s)) virtualization = 'LXC'
|
||||||
|
|
||||||
|
return { vcpu, ramGb, diskGb, diskType, virtualization }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит описание тарифа из vds.order.pricelist (desc)
|
||||||
|
* Поддерживает два формата: текстовый (Firstbyte) и HTML (Selectel и др.)
|
||||||
|
* @param {string} desc - HTML или текстовое описание тарифа
|
||||||
|
* @returns {{ name: string, vcpu: number, ramGb: number, diskGb: number, diskType: string, virtualization: string, channel: string, location: string, cpuModel: string }}
|
||||||
|
*/
|
||||||
|
export function parseTariffDesc(desc) {
|
||||||
|
const s = String(desc || '')
|
||||||
|
let name = ''
|
||||||
|
let vcpu = 0
|
||||||
|
let ramGb = 0
|
||||||
|
let diskGb = 0
|
||||||
|
let diskType = 'SSD'
|
||||||
|
let virtualization = 'KVM'
|
||||||
|
let channel = ''
|
||||||
|
let location = ''
|
||||||
|
let cpuModel = ''
|
||||||
|
|
||||||
|
const firstLine = s.split(/\r?\n|<br\s*\/?>/i)[0]?.trim() || ''
|
||||||
|
name = firstLine.replace(/<[^>]+>/g, '').trim()
|
||||||
|
|
||||||
|
// Формат 1: "Процессор: 1 ядро", "Память: 1 GB", "Диск: 20 GB SSD", "Канал: 200Mb/s"
|
||||||
|
const cpuMatch = s.match(/Процессор:\s*(\d+)\s*(?:ядро|ядра|ядер)/i)
|
||||||
|
if (cpuMatch) vcpu = parseInt(cpuMatch[1], 10) || 0
|
||||||
|
|
||||||
|
const ramMbMatch = s.match(/Память:\s*(\d+)\s*MB/i)
|
||||||
|
if (ramMbMatch) ramGb = Math.max(0.5, Math.round((parseInt(ramMbMatch[1], 10) || 0) / 1024 * 10) / 10)
|
||||||
|
else {
|
||||||
|
const ramGbMatch = s.match(/Память:\s*(\d+)\s*GB/i)
|
||||||
|
if (ramGbMatch) ramGb = parseInt(ramGbMatch[1], 10) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const diskMatch = s.match(/Диск:\s*(\d+)\s*GB\s*(SSD|SAS|HDD|NVMe)/i)
|
||||||
|
if (diskMatch) {
|
||||||
|
diskGb = parseInt(diskMatch[1], 10) || 0
|
||||||
|
diskType = diskMatch[2] || 'SSD'
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelMatch = s.match(/Канал:\s*(\d+Mb\/s)/i)
|
||||||
|
if (channelMatch) channel = channelMatch[1]
|
||||||
|
|
||||||
|
if (/\bKVM\b/i.test(s)) virtualization = 'KVM'
|
||||||
|
else if (/\bOpenVZ\b/i.test(s)) virtualization = 'OpenVZ'
|
||||||
|
else if (/\bLXC\b/i.test(s)) virtualization = 'LXC'
|
||||||
|
|
||||||
|
// Формат 2 (HTML): "Публичная сеть: 250 Мбит/с", "Локация: Москва, Россия", "Процессор: Ryzen 7 5800X", "NVMe накопитель"
|
||||||
|
if (!channel) {
|
||||||
|
const netMatch = s.match(/(?:Публичная сеть|Канал)[:\s]*\*?\*?(\d+)\s*Мбит/i)
|
||||||
|
if (netMatch) channel = `${netMatch[1]} Мбит/с`
|
||||||
|
}
|
||||||
|
if (!location) {
|
||||||
|
const locMatch = s.match(/Локация[:\s]*([^;]+)/i)
|
||||||
|
if (locMatch) location = locMatch[1].replace(/<[^>]+>/g, '').trim()
|
||||||
|
}
|
||||||
|
if (!cpuModel) {
|
||||||
|
const procMatch = s.match(/Процессор[:\s]*([^;]+?)(?:\s+до\s|$)/i)
|
||||||
|
if (procMatch) cpuModel = procMatch[1].replace(/<[^>]+>/g, '').trim()
|
||||||
|
}
|
||||||
|
if (!diskType || diskType === 'SSD') {
|
||||||
|
if (/\bNVMe\b/i.test(s)) diskType = 'NVMe'
|
||||||
|
else if (/\bSAS\b/i.test(s)) diskType = 'SAS'
|
||||||
|
else if (/\bHDD\b/i.test(s)) diskType = 'HDD'
|
||||||
|
else if (/\bSSD\b/i.test(s)) diskType = 'SSD'
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, cpuModel }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит название датацентра для извлечения страны и локации
|
||||||
|
* @param {string} dcName - название ДЦ, напр. "1 Датацентр Россия, Москва", "[DE] Франкфурт", "Франция"
|
||||||
|
* @returns {{ country: string, location: string }}
|
||||||
|
*/
|
||||||
|
export function parseDatacenterName(dcName) {
|
||||||
|
const s = String(dcName || '').trim()
|
||||||
|
if (!s) return { country: '', location: '' }
|
||||||
|
|
||||||
|
const COUNTRY_CODE_MAP = {
|
||||||
|
DE: 'Германия',
|
||||||
|
FI: 'Финляндия',
|
||||||
|
RU: 'Россия',
|
||||||
|
FR: 'Франция',
|
||||||
|
GB: 'Великобритания',
|
||||||
|
NL: 'Нидерланды',
|
||||||
|
US: 'США',
|
||||||
|
SE: 'Швеция',
|
||||||
|
NO: 'Норвегия',
|
||||||
|
BE: 'Бельгия',
|
||||||
|
CH: 'Швейцария',
|
||||||
|
CZ: 'Чехия',
|
||||||
|
CA: 'Канада',
|
||||||
|
LV: 'Латвия',
|
||||||
|
LT: 'Литва',
|
||||||
|
EE: 'Эстония',
|
||||||
|
PL: 'Польша',
|
||||||
|
IT: 'Италия',
|
||||||
|
DK: 'Дания',
|
||||||
|
AU: 'Австралия',
|
||||||
|
ES: 'Испания',
|
||||||
|
SG: 'Сингапур',
|
||||||
|
}
|
||||||
|
|
||||||
|
// "[DE] Франкфурт | AMD EPYC" -> country: DE/Германия, location: Франкфурт
|
||||||
|
const codeMatch = s.match(/\[([A-Z]{2})\]\s*([^|]+)/)
|
||||||
|
if (codeMatch) {
|
||||||
|
const code = codeMatch[1]
|
||||||
|
const loc = codeMatch[2].trim()
|
||||||
|
return { country: COUNTRY_CODE_MAP[code] || code, location: loc }
|
||||||
|
}
|
||||||
|
|
||||||
|
// "N Датацентр Страна, Город" или "Датацентр Страна, Город"
|
||||||
|
const dcMatch = s.match(/(?:\d+\s+)?Датацентр\s+([^,]+),\s*(.+)/i)
|
||||||
|
if (dcMatch) {
|
||||||
|
return { country: dcMatch[1].trim(), location: dcMatch[2].trim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Страна, Город" (без слова Датацентр)
|
||||||
|
const commaMatch = s.match(/^([^,]+),\s*(.+)$/)
|
||||||
|
if (commaMatch) {
|
||||||
|
return { country: commaMatch[1].trim(), location: commaMatch[2].trim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Только страна: "Франция", "Россия", "Германия", "Чехия" и т.д.
|
||||||
|
const countryOnly = [
|
||||||
|
'Россия', 'Чехия', 'Нидерланды', 'Франция', 'Великобритания', 'Германия',
|
||||||
|
'Финляндия', 'Швеция', 'Норвегия', 'Бельгия', 'Швейцария', 'Канада',
|
||||||
|
'Латвия', 'Литва', 'Эстония', 'Польша', 'Италия', 'Дания', 'Австралия',
|
||||||
|
'Испания', 'Сингапур', 'США', 'Азия', 'Европа',
|
||||||
|
]
|
||||||
|
for (const c of countryOnly) {
|
||||||
|
if (s === c || s.startsWith(c + ',') || s.startsWith(c + ' ')) {
|
||||||
|
const rest = s.slice(c.length).replace(/^[,\s]+/, '')
|
||||||
|
return { country: c, location: rest }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "ММТС-9", "Adman", "Европа DC1", "Москва DC3" — по ключевым словам
|
||||||
|
if (/ММТС|Adman|Москва/i.test(s)) {
|
||||||
|
return { country: 'Россия', location: s }
|
||||||
|
}
|
||||||
|
if (/Европа/i.test(s)) {
|
||||||
|
return { country: 'Европа', location: s.replace(/Европа\s*/i, '').trim() || s }
|
||||||
|
}
|
||||||
|
|
||||||
|
// "США | Ryzen 9 9950X" — страна до |
|
||||||
|
const pipeMatch = s.match(/^([^|]+)\s*\|/)
|
||||||
|
if (pipeMatch) {
|
||||||
|
const part = pipeMatch[1].trim()
|
||||||
|
for (const c of countryOnly) {
|
||||||
|
if (part.includes(c)) return { country: c, location: '' }
|
||||||
|
}
|
||||||
|
return { country: part, location: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { country: s, location: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Извлечь tariflist из ответа vds.order (поддержка разных форматов BILLmanager)
|
||||||
|
* @param {object} data - сырой ответ API
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
export function extractTariflist(data) {
|
||||||
|
if (!data) return []
|
||||||
|
const listNode = data.list ?? data.doc?.list ?? data.doc
|
||||||
|
if (!listNode) return []
|
||||||
|
|
||||||
|
// tariflist / tarifflist / pricelist — разные варианты названия
|
||||||
|
let list = listNode.tariflist ?? listNode.tarifflist ?? listNode.pricelist
|
||||||
|
if (Array.isArray(list)) return list
|
||||||
|
|
||||||
|
// elem-формат (как в vds, payment)
|
||||||
|
const elems = listNode.elem
|
||||||
|
if (elems) return Array.isArray(elems) ? elems : [elems]
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Sync BILLmanager data into vps-tracker DB
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchVds, fetchPayments, fetchDashboardInfo, fetchVdsOrderPricelistAllDatacenters } from './operations.js'
|
||||||
|
import { mapVdsToVps, mapPaymentToPayment } from './mappers.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync BILLmanager data into vps-tracker DB
|
||||||
|
* @param {object} account - provider_account with apiBaseUrl, apiCredentials
|
||||||
|
* @param {object} db - getDb() wrapper
|
||||||
|
* @param {object} [opts] - { paymentDaysBack }
|
||||||
|
* @returns {{ vpsCount: number, paymentsCount: number, tariffsCount: number, balance?: object }}
|
||||||
|
*/
|
||||||
|
export async function syncFromBillmanager(account, db, _opts = {}) {
|
||||||
|
const { apiBaseUrl, apiCredentials, providerId, id: accountId } = account
|
||||||
|
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
||||||
|
throw new Error('API URL and credentials are required')
|
||||||
|
}
|
||||||
|
const authinfo = apiCredentials.trim()
|
||||||
|
|
||||||
|
const [vdsItems, paymentItems, dashboardInfo, tariffResult] = await Promise.all([
|
||||||
|
fetchVds(apiBaseUrl, authinfo),
|
||||||
|
fetchPayments(apiBaseUrl, authinfo, {}),
|
||||||
|
fetchDashboardInfo(apiBaseUrl, authinfo, { fallbackCurrency: account.currency }).catch(() => null),
|
||||||
|
fetchVdsOrderPricelistAllDatacenters(apiBaseUrl, authinfo).catch((err) => {
|
||||||
|
console.warn('fetchVdsOrderPricelistAllDatacenters failed:', err.message)
|
||||||
|
return { tariffItems: [], slist: {} }
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
const { tariffItems = [], slist = {} } = tariffResult || {}
|
||||||
|
|
||||||
|
let vpsCount = 0
|
||||||
|
const vpsInsertSql = `INSERT INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
const vpsUpdateSql = `UPDATE vps SET ip=?, ipv6=?, additionalIps=?, dns=?, country=?, city=?, datacenter=?, os=?, status=?, tariffType=?, currency=?, dailyRate=?, monthlyRate=?, paidUntil=?, notes=?
|
||||||
|
WHERE id=?`
|
||||||
|
|
||||||
|
const SYNC_UPDATE_FIELDS = ['country', 'city', 'datacenter', 'os', 'notes', 'status', 'tariffType', 'currency', 'dailyRate', 'monthlyRate', 'paidUntil']
|
||||||
|
|
||||||
|
for (const item of vdsItems) {
|
||||||
|
const vps = mapVdsToVps(item, providerId, accountId)
|
||||||
|
const id = `vps-bm-${accountId}-${vps.externalId}`
|
||||||
|
const additionalIps = JSON.stringify(vps.additionalIps || [])
|
||||||
|
const dailyRate = vps.dailyRate
|
||||||
|
const monthlyRate = vps.monthlyRate
|
||||||
|
const paidUntil = vps.paidUntil || ''
|
||||||
|
const notes = vps.notes ? `${vps.notes} [bm-${vps.externalId}]` : `bm-${vps.externalId}`
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT * FROM vps WHERE providerAccountId = ? AND (ip = ? OR notes LIKE ?)').get(accountId, vps.ip, `%bm-${vps.externalId}%`)
|
||||||
|
if (existing) {
|
||||||
|
let userOverrides = []
|
||||||
|
try {
|
||||||
|
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
|
||||||
|
} catch {
|
||||||
|
userOverrides = []
|
||||||
|
}
|
||||||
|
const merged = {
|
||||||
|
ip: vps.ip,
|
||||||
|
ipv6: vps.ipv6,
|
||||||
|
additionalIps,
|
||||||
|
dns: vps.dns,
|
||||||
|
country: vps.country,
|
||||||
|
city: vps.city,
|
||||||
|
datacenter: vps.datacenter,
|
||||||
|
os: vps.os,
|
||||||
|
status: vps.status,
|
||||||
|
tariffType: vps.tariffType,
|
||||||
|
currency: vps.currency,
|
||||||
|
dailyRate,
|
||||||
|
monthlyRate,
|
||||||
|
paidUntil,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
for (const f of SYNC_UPDATE_FIELDS) {
|
||||||
|
if (userOverrides.includes(f)) {
|
||||||
|
merged[f] = existing[f]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.run(vpsUpdateSql,
|
||||||
|
merged.ip,
|
||||||
|
merged.ipv6,
|
||||||
|
merged.additionalIps,
|
||||||
|
merged.dns,
|
||||||
|
merged.country,
|
||||||
|
merged.city,
|
||||||
|
merged.datacenter,
|
||||||
|
merged.os,
|
||||||
|
merged.status,
|
||||||
|
merged.tariffType,
|
||||||
|
merged.currency,
|
||||||
|
merged.dailyRate,
|
||||||
|
merged.monthlyRate,
|
||||||
|
merged.paidUntil,
|
||||||
|
merged.notes,
|
||||||
|
existing.id,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
db.run(vpsInsertSql,
|
||||||
|
id,
|
||||||
|
vps.ip,
|
||||||
|
vps.ipv6,
|
||||||
|
additionalIps,
|
||||||
|
vps.dns,
|
||||||
|
vps.providerId,
|
||||||
|
vps.providerAccountId,
|
||||||
|
vps.country,
|
||||||
|
vps.city,
|
||||||
|
vps.datacenter,
|
||||||
|
vps.os,
|
||||||
|
vps.vcpu,
|
||||||
|
vps.ramGb,
|
||||||
|
vps.diskGb,
|
||||||
|
vps.diskType,
|
||||||
|
vps.virtualization,
|
||||||
|
vps.bandwidthTb,
|
||||||
|
vps.sshPort,
|
||||||
|
vps.rootUser,
|
||||||
|
vps.purpose,
|
||||||
|
vps.environment,
|
||||||
|
vps.project,
|
||||||
|
vps.monitoringEnabled ? 1 : 0,
|
||||||
|
vps.backupEnabled ? 1 : 0,
|
||||||
|
vps.status,
|
||||||
|
vps.tariffType,
|
||||||
|
vps.currency,
|
||||||
|
dailyRate,
|
||||||
|
monthlyRate,
|
||||||
|
vps.createdAt,
|
||||||
|
paidUntil,
|
||||||
|
notes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
vpsCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
let paymentsCount = 0
|
||||||
|
const existingPayments = new Set(
|
||||||
|
db.prepare('SELECT note FROM payments WHERE providerAccountId = ?').all(accountId).map((r) => r.note),
|
||||||
|
)
|
||||||
|
const paymentInsertSql = `INSERT INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
|
||||||
|
for (const item of paymentItems) {
|
||||||
|
const payment = mapPaymentToPayment(item, accountId)
|
||||||
|
if (!payment || payment.amount <= 0) continue
|
||||||
|
const note = payment.note
|
||||||
|
if (existingPayments.has(note)) continue
|
||||||
|
const id = `pay-bm-${accountId}-${payment.externalId}`
|
||||||
|
db.run(paymentInsertSql, id, payment.type, payment.date, payment.amount, payment.currency, payment.providerAccountId, payment.vpsId, note)
|
||||||
|
existingPayments.add(note)
|
||||||
|
paymentsCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dashboardInfo) {
|
||||||
|
db.run(
|
||||||
|
'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?',
|
||||||
|
dashboardInfo.balance,
|
||||||
|
dashboardInfo.currency || 'RUB',
|
||||||
|
new Date().toISOString(),
|
||||||
|
dashboardInfo.enoughmoneyto || '',
|
||||||
|
accountId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let tariffsCount = 0
|
||||||
|
const syncedAt = new Date().toISOString()
|
||||||
|
db.run('DELETE FROM active_tariffs WHERE providerAccountId = ?', accountId)
|
||||||
|
const tariffInsertSql = `INSERT INTO active_tariffs (id, providerAccountId, providerId, externalId, datacenterKey, datacenterName, name, desc, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, country, cpuModel, orderAvailable, price, syncedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
|
||||||
|
for (const t of tariffItems) {
|
||||||
|
const dcKey = t.datacenterKey ?? ''
|
||||||
|
const dcName = t.datacenterName ?? ''
|
||||||
|
const id = dcKey ? `tariff-bm-${accountId}-${t.externalId}-${dcKey}` : `tariff-bm-${accountId}-${t.externalId}`
|
||||||
|
db.run(tariffInsertSql,
|
||||||
|
id,
|
||||||
|
accountId,
|
||||||
|
providerId,
|
||||||
|
t.externalId,
|
||||||
|
dcKey,
|
||||||
|
dcName,
|
||||||
|
t.name || '',
|
||||||
|
t.desc || '',
|
||||||
|
t.vcpu || 0,
|
||||||
|
t.ramGb || 0,
|
||||||
|
t.diskGb || 0,
|
||||||
|
t.diskType || 'SSD',
|
||||||
|
t.virtualization || 'KVM',
|
||||||
|
t.channel || '',
|
||||||
|
t.location || '',
|
||||||
|
t.country || '',
|
||||||
|
t.cpuModel || '',
|
||||||
|
t.orderAvailable ? 1 : 0,
|
||||||
|
t.price || '',
|
||||||
|
syncedAt,
|
||||||
|
)
|
||||||
|
tariffsCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(slist).length > 0) {
|
||||||
|
const datacenters = Array.isArray(slist.datacenter) ? JSON.stringify(slist.datacenter) : '[]'
|
||||||
|
const periods = Array.isArray(slist.period) ? JSON.stringify(slist.period) : '[]'
|
||||||
|
db.run(
|
||||||
|
`INSERT OR REPLACE INTO tariff_sync_options (providerAccountId, datacenters, periods, syncedAt) VALUES (?, ?, ?, ?)`,
|
||||||
|
accountId,
|
||||||
|
datacenters,
|
||||||
|
periods,
|
||||||
|
syncedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { vpsCount, paymentsCount, tariffsCount, balance: dashboardInfo }
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Re-export from db module
|
||||||
|
*/
|
||||||
|
export { initDb, getDb, saveDb } from './db/index.js'
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Database initialization and access
|
||||||
|
*/
|
||||||
|
|
||||||
|
import initSqlJs from 'sql.js'
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
import { SCHEMA } from './schema.js'
|
||||||
|
import { MIGRATIONS } from './migrations.js'
|
||||||
|
import { seed, isDbEmpty } from './seed.js'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
// db/ is in server/, so .. = server, .. again = project root
|
||||||
|
const DB_PATH = join(__dirname, '..', '..', 'data', 'vps-tracker.db')
|
||||||
|
const SEED_DIR = join(__dirname, '..', '..', 'public', 'data')
|
||||||
|
|
||||||
|
let dbInstance = null
|
||||||
|
|
||||||
|
export async function initDb() {
|
||||||
|
const dataDir = join(__dirname, '..', '..', 'data')
|
||||||
|
if (!existsSync(dataDir)) {
|
||||||
|
mkdirSync(dataDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const SQL = await initSqlJs()
|
||||||
|
let db
|
||||||
|
|
||||||
|
if (existsSync(DB_PATH)) {
|
||||||
|
const fileBuffer = readFileSync(DB_PATH)
|
||||||
|
db = new SQL.Database(fileBuffer)
|
||||||
|
} else {
|
||||||
|
db = new SQL.Database()
|
||||||
|
}
|
||||||
|
|
||||||
|
db.exec(SCHEMA)
|
||||||
|
|
||||||
|
for (const m of MIGRATIONS) {
|
||||||
|
try {
|
||||||
|
m.run(db)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Migration ${m.name} failed:`, err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDbEmpty(db)) {
|
||||||
|
seed(db, SEED_DIR)
|
||||||
|
const data = db.export()
|
||||||
|
writeFileSync(DB_PATH, Buffer.from(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
dbInstance = db
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb() {
|
||||||
|
if (!dbInstance) throw new Error('Database not initialized')
|
||||||
|
return createDbWrapper(dbInstance)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDbWrapper(db) {
|
||||||
|
return {
|
||||||
|
/** One-off run: prepare, bind, step, free. Use for statements that run once. */
|
||||||
|
run(sql, ...params) {
|
||||||
|
const stmt = db.prepare(sql)
|
||||||
|
stmt.bind(params)
|
||||||
|
stmt.step()
|
||||||
|
const changes = db.getRowsModified()
|
||||||
|
stmt.free()
|
||||||
|
saveDb()
|
||||||
|
return { changes }
|
||||||
|
},
|
||||||
|
prepare(sql) {
|
||||||
|
const stmt = db.prepare(sql)
|
||||||
|
return {
|
||||||
|
all(...params) {
|
||||||
|
stmt.bind(params)
|
||||||
|
const rows = []
|
||||||
|
while (stmt.step()) {
|
||||||
|
rows.push(stmt.getAsObject())
|
||||||
|
}
|
||||||
|
stmt.free()
|
||||||
|
return rows
|
||||||
|
},
|
||||||
|
get(...params) {
|
||||||
|
stmt.bind(params)
|
||||||
|
const row = stmt.step() ? stmt.getAsObject() : null
|
||||||
|
stmt.free()
|
||||||
|
return row
|
||||||
|
},
|
||||||
|
run(...params) {
|
||||||
|
stmt.bind(params)
|
||||||
|
stmt.step()
|
||||||
|
const changes = db.getRowsModified()
|
||||||
|
stmt.free()
|
||||||
|
saveDb()
|
||||||
|
return { changes }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDb() {
|
||||||
|
if (!dbInstance) return
|
||||||
|
const data = dbInstance.export()
|
||||||
|
writeFileSync(DB_PATH, Buffer.from(data))
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Database migrations — add columns to existing tables
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MIGRATIONS = [
|
||||||
|
{
|
||||||
|
name: 'provider_accounts_api',
|
||||||
|
run(db) {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE provider_accounts ADD COLUMN apiType TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE provider_accounts ADD COLUMN apiBaseUrl TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE provider_accounts ADD COLUMN apiCredentials TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'settings_sync',
|
||||||
|
run(db) {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE settings ADD COLUMN syncEnabled INTEGER')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE settings ADD COLUMN syncIntervalMinutes INTEGER')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'vps_paidUntil',
|
||||||
|
run(db) {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE vps ADD COLUMN paidUntil TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'provider_accounts_balance_api',
|
||||||
|
run(db) {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE provider_accounts ADD COLUMN balance_api REAL')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE provider_accounts ADD COLUMN balance_currency TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE provider_accounts ADD COLUMN balance_updated_at TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE provider_accounts ADD COLUMN enoughmoneyto TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'vps_userOverrides',
|
||||||
|
run(db) {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE vps ADD COLUMN userOverrides TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'active_tariffs_location_cpu',
|
||||||
|
run(db) {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE active_tariffs ADD COLUMN location TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE active_tariffs ADD COLUMN cpuModel TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'active_tariffs_country_datacenter',
|
||||||
|
run(db) {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE active_tariffs ADD COLUMN country TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE active_tariffs ADD COLUMN datacenterKey TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE active_tariffs ADD COLUMN datacenterName TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* SQLite schema for vps-tracker
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SCHEMA = `
|
||||||
|
CREATE TABLE IF NOT EXISTS providers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
website TEXT,
|
||||||
|
contact TEXT,
|
||||||
|
baseCurrency TEXT,
|
||||||
|
usdRate TEXT,
|
||||||
|
eurRate TEXT,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS provider_accounts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
providerId TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
panelUrl TEXT,
|
||||||
|
currency TEXT,
|
||||||
|
billingMode TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
apiType TEXT,
|
||||||
|
apiBaseUrl TEXT,
|
||||||
|
apiCredentials TEXT,
|
||||||
|
FOREIGN KEY (providerId) REFERENCES providers(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vps (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
ip TEXT,
|
||||||
|
ipv6 TEXT,
|
||||||
|
additionalIps TEXT,
|
||||||
|
dns TEXT,
|
||||||
|
providerId TEXT,
|
||||||
|
providerAccountId TEXT,
|
||||||
|
country TEXT,
|
||||||
|
city TEXT,
|
||||||
|
datacenter TEXT,
|
||||||
|
os TEXT,
|
||||||
|
vcpu INTEGER,
|
||||||
|
ramGb INTEGER,
|
||||||
|
diskGb INTEGER,
|
||||||
|
diskType TEXT,
|
||||||
|
virtualization TEXT,
|
||||||
|
bandwidthTb INTEGER,
|
||||||
|
sshPort INTEGER,
|
||||||
|
rootUser TEXT,
|
||||||
|
purpose TEXT,
|
||||||
|
environment TEXT,
|
||||||
|
project TEXT,
|
||||||
|
monitoringEnabled INTEGER,
|
||||||
|
backupEnabled INTEGER,
|
||||||
|
status TEXT,
|
||||||
|
tariffType TEXT,
|
||||||
|
currency TEXT,
|
||||||
|
dailyRate REAL,
|
||||||
|
monthlyRate REAL,
|
||||||
|
createdAt TEXT,
|
||||||
|
paidUntil TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
userOverrides TEXT,
|
||||||
|
FOREIGN KEY (providerId) REFERENCES providers(id),
|
||||||
|
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
currency TEXT,
|
||||||
|
providerAccountId TEXT,
|
||||||
|
vpsId TEXT,
|
||||||
|
note TEXT,
|
||||||
|
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
|
||||||
|
FOREIGN KEY (vpsId) REFERENCES vps(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS balance_ledger (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
currency TEXT,
|
||||||
|
direction TEXT,
|
||||||
|
providerAccountId TEXT,
|
||||||
|
vpsId TEXT,
|
||||||
|
note TEXT,
|
||||||
|
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
|
||||||
|
FOREIGN KEY (vpsId) REFERENCES vps(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
baseCurrency TEXT,
|
||||||
|
ratesUrl TEXT,
|
||||||
|
autoConvert INTEGER,
|
||||||
|
ratesUpdatedAt TEXT,
|
||||||
|
syncEnabled INTEGER,
|
||||||
|
syncIntervalMinutes INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
accountId TEXT NOT NULL,
|
||||||
|
startedAt TEXT NOT NULL,
|
||||||
|
finishedAt TEXT,
|
||||||
|
status TEXT,
|
||||||
|
vpsCount INTEGER,
|
||||||
|
paymentsCount INTEGER,
|
||||||
|
error TEXT,
|
||||||
|
FOREIGN KEY (accountId) REFERENCES provider_accounts(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS active_tariffs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
providerAccountId TEXT NOT NULL,
|
||||||
|
providerId TEXT NOT NULL,
|
||||||
|
externalId TEXT NOT NULL,
|
||||||
|
datacenterKey TEXT,
|
||||||
|
datacenterName TEXT,
|
||||||
|
name TEXT,
|
||||||
|
desc TEXT,
|
||||||
|
vcpu INTEGER,
|
||||||
|
ramGb REAL,
|
||||||
|
diskGb INTEGER,
|
||||||
|
diskType TEXT,
|
||||||
|
virtualization TEXT,
|
||||||
|
channel TEXT,
|
||||||
|
location TEXT,
|
||||||
|
country TEXT,
|
||||||
|
cpuModel TEXT,
|
||||||
|
orderAvailable INTEGER,
|
||||||
|
price TEXT,
|
||||||
|
syncedAt TEXT,
|
||||||
|
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
|
||||||
|
FOREIGN KEY (providerId) REFERENCES providers(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tariff_sync_options (
|
||||||
|
providerAccountId TEXT PRIMARY KEY,
|
||||||
|
datacenters TEXT,
|
||||||
|
periods TEXT,
|
||||||
|
syncedAt TEXT,
|
||||||
|
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
|
||||||
|
);
|
||||||
|
`
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Seed database with initial data from public/data/*.json
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} path - path to JSON file
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
function loadJson(path) {
|
||||||
|
if (!existsSync(path)) return []
|
||||||
|
const raw = readFileSync(path, 'utf-8')
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw)
|
||||||
|
return Array.isArray(data) ? data : [data]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} db - raw sql.js database (not wrapper)
|
||||||
|
* @param {string} seedDir - path to public/data
|
||||||
|
*/
|
||||||
|
export function seed(db, seedDir) {
|
||||||
|
const providers = loadJson(join(seedDir, 'providers.json'))
|
||||||
|
if (providers.length === 0) return
|
||||||
|
|
||||||
|
const run = db.run.bind(db)
|
||||||
|
for (const r of providers) {
|
||||||
|
run(
|
||||||
|
'INSERT OR IGNORE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[r.id, r.name ?? '', r.website ?? '', r.contact ?? '', r.baseCurrency ?? '', r.usdRate ?? '', r.eurRate ?? '', r.notes ?? ''],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerAccounts = loadJson(join(seedDir, 'provider-accounts.json'))
|
||||||
|
for (const r of providerAccounts) {
|
||||||
|
run(
|
||||||
|
'INSERT OR IGNORE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[r.id, r.providerId ?? '', r.name ?? '', r.panelUrl ?? '', r.currency ?? '', r.billingMode ?? '', r.notes ?? '', r.apiType ?? '', r.apiBaseUrl ?? '', r.apiCredentials ?? ''],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vpsList = loadJson(join(seedDir, 'vps.json'))
|
||||||
|
for (const r of vpsList) {
|
||||||
|
const additionalIps = Array.isArray(r.additionalIps) ? JSON.stringify(r.additionalIps) : '[]'
|
||||||
|
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
|
||||||
|
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
|
||||||
|
run(
|
||||||
|
`INSERT OR IGNORE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
r.id,
|
||||||
|
r.ip ?? '',
|
||||||
|
r.ipv6 ?? '',
|
||||||
|
additionalIps,
|
||||||
|
r.dns ?? '',
|
||||||
|
r.providerId ?? '',
|
||||||
|
r.providerAccountId ?? '',
|
||||||
|
r.country ?? '',
|
||||||
|
r.city ?? '',
|
||||||
|
r.datacenter ?? '',
|
||||||
|
r.os ?? '',
|
||||||
|
r.vcpu ?? 0,
|
||||||
|
r.ramGb ?? 0,
|
||||||
|
r.diskGb ?? 0,
|
||||||
|
r.diskType ?? '',
|
||||||
|
r.virtualization ?? '',
|
||||||
|
r.bandwidthTb ?? 0,
|
||||||
|
r.sshPort ?? 22,
|
||||||
|
r.rootUser ?? '',
|
||||||
|
r.purpose ?? '',
|
||||||
|
r.environment ?? '',
|
||||||
|
r.project ?? '',
|
||||||
|
r.monitoringEnabled ? 1 : 0,
|
||||||
|
r.backupEnabled ? 1 : 0,
|
||||||
|
r.status ?? 'active',
|
||||||
|
r.tariffType ?? '',
|
||||||
|
r.currency ?? '',
|
||||||
|
dailyRate,
|
||||||
|
monthlyRate,
|
||||||
|
r.createdAt ?? '',
|
||||||
|
r.paidUntil ?? '',
|
||||||
|
r.notes ?? '',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payments = loadJson(join(seedDir, 'payments.json'))
|
||||||
|
for (const r of payments) {
|
||||||
|
run(
|
||||||
|
'INSERT OR IGNORE INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[r.id, r.type ?? '', r.date ?? '', Number(r.amount) || 0, r.currency ?? '', r.providerAccountId ?? '', r.vpsId ?? '', r.note ?? ''],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ledger = loadJson(join(seedDir, 'balance-ledger.json'))
|
||||||
|
for (const r of ledger) {
|
||||||
|
run(
|
||||||
|
'INSERT OR IGNORE INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[r.id, r.type ?? '', r.date ?? '', Number(r.amount) || 0, r.currency ?? '', r.direction ?? '', r.providerAccountId ?? '', r.vpsId ?? '', r.note ?? ''],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsList = loadJson(join(seedDir, 'settings.json'))
|
||||||
|
for (const r of settingsList) {
|
||||||
|
run(
|
||||||
|
'INSERT OR IGNORE INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[r.id ?? 'settings-main', r.baseCurrency ?? 'RUB', r.ratesUrl ?? '', r.autoConvert !== false ? 1 : 0, r.ratesUpdatedAt ?? '', r.syncEnabled ? 1 : 0, r.syncIntervalMinutes ?? 60],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} db - raw sql.js database
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isDbEmpty(db) {
|
||||||
|
const result = db.exec('SELECT COUNT(*) as c FROM providers')
|
||||||
|
if (!result.length || !result[0].values.length) return true
|
||||||
|
return result[0].values[0][0] === 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import cors from 'cors'
|
||||||
|
import { initDb } from './db.js'
|
||||||
|
import dataRouter from './routes/data.js'
|
||||||
|
import migrateRouter from './routes/migrate.js'
|
||||||
|
import vpsRouter from './routes/vps.js'
|
||||||
|
import providersRouter from './routes/providers.js'
|
||||||
|
import providerAccountsRouter from './routes/provider-accounts.js'
|
||||||
|
import paymentsRouter from './routes/payments.js'
|
||||||
|
import balanceLedgerRouter from './routes/balance-ledger.js'
|
||||||
|
import settingsRouter from './routes/settings.js'
|
||||||
|
import syncRouter from './routes/sync.js'
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
const PORT = process.env.PORT || 3001
|
||||||
|
|
||||||
|
app.use(cors())
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
await initDb()
|
||||||
|
|
||||||
|
app.use('/api/data', dataRouter)
|
||||||
|
app.use('/api/migrate', migrateRouter)
|
||||||
|
app.use('/api/vps', vpsRouter)
|
||||||
|
app.use('/api/providers', providersRouter)
|
||||||
|
app.use('/api/provider-accounts', providerAccountsRouter)
|
||||||
|
app.use('/api/payments', paymentsRouter)
|
||||||
|
app.use('/api/balance-ledger', balanceLedgerRouter)
|
||||||
|
app.use('/api/settings', settingsRouter)
|
||||||
|
app.use('/api/sync', syncRouter)
|
||||||
|
|
||||||
|
const { startScheduler } = await import('./sync-scheduler.js')
|
||||||
|
startScheduler()
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running at http://localhost:${PORT}`)
|
||||||
|
})
|
||||||
|
})()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { getDb } from '../db.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db.prepare('SELECT * FROM balance_ledger ORDER BY date DESC').all()
|
||||||
|
res.json(rows)
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const r = req.body
|
||||||
|
const id = r.id || `ledger-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
id,
|
||||||
|
r.type ?? '',
|
||||||
|
r.date ?? '',
|
||||||
|
Number(r.amount) || 0,
|
||||||
|
r.currency ?? '',
|
||||||
|
r.direction ?? '',
|
||||||
|
r.providerAccountId ?? '',
|
||||||
|
r.vpsId ?? '',
|
||||||
|
r.note ?? '',
|
||||||
|
)
|
||||||
|
const row = db.prepare('SELECT * FROM balance_ledger WHERE id = ?').get(id)
|
||||||
|
res.status(201).json(row)
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const { id } = req.params
|
||||||
|
const r = req.body
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE balance_ledger SET
|
||||||
|
type = ?, date = ?, amount = ?, currency = ?, direction = ?, providerAccountId = ?, vpsId = ?, note = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
r.type ?? '',
|
||||||
|
r.date ?? '',
|
||||||
|
Number(r.amount) || 0,
|
||||||
|
r.currency ?? '',
|
||||||
|
r.direction ?? '',
|
||||||
|
r.providerAccountId ?? '',
|
||||||
|
r.vpsId ?? '',
|
||||||
|
r.note ?? '',
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
const row = db.prepare('SELECT * FROM balance_ledger WHERE id = ?').get(id)
|
||||||
|
if (!row) return res.status(404).json({ error: 'Not found' })
|
||||||
|
res.json(row)
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const { id } = req.params
|
||||||
|
const result = db.prepare('DELETE FROM balance_ledger WHERE id = ?').run(id)
|
||||||
|
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
|
||||||
|
res.status(204).send()
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { getDb } from '../db.js'
|
||||||
|
import { rowToVps } from './vps.js'
|
||||||
|
import { rowToSettings } from './settings.js'
|
||||||
|
import { sanitizeAccount, rowToActiveTariff, rowToTariffSyncOptions } from '../utils/row-mappers.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const vps = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
|
||||||
|
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
|
||||||
|
const providerAccounts = db.prepare('SELECT * FROM provider_accounts ORDER BY name').all()
|
||||||
|
const payments = db.prepare('SELECT * FROM payments ORDER BY date DESC').all()
|
||||||
|
const balanceLedger = db.prepare('SELECT * FROM balance_ledger ORDER BY date DESC').all()
|
||||||
|
const settingsRows = db.prepare('SELECT * FROM settings ORDER BY id').all()
|
||||||
|
const activeTariffs = db.prepare('SELECT * FROM active_tariffs ORDER BY name').all()
|
||||||
|
const tariffSyncOptions = db.prepare('SELECT * FROM tariff_sync_options').all()
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
vps: vps.map(rowToVps),
|
||||||
|
providers,
|
||||||
|
providerAccounts: providerAccounts.map(sanitizeAccount),
|
||||||
|
payments,
|
||||||
|
balanceLedger,
|
||||||
|
settings: settingsRows.map(rowToSettings),
|
||||||
|
activeTariffs: activeTariffs.map(rowToActiveTariff),
|
||||||
|
tariffSyncOptions: tariffSyncOptions.map(rowToTariffSyncOptions),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { getDb, saveDb } from '../db.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const data = req.body
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'Invalid payload' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsList = Array.isArray(data.settings) ? data.settings : (data.settings ? [data.settings] : [])
|
||||||
|
|
||||||
|
if (Array.isArray(data.providers) && data.providers.length > 0) {
|
||||||
|
const sql = `INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
for (const r of data.providers) {
|
||||||
|
const p = typeof r === 'object' ? r : {}
|
||||||
|
db.run(sql, p.id ?? '', p.name ?? '', p.website ?? '', p.contact ?? '', p.baseCurrency ?? '', p.usdRate ?? '', p.eurRate ?? '', p.notes ?? '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(data.providerAccounts) && data.providerAccounts.length > 0) {
|
||||||
|
const sql = `INSERT OR REPLACE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
for (const r of data.providerAccounts) {
|
||||||
|
const acc = typeof r === 'object' ? r : {}
|
||||||
|
db.run(sql, acc.id ?? '', acc.providerId ?? '', acc.name ?? '', acc.panelUrl ?? '', acc.currency ?? '', acc.billingMode ?? '', acc.notes ?? '', acc.apiType ?? '', acc.apiBaseUrl ?? '', acc.apiCredentials ?? '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(data.vps) && data.vps.length > 0) {
|
||||||
|
const sql = `INSERT OR REPLACE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
for (const r of data.vps) {
|
||||||
|
const v = typeof r === 'object' ? r : {}
|
||||||
|
const additionalIps = Array.isArray(v.additionalIps) ? JSON.stringify(v.additionalIps) : '[]'
|
||||||
|
const dailyRate = v.dailyRate === '' || v.dailyRate == null ? null : Number(v.dailyRate)
|
||||||
|
const monthlyRate = v.monthlyRate === '' || v.monthlyRate == null ? null : Number(v.monthlyRate)
|
||||||
|
const userOverrides = Array.isArray(v.userOverrides) ? JSON.stringify(v.userOverrides) : (v.userOverrides ?? '')
|
||||||
|
db.run(sql, v.id ?? '', v.ip ?? '', v.ipv6 ?? '', additionalIps, v.dns ?? '', v.providerId ?? '', v.providerAccountId ?? '', v.country ?? '', v.city ?? '', v.datacenter ?? '', v.os ?? '', v.vcpu ?? 0, v.ramGb ?? 0, v.diskGb ?? 0, v.diskType ?? '', v.virtualization ?? '', v.bandwidthTb ?? 0, v.sshPort ?? 22, v.rootUser ?? '', v.purpose ?? '', v.environment ?? '', v.project ?? '', v.monitoringEnabled ? 1 : 0, v.backupEnabled ? 1 : 0, v.status ?? 'active', v.tariffType ?? '', v.currency ?? '', dailyRate, monthlyRate, v.createdAt ?? '', v.paidUntil ?? '', v.notes ?? '', userOverrides)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(data.payments) && data.payments.length > 0) {
|
||||||
|
const sql = `INSERT OR REPLACE INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
for (const r of data.payments) {
|
||||||
|
const pm = typeof r === 'object' ? r : {}
|
||||||
|
db.run(sql, pm.id ?? '', pm.type ?? '', pm.date ?? '', Number(pm.amount) || 0, pm.currency ?? '', pm.providerAccountId ?? '', pm.vpsId ?? '', pm.note ?? '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(data.balanceLedger) && data.balanceLedger.length > 0) {
|
||||||
|
const sql = `INSERT OR REPLACE INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
for (const r of data.balanceLedger) {
|
||||||
|
const bl = typeof r === 'object' ? r : {}
|
||||||
|
db.run(sql, bl.id ?? '', bl.type ?? '', bl.date ?? '', Number(bl.amount) || 0, bl.currency ?? '', bl.direction ?? '', bl.providerAccountId ?? '', bl.vpsId ?? '', bl.note ?? '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (settingsList.length > 0) {
|
||||||
|
const sql = `INSERT OR REPLACE INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
for (const r of settingsList) {
|
||||||
|
const s = typeof r === 'object' ? r : {}
|
||||||
|
db.run(sql, s.id ?? 'settings-main', s.baseCurrency ?? 'RUB', s.ratesUrl ?? '', s.autoConvert !== false ? 1 : 0, s.ratesUpdatedAt ?? '', s.syncEnabled ? 1 : 0, s.syncIntervalMinutes ?? 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveDb()
|
||||||
|
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Migrate error:', err)
|
||||||
|
res.status(500).json({ error: err.message || 'Migration failed' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { getDb } from '../db.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db.prepare('SELECT * FROM payments ORDER BY date DESC').all()
|
||||||
|
res.json(rows)
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const r = req.body
|
||||||
|
const id = r.id || `pay-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
id,
|
||||||
|
r.type ?? '',
|
||||||
|
r.date ?? '',
|
||||||
|
Number(r.amount) || 0,
|
||||||
|
r.currency ?? '',
|
||||||
|
r.providerAccountId ?? '',
|
||||||
|
r.vpsId ?? '',
|
||||||
|
r.note ?? '',
|
||||||
|
)
|
||||||
|
const row = db.prepare('SELECT * FROM payments WHERE id = ?').get(id)
|
||||||
|
res.status(201).json(row)
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const { id } = req.params
|
||||||
|
const r = req.body
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE payments SET
|
||||||
|
type = ?, date = ?, amount = ?, currency = ?, providerAccountId = ?, vpsId = ?, note = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
r.type ?? '',
|
||||||
|
r.date ?? '',
|
||||||
|
Number(r.amount) || 0,
|
||||||
|
r.currency ?? '',
|
||||||
|
r.providerAccountId ?? '',
|
||||||
|
r.vpsId ?? '',
|
||||||
|
r.note ?? '',
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
const row = db.prepare('SELECT * FROM payments WHERE id = ?').get(id)
|
||||||
|
if (!row) return res.status(404).json({ error: 'Not found' })
|
||||||
|
res.json(row)
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const { id } = req.params
|
||||||
|
const result = db.prepare('DELETE FROM payments WHERE id = ?').run(id)
|
||||||
|
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
|
||||||
|
res.status(204).send()
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { getDb } from '../db.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
function sanitizeAccount(row) {
|
||||||
|
if (!row) return row
|
||||||
|
const { apiCredentials, ...rest } = row
|
||||||
|
return { ...rest, apiCredentialsSet: Boolean(apiCredentials) }
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db.prepare('SELECT * FROM provider_accounts ORDER BY name').all()
|
||||||
|
res.json(rows.map(sanitizeAccount))
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const r = req.body
|
||||||
|
const id = r.id || `account-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
id,
|
||||||
|
r.providerId ?? '',
|
||||||
|
r.name ?? '',
|
||||||
|
r.panelUrl ?? '',
|
||||||
|
r.currency ?? '',
|
||||||
|
r.billingMode ?? '',
|
||||||
|
r.notes ?? '',
|
||||||
|
r.apiType ?? '',
|
||||||
|
r.apiBaseUrl ?? '',
|
||||||
|
r.apiCredentials ?? '',
|
||||||
|
)
|
||||||
|
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
|
||||||
|
res.status(201).json(sanitizeAccount(row))
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const { id } = req.params
|
||||||
|
const r = req.body
|
||||||
|
const existing = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Not found' })
|
||||||
|
const apiType = r.apiType !== undefined ? String(r.apiType || '') : (existing.apiType || '')
|
||||||
|
const apiBaseUrl = r.apiBaseUrl !== undefined ? String(r.apiBaseUrl || '') : (existing.apiBaseUrl || '')
|
||||||
|
const apiCredentials = r.apiCredentials !== undefined ? String(r.apiCredentials || '') : (existing.apiCredentials || '')
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE provider_accounts SET
|
||||||
|
providerId = ?, name = ?, panelUrl = ?, currency = ?, billingMode = ?, notes = ?, apiType = ?, apiBaseUrl = ?, apiCredentials = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
r.providerId ?? existing.providerId ?? '',
|
||||||
|
r.name ?? existing.name ?? '',
|
||||||
|
r.panelUrl ?? existing.panelUrl ?? '',
|
||||||
|
r.currency ?? existing.currency ?? '',
|
||||||
|
r.billingMode ?? existing.billingMode ?? '',
|
||||||
|
r.notes ?? existing.notes ?? '',
|
||||||
|
apiType,
|
||||||
|
apiBaseUrl,
|
||||||
|
apiCredentials,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
|
||||||
|
res.json(sanitizeAccount(row))
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const { id } = req.params
|
||||||
|
const result = db.prepare('DELETE FROM provider_accounts WHERE id = ?').run(id)
|
||||||
|
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
|
||||||
|
res.status(204).send()
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { getDb } from '../db.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db.prepare('SELECT * FROM providers ORDER BY name').all()
|
||||||
|
res.json(rows)
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const r = req.body
|
||||||
|
const id = r.id || `provider-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
id,
|
||||||
|
r.name ?? '',
|
||||||
|
r.website ?? '',
|
||||||
|
r.contact ?? '',
|
||||||
|
r.baseCurrency ?? '',
|
||||||
|
r.usdRate ?? '',
|
||||||
|
r.eurRate ?? '',
|
||||||
|
r.notes ?? '',
|
||||||
|
)
|
||||||
|
const row = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
|
||||||
|
res.status(201).json(row)
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const { id } = req.params
|
||||||
|
const r = req.body
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE providers SET
|
||||||
|
name = ?, website = ?, contact = ?, baseCurrency = ?, usdRate = ?, eurRate = ?, notes = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
r.name ?? '',
|
||||||
|
r.website ?? '',
|
||||||
|
r.contact ?? '',
|
||||||
|
r.baseCurrency ?? '',
|
||||||
|
r.usdRate ?? '',
|
||||||
|
r.eurRate ?? '',
|
||||||
|
r.notes ?? '',
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
const row = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
|
||||||
|
if (!row) return res.status(404).json({ error: 'Not found' })
|
||||||
|
res.json(row)
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const { id } = req.params
|
||||||
|
const result = db.prepare('DELETE FROM providers WHERE id = ?').run(id)
|
||||||
|
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
|
||||||
|
res.status(204).send()
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { getDb } from '../db.js'
|
||||||
|
import { startScheduler } from '../sync-scheduler.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
export function rowToSettings(row) {
|
||||||
|
if (!row) return null
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
autoConvert: Boolean(row.autoConvert),
|
||||||
|
syncEnabled: Boolean(row.syncEnabled),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db.prepare('SELECT * FROM settings ORDER BY id').all()
|
||||||
|
res.json(rows.map(rowToSettings))
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const { id } = req.params
|
||||||
|
const r = req.body
|
||||||
|
const existing = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
|
||||||
|
const syncEnabled = r.syncEnabled !== undefined ? (r.syncEnabled ? 1 : 0) : (existing?.syncEnabled ? 1 : 0)
|
||||||
|
const syncIntervalMinutes = r.syncIntervalMinutes !== undefined ? Math.max(15, Number(r.syncIntervalMinutes) || 60) : (existing?.syncIntervalMinutes ?? 60)
|
||||||
|
if (existing) {
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE settings SET
|
||||||
|
baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
r.baseCurrency ?? existing.baseCurrency ?? 'RUB',
|
||||||
|
r.ratesUrl ?? existing.ratesUrl ?? '',
|
||||||
|
r.autoConvert !== undefined ? (r.autoConvert !== false ? 1 : 0) : (existing.autoConvert ? 1 : 0),
|
||||||
|
r.ratesUpdatedAt ?? existing.ratesUpdatedAt ?? '',
|
||||||
|
syncEnabled,
|
||||||
|
syncIntervalMinutes,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
id,
|
||||||
|
r.baseCurrency ?? 'RUB',
|
||||||
|
r.ratesUrl ?? '',
|
||||||
|
r.autoConvert !== false ? 1 : 0,
|
||||||
|
r.ratesUpdatedAt ?? '',
|
||||||
|
syncEnabled,
|
||||||
|
syncIntervalMinutes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
startScheduler()
|
||||||
|
const row = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
|
||||||
|
res.json(rowToSettings(row))
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const r = req.body
|
||||||
|
const id = r.id ?? 'settings-main'
|
||||||
|
const syncEnabled = r.syncEnabled ? 1 : 0
|
||||||
|
const syncIntervalMinutes = Math.max(15, Number(r.syncIntervalMinutes) || 60)
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
id,
|
||||||
|
r.baseCurrency ?? 'RUB',
|
||||||
|
r.ratesUrl ?? 'https://www.cbr-xml-daily.ru/latest.js',
|
||||||
|
r.autoConvert !== false ? 1 : 0,
|
||||||
|
r.ratesUpdatedAt ?? '',
|
||||||
|
syncEnabled,
|
||||||
|
syncIntervalMinutes,
|
||||||
|
)
|
||||||
|
startScheduler()
|
||||||
|
const row = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
|
||||||
|
res.status(201).json(rowToSettings(row))
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { getDb } from '../db.js'
|
||||||
|
import { syncFromBillmanager, fetchDashboardInfo, testConnection } from '../adapters/billmanager/index.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.post('/test-connection', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { apiBaseUrl, apiCredentials } = req.body || {}
|
||||||
|
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'Укажите URL и учётные данные' })
|
||||||
|
}
|
||||||
|
const result = await testConnection(apiBaseUrl.trim(), apiCredentials.trim())
|
||||||
|
res.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message || 'Ошибка проверки' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/status', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT accountId, startedAt, finishedAt, status, vpsCount, paymentsCount, error
|
||||||
|
FROM sync_log ORDER BY startedAt DESC LIMIT 50
|
||||||
|
`).all()
|
||||||
|
res.json(rows)
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/:accountId/balance', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const { accountId } = req.params
|
||||||
|
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(accountId)
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
if (row.apiType !== 'billmanager') {
|
||||||
|
return res.status(400).json({ error: 'Account is not configured for BILLmanager API' })
|
||||||
|
}
|
||||||
|
if (!row.apiBaseUrl?.trim() || !row.apiCredentials?.trim()) {
|
||||||
|
return res.status(400).json({ error: 'API URL and credentials are required' })
|
||||||
|
}
|
||||||
|
const info = await fetchDashboardInfo(row.apiBaseUrl, row.apiCredentials.trim(), { fallbackCurrency: row.currency })
|
||||||
|
db.run(
|
||||||
|
'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?',
|
||||||
|
info.balance,
|
||||||
|
info.currency || 'RUB',
|
||||||
|
new Date().toISOString(),
|
||||||
|
info.enoughmoneyto || '',
|
||||||
|
accountId,
|
||||||
|
)
|
||||||
|
res.json({ ok: true, balance: info })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Balance fetch error:', err)
|
||||||
|
res.status(500).json({ ok: false, error: err.message || 'Failed to fetch balance' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/:accountId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const { accountId } = req.params
|
||||||
|
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(accountId)
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
if (row.apiType !== 'billmanager') {
|
||||||
|
return res.status(400).json({ error: 'Account is not configured for BILLmanager API' })
|
||||||
|
}
|
||||||
|
if (!row.apiBaseUrl?.trim() || !row.apiCredentials?.trim()) {
|
||||||
|
return res.status(400).json({ error: 'API URL and credentials are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const logId = `sync-${accountId}-${Date.now()}`
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO sync_log (id, accountId, startedAt, status)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(logId, accountId, new Date().toISOString(), 'running')
|
||||||
|
|
||||||
|
const result = await syncFromBillmanager(row, db)
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE sync_log SET finishedAt=?, status=?, vpsCount=?, paymentsCount=?
|
||||||
|
WHERE id=?
|
||||||
|
`).run(new Date().toISOString(), 'ok', result.vpsCount, result.paymentsCount, logId)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
synced: {
|
||||||
|
vpsCount: result.vpsCount,
|
||||||
|
paymentsCount: result.paymentsCount,
|
||||||
|
tariffsCount: result.tariffsCount ?? 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Sync error:', err)
|
||||||
|
const { accountId } = req.params
|
||||||
|
const db = getDb()
|
||||||
|
const logRows = db.prepare('SELECT id FROM sync_log WHERE accountId=? AND status=? ORDER BY startedAt DESC LIMIT 1').all(accountId, 'running')
|
||||||
|
if (logRows.length > 0) {
|
||||||
|
db.prepare('UPDATE sync_log SET finishedAt=?, status=?, error=? WHERE id=?').run(
|
||||||
|
new Date().toISOString(),
|
||||||
|
'error',
|
||||||
|
err.message || 'Unknown error',
|
||||||
|
logRows[0].id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
res.status(500).json({ ok: false, error: err.message || 'Sync failed' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { getDb } from '../db.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
export function rowToVps(row) {
|
||||||
|
if (!row) return null
|
||||||
|
let additionalIps = []
|
||||||
|
try {
|
||||||
|
additionalIps = row.additionalIps ? JSON.parse(row.additionalIps) : []
|
||||||
|
} catch {
|
||||||
|
additionalIps = []
|
||||||
|
}
|
||||||
|
let userOverrides = []
|
||||||
|
try {
|
||||||
|
userOverrides = row.userOverrides ? JSON.parse(row.userOverrides) : []
|
||||||
|
} catch {
|
||||||
|
userOverrides = []
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
additionalIps,
|
||||||
|
userOverrides,
|
||||||
|
monitoringEnabled: Boolean(row.monitoringEnabled),
|
||||||
|
backupEnabled: Boolean(row.backupEnabled),
|
||||||
|
dailyRate: row.dailyRate != null ? row.dailyRate : '',
|
||||||
|
monthlyRate: row.monthlyRate != null ? row.monthlyRate : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
|
||||||
|
res.json(rows.map(rowToVps))
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const r = req.body
|
||||||
|
const id = r.id || `vps-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
const additionalIps = Array.isArray(r.additionalIps) ? JSON.stringify(r.additionalIps) : '[]'
|
||||||
|
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
|
||||||
|
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO vps (
|
||||||
|
id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter,
|
||||||
|
os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser,
|
||||||
|
purpose, environment, project, monitoringEnabled, backupEnabled, status, tariffType,
|
||||||
|
currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
id,
|
||||||
|
r.ip ?? '',
|
||||||
|
r.ipv6 ?? '',
|
||||||
|
additionalIps,
|
||||||
|
r.dns ?? '',
|
||||||
|
r.providerId ?? '',
|
||||||
|
r.providerAccountId ?? '',
|
||||||
|
r.country ?? '',
|
||||||
|
r.city ?? '',
|
||||||
|
r.datacenter ?? '',
|
||||||
|
r.os ?? '',
|
||||||
|
r.vcpu ?? 0,
|
||||||
|
r.ramGb ?? 0,
|
||||||
|
r.diskGb ?? 0,
|
||||||
|
r.diskType ?? '',
|
||||||
|
r.virtualization ?? '',
|
||||||
|
r.bandwidthTb ?? 0,
|
||||||
|
r.sshPort ?? 22,
|
||||||
|
r.rootUser ?? '',
|
||||||
|
r.purpose ?? '',
|
||||||
|
r.environment ?? '',
|
||||||
|
r.project ?? '',
|
||||||
|
r.monitoringEnabled ? 1 : 0,
|
||||||
|
r.backupEnabled ? 1 : 0,
|
||||||
|
r.status ?? 'active',
|
||||||
|
r.tariffType ?? '',
|
||||||
|
r.currency ?? '',
|
||||||
|
dailyRate,
|
||||||
|
monthlyRate,
|
||||||
|
r.createdAt ?? new Date().toISOString().slice(0, 10),
|
||||||
|
r.paidUntil ?? '',
|
||||||
|
r.notes ?? '',
|
||||||
|
r.userOverrides ? (Array.isArray(r.userOverrides) ? JSON.stringify(r.userOverrides) : r.userOverrides) : '[]',
|
||||||
|
)
|
||||||
|
const row = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
|
||||||
|
res.status(201).json(rowToVps(row))
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const USER_OVERRIDABLE_FIELDS = ['country', 'city', 'datacenter', 'os', 'vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization', 'purpose', 'environment', 'project', 'notes', 'sshPort', 'rootUser', 'bandwidthTb', 'monitoringEnabled', 'backupEnabled']
|
||||||
|
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const { id } = req.params
|
||||||
|
const r = req.body
|
||||||
|
const existing = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Not found' })
|
||||||
|
|
||||||
|
let userOverrides = []
|
||||||
|
try {
|
||||||
|
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
|
||||||
|
} catch {
|
||||||
|
userOverrides = []
|
||||||
|
}
|
||||||
|
if (r.userOverrides === 'clear' || (Array.isArray(r.userOverrides) && r.userOverrides.length === 0)) {
|
||||||
|
userOverrides = []
|
||||||
|
} else {
|
||||||
|
for (const f of USER_OVERRIDABLE_FIELDS) {
|
||||||
|
const newVal = r[f]
|
||||||
|
const oldVal = existing[f]
|
||||||
|
const changed = String(newVal ?? '') !== String(oldVal ?? '')
|
||||||
|
if (changed && !userOverrides.includes(f)) {
|
||||||
|
userOverrides.push(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const userOverridesJson = JSON.stringify([...new Set(userOverrides)])
|
||||||
|
|
||||||
|
const additionalIps = Array.isArray(r.additionalIps) ? JSON.stringify(r.additionalIps) : '[]'
|
||||||
|
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
|
||||||
|
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE vps SET
|
||||||
|
ip = ?, ipv6 = ?, additionalIps = ?, dns = ?, providerId = ?, providerAccountId = ?,
|
||||||
|
country = ?, city = ?, datacenter = ?, os = ?, vcpu = ?, ramGb = ?, diskGb = ?, diskType = ?,
|
||||||
|
virtualization = ?, bandwidthTb = ?, sshPort = ?, rootUser = ?, purpose = ?, environment = ?,
|
||||||
|
project = ?, monitoringEnabled = ?, backupEnabled = ?, status = ?, tariffType = ?,
|
||||||
|
currency = ?, dailyRate = ?, monthlyRate = ?, createdAt = ?, paidUntil = ?, notes = ?,
|
||||||
|
userOverrides = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
r.ip ?? '',
|
||||||
|
r.ipv6 ?? '',
|
||||||
|
additionalIps,
|
||||||
|
r.dns ?? '',
|
||||||
|
r.providerId ?? '',
|
||||||
|
r.providerAccountId ?? '',
|
||||||
|
r.country ?? '',
|
||||||
|
r.city ?? '',
|
||||||
|
r.datacenter ?? '',
|
||||||
|
r.os ?? '',
|
||||||
|
r.vcpu ?? 0,
|
||||||
|
r.ramGb ?? 0,
|
||||||
|
r.diskGb ?? 0,
|
||||||
|
r.diskType ?? '',
|
||||||
|
r.virtualization ?? '',
|
||||||
|
r.bandwidthTb ?? 0,
|
||||||
|
r.sshPort ?? 22,
|
||||||
|
r.rootUser ?? '',
|
||||||
|
r.purpose ?? '',
|
||||||
|
r.environment ?? '',
|
||||||
|
r.project ?? '',
|
||||||
|
r.monitoringEnabled ? 1 : 0,
|
||||||
|
r.backupEnabled ? 1 : 0,
|
||||||
|
r.status ?? 'active',
|
||||||
|
r.tariffType ?? '',
|
||||||
|
r.currency ?? '',
|
||||||
|
dailyRate,
|
||||||
|
monthlyRate,
|
||||||
|
r.createdAt ?? '',
|
||||||
|
r.paidUntil ?? '',
|
||||||
|
r.notes ?? '',
|
||||||
|
userOverridesJson,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
const row = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
|
||||||
|
if (!row) return res.status(404).json({ error: 'Not found' })
|
||||||
|
res.json(rowToVps(row))
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const { id } = req.params
|
||||||
|
const result = db.prepare('DELETE FROM vps WHERE id = ?').run(id)
|
||||||
|
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
|
||||||
|
res.status(204).send()
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { getDb } from './db.js'
|
||||||
|
import { syncFromBillmanager } from './adapters/billmanager/index.js'
|
||||||
|
|
||||||
|
let syncIntervalId = null
|
||||||
|
|
||||||
|
export function runScheduledSync() {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
|
||||||
|
if (!settings?.syncEnabled) return
|
||||||
|
const accounts = db.prepare(`
|
||||||
|
SELECT * FROM provider_accounts
|
||||||
|
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != ''
|
||||||
|
`).all()
|
||||||
|
for (const account of accounts) {
|
||||||
|
syncFromBillmanager(account, db).catch((err) => {
|
||||||
|
console.warn(`Sync failed for account ${account.id}:`, err.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Scheduled sync error:', err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startScheduler() {
|
||||||
|
if (syncIntervalId) clearInterval(syncIntervalId)
|
||||||
|
syncIntervalId = null
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
|
||||||
|
if (!settings?.syncEnabled) return
|
||||||
|
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
|
||||||
|
syncIntervalId = setInterval(runScheduledSync, interval * 60 * 1000)
|
||||||
|
console.log(`Scheduled sync enabled: every ${interval} min`)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Map database rows to API response format
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} row - provider_account row
|
||||||
|
* @returns {object} sanitized (apiCredentials hidden)
|
||||||
|
*/
|
||||||
|
export function sanitizeAccount(row) {
|
||||||
|
if (!row) return row
|
||||||
|
const { apiCredentials, ...rest } = row
|
||||||
|
return { ...rest, apiCredentialsSet: Boolean(apiCredentials) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} row - active_tariffs row
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
export function rowToActiveTariff(row) {
|
||||||
|
if (!row) return null
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
orderAvailable: Boolean(row.orderAvailable),
|
||||||
|
ramGb: row.ramGb != null ? Number(row.ramGb) : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} row - tariff_sync_options row
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
export function rowToTariffSyncOptions(row) {
|
||||||
|
if (!row) return null
|
||||||
|
let datacenters = []
|
||||||
|
let periods = []
|
||||||
|
try {
|
||||||
|
datacenters = row.datacenters ? JSON.parse(row.datacenters) : []
|
||||||
|
} catch {
|
||||||
|
/* ignore parse error */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
periods = row.periods ? JSON.parse(row.periods) : []
|
||||||
|
} catch {
|
||||||
|
/* ignore parse error */
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
datacenters: Array.isArray(datacenters) ? datacenters : [],
|
||||||
|
periods: Array.isArray(periods) ? periods : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import { AppLayout } from './components/AppLayout'
|
||||||
|
import { DashboardPage } from './pages/DashboardPage'
|
||||||
|
import { VpsPage } from './pages/VpsPage'
|
||||||
|
import { ProvidersPage } from './pages/ProvidersPage'
|
||||||
|
import { AccountsPage } from './pages/AccountsPage'
|
||||||
|
import { PaymentsPage } from './pages/PaymentsPage'
|
||||||
|
import { BalancePage } from './pages/BalancePage'
|
||||||
|
import { ReportsPage } from './pages/ReportsPage'
|
||||||
|
import { SettingsPage } from './pages/SettingsPage'
|
||||||
|
import { TariffsPage } from './pages/TariffsPage'
|
||||||
|
import {
|
||||||
|
createRecord,
|
||||||
|
deleteRecord,
|
||||||
|
initDataStore,
|
||||||
|
loadDataSet,
|
||||||
|
updateRecord,
|
||||||
|
} from './lib/api'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [isReady, setIsReady] = useState(false)
|
||||||
|
const [loadError, setLoadError] = useState('')
|
||||||
|
const [db, setDb] = useState({
|
||||||
|
vps: [],
|
||||||
|
providers: [],
|
||||||
|
providerAccounts: [],
|
||||||
|
payments: [],
|
||||||
|
balanceLedger: [],
|
||||||
|
settings: [],
|
||||||
|
activeTariffs: [],
|
||||||
|
tariffSyncOptions: [],
|
||||||
|
})
|
||||||
|
const [ratesData, setRatesData] = useState(null)
|
||||||
|
const [ratesError, setRatesError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initDataStore()
|
||||||
|
.then(() => loadDataSet())
|
||||||
|
.then((data) => {
|
||||||
|
setDb(data)
|
||||||
|
setIsReady(true)
|
||||||
|
setLoadError('')
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setLoadError(err.message || 'Ошибка загрузки данных')
|
||||||
|
setIsReady(true)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const settings = db.settings?.[0]
|
||||||
|
if (!settings?.ratesUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetch(settings.ratesUrl)
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Не удалось получить курсы валют')
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
})
|
||||||
|
.then((payload) => {
|
||||||
|
setRatesData(payload)
|
||||||
|
setRatesError('')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setRatesError(error.message || 'Ошибка загрузки курсов')
|
||||||
|
})
|
||||||
|
}, [db.settings])
|
||||||
|
|
||||||
|
const actions = useMemo(
|
||||||
|
() => ({
|
||||||
|
create: async (collectionName, record) => {
|
||||||
|
const nextCollection = await createRecord(collectionName, record)
|
||||||
|
setDb((prev) => ({ ...prev, [collectionName]: nextCollection }))
|
||||||
|
},
|
||||||
|
update: async (collectionName, id, patch) => {
|
||||||
|
const nextCollection = await updateRecord(collectionName, id, patch)
|
||||||
|
setDb((prev) => ({ ...prev, [collectionName]: nextCollection }))
|
||||||
|
},
|
||||||
|
remove: async (collectionName, id) => {
|
||||||
|
const nextCollection = await deleteRecord(collectionName, id)
|
||||||
|
setDb((prev) => ({ ...prev, [collectionName]: nextCollection }))
|
||||||
|
},
|
||||||
|
upsertSettings: async (patch) => {
|
||||||
|
const current = db.settings?.[0]
|
||||||
|
if (current?.id) {
|
||||||
|
const nextCollection = await updateRecord('settings', current.id, patch)
|
||||||
|
setDb((prev) => ({ ...prev, settings: nextCollection }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextCollection = await createRecord('settings', {
|
||||||
|
id: 'settings-main',
|
||||||
|
baseCurrency: 'RUB',
|
||||||
|
ratesUrl: 'https://www.cbr-xml-daily.ru/latest.js',
|
||||||
|
autoConvert: true,
|
||||||
|
...patch,
|
||||||
|
})
|
||||||
|
setDb((prev) => ({ ...prev, settings: nextCollection }))
|
||||||
|
},
|
||||||
|
refreshData: async () => {
|
||||||
|
const data = await loadDataSet()
|
||||||
|
setDb(data)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[db.settings],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return (
|
||||||
|
<div className="page page-center">
|
||||||
|
<div className="container container-tight py-4 text-center">
|
||||||
|
<div className="spinner-border text-blue" role="status" />
|
||||||
|
<div className="text-secondary mt-2">Загрузка данных...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<div className="page page-center">
|
||||||
|
<div className="container container-tight py-4 text-center">
|
||||||
|
<div className="text-danger mb-2">{loadError}</div>
|
||||||
|
<div className="text-secondary">Убедитесь, что сервер запущен (npm run server)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppLayout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={<DashboardPage db={db} settings={db.settings} ratesData={ratesData} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/vps"
|
||||||
|
element={<VpsPage db={db} actions={actions} settings={db.settings} ratesData={ratesData} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/tariffs"
|
||||||
|
element={<TariffsPage db={db} actions={actions} settings={db.settings} ratesData={ratesData} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/providers"
|
||||||
|
element={<ProvidersPage db={db} actions={actions} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/accounts"
|
||||||
|
element={<AccountsPage db={db} actions={actions} settings={db.settings} ratesData={ratesData} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/payments"
|
||||||
|
element={<PaymentsPage db={db} actions={actions} settings={db.settings} ratesData={ratesData} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/balance"
|
||||||
|
element={<BalancePage db={db} actions={actions} settings={db.settings} ratesData={ratesData} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/reports"
|
||||||
|
element={<ReportsPage db={db} settings={db.settings} ratesData={ratesData} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<SettingsPage
|
||||||
|
db={db}
|
||||||
|
actions={actions}
|
||||||
|
ratesData={ratesData}
|
||||||
|
ratesError={ratesError}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</AppLayout>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,84 @@
|
|||||||
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
IconBuildingSkyscraper,
|
||||||
|
IconChartHistogram,
|
||||||
|
IconCoin,
|
||||||
|
IconCreditCardPay,
|
||||||
|
IconLayoutDashboard,
|
||||||
|
IconSettings,
|
||||||
|
IconServer,
|
||||||
|
IconServer2,
|
||||||
|
IconWallet,
|
||||||
|
} from '@tabler/icons-react'
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ to: '/dashboard', label: 'Дашборд', icon: IconLayoutDashboard },
|
||||||
|
{ to: '/vps', label: 'VPS', icon: IconServer },
|
||||||
|
{ to: '/tariffs', label: 'Активные тарифы', icon: IconServer2 },
|
||||||
|
{ to: '/providers', label: 'Хостеры', icon: IconBuildingSkyscraper },
|
||||||
|
{ to: '/accounts', label: 'Аккаунты хостеров', icon: IconWallet },
|
||||||
|
{ to: '/payments', label: 'Платежи', icon: IconCreditCardPay },
|
||||||
|
{ to: '/balance', label: 'Баланс и списания', icon: IconCoin },
|
||||||
|
{ to: '/reports', label: 'Отчёты', icon: IconChartHistogram },
|
||||||
|
{ to: '/settings', label: 'Настройки', icon: IconSettings },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AppLayout({ children }) {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<aside className="navbar navbar-vertical navbar-expand-lg app-sidebar" data-bs-theme="dark">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<h1 className="navbar-brand navbar-brand-autodark my-3 text-white">VPS Tracker</h1>
|
||||||
|
<div className="collapse navbar-collapse show">
|
||||||
|
<ul className="navbar-nav pt-lg-3">
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<li className="nav-item" key={item.to}>
|
||||||
|
<NavLink
|
||||||
|
to={item.to}
|
||||||
|
className={`nav-link ${
|
||||||
|
location.pathname === item.to ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<item.icon size={18} stroke={1.75} />
|
||||||
|
</span>
|
||||||
|
<span className="nav-link-title">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="page-wrapper">
|
||||||
|
<div className="d-lg-none border-bottom bg-white">
|
||||||
|
<div className="container-fluid py-2">
|
||||||
|
<div className="mobile-nav-scroll">
|
||||||
|
<div className="nav nav-pills nav-sm flex-nowrap">
|
||||||
|
{menuItems.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={`nav-link ${location.pathname === item.to ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<Icon size={16} className="me-1" />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="page-body">
|
||||||
|
<div className="container-fluid">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { convertWithProviderRate, formatCurrency } from '../lib/utils'
|
||||||
|
|
||||||
|
function sourceMeta(source) {
|
||||||
|
if (source === 'provider') {
|
||||||
|
return { label: 'Курс хостера', className: 'bg-green-lt text-green' }
|
||||||
|
}
|
||||||
|
if (source === 'global') {
|
||||||
|
return { label: 'Глобальный курс', className: 'bg-blue-lt text-blue' }
|
||||||
|
}
|
||||||
|
return { label: 'Без конвертации', className: 'bg-secondary-lt text-secondary' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConvertedAmount({ amount, currency, provider, settings, ratesData }) {
|
||||||
|
const result = convertWithProviderRate(amount, currency, provider, settings, ratesData)
|
||||||
|
const meta = sourceMeta(result.source)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<span>{formatCurrency(result.value, result.currency)}</span>
|
||||||
|
<span className={`badge ${meta.className}`}>{meta.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary small">{formatCurrency(amount, currency)}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Tabler-style empty state for tables.
|
||||||
|
* @see https://docs.tabler.io/docs/components/empty-states.html
|
||||||
|
*/
|
||||||
|
export function EmptyState({ message = 'Нет данных', colSpan = 10 }) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={colSpan} className="text-secondary text-center py-4">
|
||||||
|
{message}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
BarController,
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from 'chart.js'
|
||||||
|
|
||||||
|
ChartJS.register(BarController, BarElement, CategoryScale, LinearScale, Title, Tooltip)
|
||||||
|
|
||||||
|
export function ExpenseChart({ data, baseCurrency, formatCurrency }) {
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const chartRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const safeData = Array.isArray(data) ? data : []
|
||||||
|
if (safeData.length === 0) return
|
||||||
|
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (chartRef.current) {
|
||||||
|
chartRef.current.destroy()
|
||||||
|
chartRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const chart = new ChartJS(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: safeData.map((d) => d.monthLabel || ''),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Расход',
|
||||||
|
data: safeData.map((d) => Number(d.amount) || 0),
|
||||||
|
backgroundColor: 'rgba(47, 179, 68, 0.6)',
|
||||||
|
borderColor: 'rgb(47, 179, 68)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) =>
|
||||||
|
typeof formatCurrency === 'function'
|
||||||
|
? formatCurrency(ctx.raw, baseCurrency)
|
||||||
|
: String(ctx.raw),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
suggestedMax: (ctx) => {
|
||||||
|
const max = ctx.chart?.data?.datasets?.[0]?.data
|
||||||
|
? Math.max(...ctx.chart.data.datasets[0].data, 0)
|
||||||
|
: 0
|
||||||
|
return max > 0 ? undefined : 1
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: (value) =>
|
||||||
|
typeof formatCurrency === 'function'
|
||||||
|
? formatCurrency(value, baseCurrency)
|
||||||
|
: String(value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
chartRef.current = chart
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('ExpenseChart error:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (chartRef.current) {
|
||||||
|
chartRef.current.destroy()
|
||||||
|
chartRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data, baseCurrency, formatCurrency])
|
||||||
|
|
||||||
|
const safeData = Array.isArray(data) ? data : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: 220, minHeight: 220, position: 'relative' }}>
|
||||||
|
{safeData.length === 0 ? (
|
||||||
|
<div className="text-secondary text-center py-5">Нет данных за период</div>
|
||||||
|
) : (
|
||||||
|
<canvas ref={canvasRef} style={{ display: 'block', maxHeight: 220 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function PageHeader({ pretitle, title }) {
|
||||||
|
return (
|
||||||
|
<div className="page-header d-print-none mb-3">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col">
|
||||||
|
<div className="page-pretitle">{pretitle}</div>
|
||||||
|
<h2 className="page-title">{title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
ArcElement,
|
||||||
|
DoughnutController,
|
||||||
|
Legend,
|
||||||
|
Tooltip,
|
||||||
|
} from 'chart.js'
|
||||||
|
|
||||||
|
ChartJS.register(ArcElement, DoughnutController, Legend, Tooltip)
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'rgba(32, 107, 196, 0.8)',
|
||||||
|
'rgba(47, 179, 68, 0.8)',
|
||||||
|
'rgba(245, 159, 0, 0.8)',
|
||||||
|
'rgba(155, 93, 229, 0.8)',
|
||||||
|
'rgba(214, 51, 132, 0.8)',
|
||||||
|
'rgba(13, 202, 240, 0.8)',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ProviderPieChart({ data, baseCurrency, formatCurrency }) {
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const chartRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const safeData = Array.isArray(data) ? data : []
|
||||||
|
if (safeData.length === 0) return
|
||||||
|
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (chartRef.current) {
|
||||||
|
chartRef.current.destroy()
|
||||||
|
chartRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
chartRef.current = new ChartJS(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: safeData.map((d) => d.providerName || '-'),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: safeData.map((d) => Number(d.amount) || 0),
|
||||||
|
backgroundColor: safeData.map((_, i) => COLORS[i % COLORS.length]),
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'right' },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => {
|
||||||
|
const total = ctx.dataset.data.reduce((a, b) => a + b, 0)
|
||||||
|
const pct = total > 0 ? ((ctx.raw / total) * 100).toFixed(1) : 0
|
||||||
|
const formatted =
|
||||||
|
typeof formatCurrency === 'function'
|
||||||
|
? formatCurrency(ctx.raw, baseCurrency)
|
||||||
|
: String(ctx.raw)
|
||||||
|
return `${ctx.label}: ${formatted} (${pct}%)`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('ProviderPieChart error:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (chartRef.current) {
|
||||||
|
chartRef.current.destroy()
|
||||||
|
chartRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data, baseCurrency, formatCurrency])
|
||||||
|
|
||||||
|
const safeData = Array.isArray(data) ? data : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: 220, minHeight: 220, position: 'relative' }}>
|
||||||
|
{safeData.length === 0 ? (
|
||||||
|
<div className="text-secondary text-center py-5">Нет расходов по хостеру</div>
|
||||||
|
) : (
|
||||||
|
<canvas ref={canvasRef} style={{ display: 'block', maxHeight: 220 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
export function UiModal({ open, title, onClose, size = 'modal-lg', footer, scrollable = false, children }) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const previousOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalNode = (
|
||||||
|
<div
|
||||||
|
className="ui-modal-root"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 2000,
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-backdrop fade show"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="modal modal-blur fade show d-block"
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`modal-dialog ${size} modal-dialog-centered`} role="document">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">{title}</h5>
|
||||||
|
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="modal-body"
|
||||||
|
style={scrollable ? { maxHeight: '60vh', overflowY: 'auto' } : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{footer ? <div className="modal-footer">{footer}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return createPortal(modalNode, document.body)
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
background: var(--tblr-bg-surface);
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(180deg, #18233a 0%, #111a2d 100%);
|
||||||
|
border-right: 1px solid #23314b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar .nav-link {
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #aebbd2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar .nav-link:hover,
|
||||||
|
.app-sidebar .nav-link.active {
|
||||||
|
background: rgba(61, 119, 255, 0.2);
|
||||||
|
color: #e8f0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-body {
|
||||||
|
background: #f5f7fb;
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-wrapper {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-scroll .nav {
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td,
|
||||||
|
.table th {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card.metric-blue {
|
||||||
|
border-left-color: #206bc4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card.metric-green {
|
||||||
|
border-left-color: #2fb344;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card.metric-yellow {
|
||||||
|
border-left-color: #f59f00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card.metric-purple {
|
||||||
|
border-left-color: #9b5de5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vps-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VPS modal form sections */
|
||||||
|
.vps-modal-form .vps-form-section {
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--tblr-border-color, #e6e7e9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vps-modal-form .vps-form-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vps-form-section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--tblr-secondary, #656d77);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Компактный отступ между карточками в одной колонке */
|
||||||
|
.card-stack .card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.card-stack .card + .card {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.app-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-body .container-fluid {
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .table-responsive {
|
||||||
|
margin-left: -0.75rem;
|
||||||
|
margin-right: -0.75rem;
|
||||||
|
width: calc(100% + 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 8.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vps-header-actions {
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
import { uid } from './utils'
|
||||||
|
|
||||||
|
const COLLECTIONS = {
|
||||||
|
vps: 'vps',
|
||||||
|
providers: 'providers',
|
||||||
|
providerAccounts: 'providerAccounts',
|
||||||
|
payments: 'payments',
|
||||||
|
balanceLedger: 'balanceLedger',
|
||||||
|
settings: 'settings',
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_PATHS = {
|
||||||
|
[COLLECTIONS.vps]: '/api/vps',
|
||||||
|
[COLLECTIONS.providers]: '/api/providers',
|
||||||
|
[COLLECTIONS.providerAccounts]: '/api/provider-accounts',
|
||||||
|
[COLLECTIONS.payments]: '/api/payments',
|
||||||
|
[COLLECTIONS.balanceLedger]: '/api/balance-ledger',
|
||||||
|
[COLLECTIONS.settings]: '/api/settings',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY_PREFIX = 'vps-tracker:'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || ''
|
||||||
|
|
||||||
|
function getLocalStorageData() {
|
||||||
|
const data = {}
|
||||||
|
for (const name of Object.values(COLLECTIONS)) {
|
||||||
|
const key = `${STORAGE_KEY_PREFIX}${name}`
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
data[name] = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
data[name] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLocalStorage() {
|
||||||
|
for (const name of Object.values(COLLECTIONS)) {
|
||||||
|
localStorage.removeItem(`${STORAGE_KEY_PREFIX}${name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchApi(path, options = {}) {
|
||||||
|
const url = `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error(res.statusText || 'API error')
|
||||||
|
err.status = res.status
|
||||||
|
err.response = res
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
if (res.status === 204) return null
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initDataStore() {
|
||||||
|
const localData = getLocalStorageData()
|
||||||
|
const hasLocalData = Object.keys(localData).some((k) => {
|
||||||
|
const arr = localData[k]
|
||||||
|
return Array.isArray(arr) ? arr.length > 0 : arr && typeof arr === 'object'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasLocalData) {
|
||||||
|
try {
|
||||||
|
await fetchApi('/api/migrate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(localData),
|
||||||
|
})
|
||||||
|
clearLocalStorage()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Migration from localStorage failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDataSet() {
|
||||||
|
const data = await fetchApi('/api/data')
|
||||||
|
return {
|
||||||
|
[COLLECTIONS.vps]: data.vps ?? [],
|
||||||
|
[COLLECTIONS.providers]: data.providers ?? [],
|
||||||
|
[COLLECTIONS.providerAccounts]: data.providerAccounts ?? [],
|
||||||
|
[COLLECTIONS.payments]: data.payments ?? [],
|
||||||
|
[COLLECTIONS.balanceLedger]: data.balanceLedger ?? [],
|
||||||
|
[COLLECTIONS.settings]: data.settings ?? [],
|
||||||
|
activeTariffs: data.activeTariffs ?? [],
|
||||||
|
tariffSyncOptions: data.tariffSyncOptions ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCollection(collectionName) {
|
||||||
|
const path = API_PATHS[collectionName]
|
||||||
|
if (!path) throw new Error(`Unknown collection: ${collectionName}`)
|
||||||
|
return fetchApi(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRecord(collectionName, record) {
|
||||||
|
const path = API_PATHS[collectionName]
|
||||||
|
if (!path) throw new Error(`Unknown collection: ${collectionName}`)
|
||||||
|
const payload = { ...record, id: record.id || uid() }
|
||||||
|
await fetchApi(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
return fetchCollection(collectionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRecord(collectionName, id, patch) {
|
||||||
|
const path = API_PATHS[collectionName]
|
||||||
|
if (!path) throw new Error(`Unknown collection: ${collectionName}`)
|
||||||
|
await fetchApi(`${path}/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
})
|
||||||
|
return fetchCollection(collectionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRecord(collectionName, id) {
|
||||||
|
const path = API_PATHS[collectionName]
|
||||||
|
if (!path) throw new Error(`Unknown collection: ${collectionName}`)
|
||||||
|
await fetchApi(`${path}/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||||
|
return fetchCollection(collectionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncAccount(accountId) {
|
||||||
|
return fetchApi(`/api/sync/${encodeURIComponent(accountId)}`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAccountBalance(accountId) {
|
||||||
|
return fetchApi(`/api/sync/${encodeURIComponent(accountId)}/balance`, { method: 'GET' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testApiConnection(apiBaseUrl, apiCredentials) {
|
||||||
|
return fetchApi('/api/sync/test-connection', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ apiBaseUrl, apiCredentials }),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* Утилиты vps-tracker: ID, URL, форматирование, валюта, лейблы, CSV
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерирует уникальный ID (UUID или fallback)
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function uid() {
|
||||||
|
if (crypto && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет https:// к URL при отсутствии протокола
|
||||||
|
* @param {string} website
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function normalizeWebsiteUrl(website) {
|
||||||
|
if (!website) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (website.startsWith('http://') || website.startsWith('https://')) {
|
||||||
|
return website
|
||||||
|
}
|
||||||
|
return `https://${website}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL иконки сайта через Google Favicon API
|
||||||
|
* @param {string} website
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function faviconUrlFromWebsite(website) {
|
||||||
|
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 countryCodeByName = {
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Эмодзи флага страны по названию (ru-RU)
|
||||||
|
* @param {string} country - название страны
|
||||||
|
* @returns {string} эмодзи флага или 🌐
|
||||||
|
*/
|
||||||
|
export function getCountryFlagEmoji(country) {
|
||||||
|
if (!country) {
|
||||||
|
return '🌐'
|
||||||
|
}
|
||||||
|
const code = countryCodeByName[country.trim().toLowerCase()]
|
||||||
|
if (!code) {
|
||||||
|
return '🌐'
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
.toUpperCase()
|
||||||
|
.split('')
|
||||||
|
.map((char) => String.fromCodePoint(127397 + char.charCodeAt(0)))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentTypeLabels = {
|
||||||
|
direct_vps_payment: 'Прямой платеж за VPS',
|
||||||
|
provider_balance_topup: 'Пополнение баланса хостера',
|
||||||
|
daily_debit: 'Ежедневное списание',
|
||||||
|
monthly_debit: 'Ежемесячное списание',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Человекочитаемая метка типа платежа
|
||||||
|
* @param {string} type - direct_vps_payment | provider_balance_topup | daily_debit | monthly_debit
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function paymentTypeLabel(type) {
|
||||||
|
return paymentTypeLabels[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
const vpsStatusLabels = {
|
||||||
|
active: 'Активен',
|
||||||
|
paused: 'Приостановлен',
|
||||||
|
archived: 'Архив',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Человекочитаемая метка статуса VPS
|
||||||
|
* @param {string} status - active | paused | archived
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function vpsStatusLabel(status) {
|
||||||
|
return vpsStatusLabels[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
const billingModeLabels = {
|
||||||
|
daily: 'Ежедневно',
|
||||||
|
monthly: 'Ежемесячно',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Человекочитаемая метка режима биллинга
|
||||||
|
* @param {string} mode - daily | monthly
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function billingModeLabel(mode) {
|
||||||
|
return billingModeLabels[mode] || mode
|
||||||
|
}
|
||||||
|
|
||||||
|
const tariffTypeLabels = {
|
||||||
|
daily: 'Суточный',
|
||||||
|
monthly: 'Месячный',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Человекочитаемая метка типа тарифа
|
||||||
|
* @param {string} type - daily | monthly
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function tariffTypeLabel(type) {
|
||||||
|
return tariffTypeLabels[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENCY_SYMBOL_MAP = {
|
||||||
|
'€': 'EUR',
|
||||||
|
'$': 'USD',
|
||||||
|
'₽': 'RUB',
|
||||||
|
'£': 'GBP',
|
||||||
|
'¥': 'JPY',
|
||||||
|
'₴': 'UAH',
|
||||||
|
'₸': 'KZT',
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoCurrency(currency) {
|
||||||
|
if (!currency || typeof currency !== 'string') return 'USD'
|
||||||
|
const trimmed = currency.trim()
|
||||||
|
if (CURRENCY_SYMBOL_MAP[trimmed]) return CURRENCY_SYMBOL_MAP[trimmed]
|
||||||
|
if (trimmed.length === 3 && /^[A-Z]{3}$/i.test(trimmed)) return trimmed.toUpperCase()
|
||||||
|
return 'USD'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует сумму в валюте (ru-RU)
|
||||||
|
* @param {number} amount
|
||||||
|
* @param {string} [currency='USD']
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatCurrency(amount, currency = 'USD') {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует сумму из одной валюты в другую по ratesData (CBR и т.п.)
|
||||||
|
* @param {number} amount
|
||||||
|
* @param {string} fromCurrency
|
||||||
|
* @param {string} toCurrency
|
||||||
|
* @param {object} ratesData - { base, rates: { USD: 1.2, EUR: 1.1, ... } }
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function convertCurrency(amount, fromCurrency, toCurrency, ratesData) {
|
||||||
|
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 = { ...ratesData.rates, [apiBase]: 1 }
|
||||||
|
|
||||||
|
if (!rates[from] || !rates[to]) {
|
||||||
|
return safeAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountInApiBase = safeAmount / rates[from]
|
||||||
|
return amountInApiBase * rates[to]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует сумму в базовой валюте приложения (settings.baseCurrency)
|
||||||
|
* @param {number} amount
|
||||||
|
* @param {string} currency
|
||||||
|
* @param {object[]} appSettings - settings из API
|
||||||
|
* @param {object} ratesData
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatInBaseCurrency(amount, currency, appSettings, ratesData) {
|
||||||
|
const settings = appSettings?.[0] || {}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует сумму в валюту отображения (из настроек).
|
||||||
|
* provider.baseCurrency — валюта, в которой хостер принимает платежи.
|
||||||
|
* settings.baseCurrency — валюта отображения на дашбордах.
|
||||||
|
* Курсы хостера (usdRate, eurRate) — курс 1 USD/EUR в валюту отображения.
|
||||||
|
*/
|
||||||
|
export function convertWithProviderRate(amount, currency, provider, appSettings, ratesData) {
|
||||||
|
const safeAmount = Number(amount)
|
||||||
|
const appBase = (appSettings?.[0]?.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 = Number(provider?.usdRate)
|
||||||
|
const eurRate = Number(provider?.eurRate)
|
||||||
|
if (fromCurrency === 'USD' && Number.isFinite(usdRate) && usdRate > 0) {
|
||||||
|
return { value: safeAmount * usdRate, currency: appBase, source: 'provider' }
|
||||||
|
}
|
||||||
|
if (fromCurrency === 'EUR' && Number.isFinite(eurRate) && eurRate > 0) {
|
||||||
|
return { value: safeAmount * eurRate, currency: appBase, source: 'provider' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: convertCurrency(safeAmount, fromCurrency, appBase, ratesData),
|
||||||
|
currency: appBase,
|
||||||
|
source: 'global',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует сумму с учётом курсов провайдера (usdRate, eurRate)
|
||||||
|
* @param {number} amount
|
||||||
|
* @param {string} currency
|
||||||
|
* @param {object} provider
|
||||||
|
* @param {object[]} appSettings
|
||||||
|
* @param {object} ratesData
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatInProviderCurrency(amount, currency, provider, appSettings, ratesData) {
|
||||||
|
const converted = convertWithProviderRate(amount, currency, provider, appSettings, ratesData)
|
||||||
|
return formatCurrency(converted.value, converted.currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ключ месяца для группировки: "2025-03"
|
||||||
|
* @param {string} dateString
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function monthKey(dateString) {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует массив объектов в CSV-строку
|
||||||
|
* @param {object[]} rows
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function toCsv(rows) {
|
||||||
|
if (!rows.length) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const headers = Object.keys(rows[0])
|
||||||
|
const escapeValue = (value) => {
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициирует скачивание текстового файла (CSV)
|
||||||
|
* @param {string} fileName
|
||||||
|
* @param {string} content
|
||||||
|
*/
|
||||||
|
export function downloadTextFile(fileName, content) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import '@tabler/core/dist/css/tabler.min.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
billingModeLabel,
|
||||||
|
faviconUrlFromWebsite,
|
||||||
|
} from '../lib/utils'
|
||||||
|
import { UiModal } from '../components/UiModal'
|
||||||
|
import { EmptyState } from '../components/EmptyState'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||||
|
import { syncAccount, testApiConnection, fetchAccountBalance } from '../lib/api'
|
||||||
|
import { IconRefresh, IconPlugConnected } from '@tabler/icons-react'
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
providerId: '',
|
||||||
|
name: '',
|
||||||
|
panelUrl: '',
|
||||||
|
currency: 'USD',
|
||||||
|
billingMode: 'monthly',
|
||||||
|
notes: '',
|
||||||
|
apiType: '',
|
||||||
|
apiBaseUrl: '',
|
||||||
|
apiLogin: '',
|
||||||
|
apiPassword: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountsPage({ db, actions, settings, ratesData }) {
|
||||||
|
const [form, setForm] = useState(emptyForm)
|
||||||
|
const [editingId, setEditingId] = useState(null)
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
const [syncLoadingId, setSyncLoadingId] = useState(null)
|
||||||
|
const [syncLoadingAll, setSyncLoadingAll] = useState(false)
|
||||||
|
const [syncMessage, setSyncMessage] = useState(null)
|
||||||
|
const [balanceLoadingId, setBalanceLoadingId] = useState(null)
|
||||||
|
const [saveError, setSaveError] = useState(null)
|
||||||
|
|
||||||
|
const billmanagerAccounts = useMemo(
|
||||||
|
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
|
||||||
|
[db.providerAccounts],
|
||||||
|
)
|
||||||
|
const [testConnectionLoading, setTestConnectionLoading] = useState(false)
|
||||||
|
const [testConnectionResult, setTestConnectionResult] = useState(null)
|
||||||
|
|
||||||
|
const balances = useMemo(() => {
|
||||||
|
return db.providerAccounts.map((account) => {
|
||||||
|
const rows = db.balanceLedger.filter((row) => row.providerAccountId === account.id)
|
||||||
|
const credits = rows
|
||||||
|
.filter((row) => row.direction === 'credit')
|
||||||
|
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||||
|
const debits = rows
|
||||||
|
.filter((row) => row.direction === 'debit')
|
||||||
|
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||||
|
return { accountId: account.id, balance: credits - debits }
|
||||||
|
})
|
||||||
|
}, [db.balanceLedger, db.providerAccounts])
|
||||||
|
|
||||||
|
const getBalance = (accountId) => balances.find((item) => item.accountId === accountId)?.balance || 0
|
||||||
|
|
||||||
|
const getDisplayBalance = (account) => {
|
||||||
|
if (account.apiType === 'billmanager' && account.balance_api != null) {
|
||||||
|
return account.balance_api
|
||||||
|
}
|
||||||
|
return getBalance(account.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDisplayCurrency = (account) => account.balance_currency || account.currency || 'USD'
|
||||||
|
|
||||||
|
const onFetchBalance = async (accountId) => {
|
||||||
|
setBalanceLoadingId(accountId)
|
||||||
|
try {
|
||||||
|
await fetchAccountBalance(accountId)
|
||||||
|
await actions.refreshData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Balance fetch failed:', err)
|
||||||
|
} finally {
|
||||||
|
setBalanceLoadingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!form.providerId || !form.name.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
providerId: form.providerId,
|
||||||
|
name: form.name,
|
||||||
|
panelUrl: form.panelUrl,
|
||||||
|
currency: form.currency,
|
||||||
|
billingMode: form.billingMode,
|
||||||
|
notes: form.notes,
|
||||||
|
apiType: form.apiType || '',
|
||||||
|
apiBaseUrl: form.apiType === 'billmanager' ? form.apiBaseUrl : '',
|
||||||
|
}
|
||||||
|
if (form.apiType === 'billmanager' && form.apiLogin && form.apiPassword) {
|
||||||
|
payload.apiCredentials = `${form.apiLogin}:${form.apiPassword}`
|
||||||
|
}
|
||||||
|
setSaveError(null)
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
await actions.update('providerAccounts', editingId, payload)
|
||||||
|
} else {
|
||||||
|
await actions.create('providerAccounts', payload)
|
||||||
|
}
|
||||||
|
setForm(emptyForm)
|
||||||
|
setEditingId(null)
|
||||||
|
setIsModalOpen(false)
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err.message || 'Ошибка сохранения')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTestConnection = async () => {
|
||||||
|
if (!form.apiBaseUrl?.trim() || !form.apiLogin?.trim() || !form.apiPassword?.trim()) {
|
||||||
|
setTestConnectionResult({ ok: false, error: 'Заполните URL, логин и пароль' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTestConnectionLoading(true)
|
||||||
|
setTestConnectionResult(null)
|
||||||
|
try {
|
||||||
|
const result = await testApiConnection(form.apiBaseUrl, `${form.apiLogin}:${form.apiPassword}`)
|
||||||
|
setTestConnectionResult(result)
|
||||||
|
} catch (err) {
|
||||||
|
setTestConnectionResult({ ok: false, error: err.message || 'Ошибка проверки' })
|
||||||
|
} finally {
|
||||||
|
setTestConnectionLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEdit = (account) => {
|
||||||
|
setForm({
|
||||||
|
providerId: account.providerId || '',
|
||||||
|
name: account.name || '',
|
||||||
|
panelUrl: account.panelUrl || '',
|
||||||
|
currency: account.currency || 'USD',
|
||||||
|
billingMode: account.billingMode || 'monthly',
|
||||||
|
notes: account.notes || '',
|
||||||
|
apiType: account.apiType || '',
|
||||||
|
apiBaseUrl: account.apiBaseUrl || '',
|
||||||
|
apiLogin: '',
|
||||||
|
apiPassword: '',
|
||||||
|
})
|
||||||
|
setEditingId(account.id)
|
||||||
|
setIsModalOpen(true)
|
||||||
|
setTestConnectionResult(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editingAccount = editingId ? db.providerAccounts.find((a) => a.id === editingId) : null
|
||||||
|
const canTestConnection = form.apiType === 'billmanager' && form.apiBaseUrl?.trim() && form.apiLogin?.trim() && form.apiPassword?.trim()
|
||||||
|
|
||||||
|
const onSync = async (accountId) => {
|
||||||
|
setSyncLoadingId(accountId)
|
||||||
|
setSyncMessage(null)
|
||||||
|
try {
|
||||||
|
const result = await syncAccount(accountId)
|
||||||
|
setSyncMessage(result.ok ? `Синхронизировано: ${result.synced?.vpsCount ?? 0} VPS, ${result.synced?.paymentsCount ?? 0} платежей${result.synced?.balance ? ', баланс обновлён' : ''}` : result.error || 'Ошибка')
|
||||||
|
if (result.ok) await actions.refreshData()
|
||||||
|
} catch (err) {
|
||||||
|
setSyncMessage(err.message || 'Ошибка синхронизации')
|
||||||
|
} finally {
|
||||||
|
setSyncLoadingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSyncAll = async () => {
|
||||||
|
if (billmanagerAccounts.length === 0) return
|
||||||
|
setSyncLoadingAll(true)
|
||||||
|
setSyncMessage(null)
|
||||||
|
let totalVps = 0
|
||||||
|
let totalPayments = 0
|
||||||
|
let lastError = null
|
||||||
|
for (const account of billmanagerAccounts) {
|
||||||
|
try {
|
||||||
|
const result = await syncAccount(account.id)
|
||||||
|
if (result.ok) {
|
||||||
|
totalVps += result.synced?.vpsCount ?? 0
|
||||||
|
totalPayments += result.synced?.paymentsCount ?? 0
|
||||||
|
} else {
|
||||||
|
lastError = result.error
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastError && totalVps === 0 && totalPayments === 0) {
|
||||||
|
setSyncMessage(lastError)
|
||||||
|
} else {
|
||||||
|
setSyncMessage(`Синхронизировано: ${totalVps} VPS, ${totalPayments} платежей${lastError ? `. Ошибки: ${lastError}` : ''}`)
|
||||||
|
}
|
||||||
|
if (totalVps > 0 || totalPayments > 0) await actions.refreshData()
|
||||||
|
setSyncLoadingAll(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader pretitle="Справочники" title="Аккаунты хостеров" />
|
||||||
|
<div className="row row-cards">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Аккаунты и привязанные VPS</h3>
|
||||||
|
{syncMessage ? (
|
||||||
|
<div className={`alert alert-${syncMessage.startsWith('Синхронизировано') ? 'success' : 'warning'} py-2 mb-0 me-2`}>
|
||||||
|
{syncMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="card-actions d-flex gap-2">
|
||||||
|
{billmanagerAccounts.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={onSyncAll}
|
||||||
|
disabled={syncLoadingAll}
|
||||||
|
title="Синхронизировать VPS и платежи со всех BILLmanager аккаунтов"
|
||||||
|
>
|
||||||
|
{syncLoadingAll ? (
|
||||||
|
<span className="spinner-border spinner-border-sm me-1" role="status" />
|
||||||
|
) : (
|
||||||
|
<IconRefresh size={16} className="me-1" />
|
||||||
|
)}
|
||||||
|
Синхронизировать VPS
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setForm(emptyForm)
|
||||||
|
setEditingId(null)
|
||||||
|
setIsModalOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Добавить аккаунт
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Аккаунт</th>
|
||||||
|
<th>Хостер</th>
|
||||||
|
<th>VPS</th>
|
||||||
|
<th>Баланс</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{db.providerAccounts.map((account) => {
|
||||||
|
const provider = db.providers.find((item) => item.id === account.providerId)
|
||||||
|
const linkedVps = db.vps.filter((item) => item.providerAccountId === account.id)
|
||||||
|
return (
|
||||||
|
<tr key={account.id}>
|
||||||
|
<td>{account.name}</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{faviconUrlFromWebsite(provider?.website) ? (
|
||||||
|
<img
|
||||||
|
src={faviconUrlFromWebsite(provider?.website)}
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span>{provider?.name || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{linkedVps.map((item) => item.dns || item.ip).join(', ') || '-'}</td>
|
||||||
|
<td>
|
||||||
|
<ConvertedAmount
|
||||||
|
amount={getDisplayBalance(account)}
|
||||||
|
currency={getDisplayCurrency(account)}
|
||||||
|
provider={provider}
|
||||||
|
settings={settings}
|
||||||
|
ratesData={ratesData}
|
||||||
|
/>
|
||||||
|
{account.balance_updated_at ? (
|
||||||
|
<div className="text-secondary small mt-1">
|
||||||
|
Обновлено: {new Date(account.balance_updated_at).toLocaleString('ru-RU')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{account.enoughmoneyto ? (
|
||||||
|
<div className="text-secondary small mt-1">
|
||||||
|
Хватит до: {account.enoughmoneyto}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<div className="table-actions d-flex gap-1 flex-wrap justify-content-end">
|
||||||
|
{account.apiType === 'billmanager' && account.apiBaseUrl ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
onClick={() => onFetchBalance(account.id)}
|
||||||
|
disabled={balanceLoadingId === account.id}
|
||||||
|
title="Обновить баланс из API"
|
||||||
|
>
|
||||||
|
{balanceLoadingId === account.id ? (
|
||||||
|
<span className="spinner-border spinner-border-sm me-1" role="status" />
|
||||||
|
) : (
|
||||||
|
<IconRefresh size={14} className="me-1" />
|
||||||
|
)}
|
||||||
|
Баланс
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
onClick={() => onSync(account.id)}
|
||||||
|
disabled={syncLoadingId === account.id}
|
||||||
|
title="Синхронизировать VPS и платежи с BILLmanager"
|
||||||
|
>
|
||||||
|
{syncLoadingId === account.id ? (
|
||||||
|
<span className="spinner-border spinner-border-sm me-1" role="status" />
|
||||||
|
) : (
|
||||||
|
<IconRefresh size={14} className="me-1" />
|
||||||
|
)}
|
||||||
|
Синхронизировать
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-primary"
|
||||||
|
onClick={() => onEdit(account)}
|
||||||
|
>
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
onClick={() => actions.remove('providerAccounts', account.id)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{db.providerAccounts.length === 0 ? (
|
||||||
|
<EmptyState message="Нет аккаунтов хостеров" colSpan={5} />
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UiModal
|
||||||
|
open={isModalOpen}
|
||||||
|
title={editingId ? 'Редактировать аккаунт' : 'Новый аккаунт хостера'}
|
||||||
|
onClose={() => {
|
||||||
|
setIsModalOpen(false)
|
||||||
|
setEditingId(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
setTestConnectionResult(null)
|
||||||
|
setSaveError(null)
|
||||||
|
}}
|
||||||
|
size="modal-md"
|
||||||
|
>
|
||||||
|
<form onSubmit={onSubmit} className="row g-3">
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Хостер</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={form.providerId}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, providerId: e.target.value }))}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Выберите хостера</option>
|
||||||
|
{db.providers.map((provider) => (
|
||||||
|
<option key={provider.id} value={provider.id}>
|
||||||
|
{provider.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Имя / Псевдоним аккаунта</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Ссылка на панель управления</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
placeholder="https://..."
|
||||||
|
value={form.panelUrl}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, panelUrl: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-sm-6">
|
||||||
|
<label className="form-label">Валюта</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={form.currency}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, currency: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option>USD</option>
|
||||||
|
<option>EUR</option>
|
||||||
|
<option>RUB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-sm-6">
|
||||||
|
<label className="form-label">Режим списания</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={form.billingMode}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, billingMode: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="daily">{billingModeLabel('daily')}</option>
|
||||||
|
<option value="monthly">{billingModeLabel('monthly')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<hr className="my-2" />
|
||||||
|
<h6 className="text-secondary mb-2">Интеграция API</h6>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Тип API</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={form.apiType}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, apiType: e.target.value, apiBaseUrl: '', apiLogin: '', apiPassword: '' }))}
|
||||||
|
>
|
||||||
|
<option value="">— Не использовать —</option>
|
||||||
|
<option value="billmanager">BILLmanager</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{form.apiType === 'billmanager' ? (
|
||||||
|
<>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">URL API BILLmanager</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
placeholder="https://bill.example.com:1500/billmgr"
|
||||||
|
value={form.apiBaseUrl}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, apiBaseUrl: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-sm-6">
|
||||||
|
<label className="form-label">Логин</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
placeholder="admin"
|
||||||
|
value={form.apiLogin}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, apiLogin: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-sm-6">
|
||||||
|
<label className="form-label">Пароль</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
placeholder={editingAccount?.apiCredentialsSet ? 'Оставьте пустым, чтобы не менять' : ''}
|
||||||
|
value={form.apiPassword}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, apiPassword: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 d-flex align-items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
onClick={onTestConnection}
|
||||||
|
disabled={!canTestConnection || testConnectionLoading}
|
||||||
|
>
|
||||||
|
{testConnectionLoading ? (
|
||||||
|
<span className="spinner-border spinner-border-sm me-1" role="status" />
|
||||||
|
) : (
|
||||||
|
<IconPlugConnected size={14} className="me-1" />
|
||||||
|
)}
|
||||||
|
Проверить соединение
|
||||||
|
</button>
|
||||||
|
{testConnectionResult ? (
|
||||||
|
<span className={testConnectionResult.ok ? 'text-success small' : 'text-danger small'}>
|
||||||
|
{testConnectionResult.ok
|
||||||
|
? `Соединение успешно${testConnectionResult.vdsCount != null ? `, VDS: ${testConnectionResult.vdsCount}` : ''}`
|
||||||
|
: testConnectionResult.error}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Комментарий</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{saveError ? (
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="alert alert-danger py-2">{saveError}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="col-12 d-flex gap-2 justify-content-end">
|
||||||
|
<button type="button" className="btn btn-outline-secondary" onClick={() => setIsModalOpen(false)}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
{editingId ? 'Сохранить' : 'Добавить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</UiModal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
billingModeLabel,
|
||||||
|
paymentTypeLabel,
|
||||||
|
} from '../lib/utils'
|
||||||
|
import { UiModal } from '../components/UiModal'
|
||||||
|
import { EmptyState } from '../components/EmptyState'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
type: 'daily_debit',
|
||||||
|
providerAccountId: '',
|
||||||
|
vpsId: '',
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
amount: '',
|
||||||
|
currency: 'USD',
|
||||||
|
note: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BalancePage({ db, actions, settings, ratesData }) {
|
||||||
|
const [form, setForm] = useState(emptyForm)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const vpsOptions = useMemo(
|
||||||
|
() => db.vps.filter((vps) => !form.providerAccountId || vps.providerAccountId === form.providerAccountId),
|
||||||
|
[db.vps, form.providerAccountId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const accountBalances = useMemo(() => {
|
||||||
|
return db.providerAccounts.map((account) => {
|
||||||
|
const records = db.balanceLedger.filter((item) => item.providerAccountId === account.id)
|
||||||
|
const value = records.reduce((acc, row) => {
|
||||||
|
const amount = Number(row.amount || 0)
|
||||||
|
return row.direction === 'credit' ? acc + amount : acc - amount
|
||||||
|
}, 0)
|
||||||
|
return { ...account, balance: value }
|
||||||
|
})
|
||||||
|
}, [db.balanceLedger, db.providerAccounts])
|
||||||
|
|
||||||
|
const onSubmit = (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const amount = Number(form.amount)
|
||||||
|
if (!form.providerAccountId) {
|
||||||
|
setError('Выберите аккаунт')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
setError('Сумма должна быть больше 0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
actions.create('balanceLedger', {
|
||||||
|
type: form.type,
|
||||||
|
providerAccountId: form.providerAccountId,
|
||||||
|
vpsId: form.vpsId || '',
|
||||||
|
date: form.date,
|
||||||
|
amount,
|
||||||
|
currency: form.currency,
|
||||||
|
direction: 'debit',
|
||||||
|
note: form.note,
|
||||||
|
})
|
||||||
|
setForm(emptyForm)
|
||||||
|
setIsModalOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionLabel = (direction) => (direction === 'credit' ? 'Пополнение' : 'Списание')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader pretitle="Финансы" title="Баланс и списания" />
|
||||||
|
<div className="row row-cards">
|
||||||
|
<div className="col-12 card-stack">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Баланс по аккаунтам</h3>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button className="btn btn-primary" type="button" onClick={() => setIsModalOpen(true)}>
|
||||||
|
Добавить списание
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Аккаунт</th>
|
||||||
|
<th>Режим</th>
|
||||||
|
<th>Баланс</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{accountBalances.map((account) => (
|
||||||
|
<tr key={account.id}>
|
||||||
|
<td>{account.name}</td>
|
||||||
|
<td>{billingModeLabel(account.billingMode)}</td>
|
||||||
|
<td>
|
||||||
|
<ConvertedAmount
|
||||||
|
amount={account.balance}
|
||||||
|
currency={account.currency}
|
||||||
|
provider={db.providers.find((item) => item.id === account.providerId)}
|
||||||
|
settings={settings}
|
||||||
|
ratesData={ratesData}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{accountBalances.length === 0 ? (
|
||||||
|
<EmptyState message="Нет аккаунтов" colSpan={3} />
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Журнал операций</h3>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Направление</th>
|
||||||
|
<th>Аккаунт / VPS</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{db.balanceLedger.map((row) => {
|
||||||
|
const account = db.providerAccounts.find((item) => item.id === row.providerAccountId)
|
||||||
|
const provider = db.providers.find((item) => item.id === account?.providerId)
|
||||||
|
const vps = db.vps.find((item) => item.id === row.vpsId)
|
||||||
|
return (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td>{row.date}</td>
|
||||||
|
<td>{paymentTypeLabel(row.type)}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${row.direction === 'credit' ? 'bg-green-lt' : 'bg-red-lt'}`}>
|
||||||
|
{directionLabel(row.direction)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>{account?.name || '-'}</div>
|
||||||
|
<div className="text-secondary">{vps?.dns || vps?.ip || '-'}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ConvertedAmount
|
||||||
|
amount={row.amount}
|
||||||
|
currency={row.currency}
|
||||||
|
provider={provider}
|
||||||
|
settings={settings}
|
||||||
|
ratesData={ratesData}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<div className="table-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => actions.remove('balanceLedger', row.id)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{db.balanceLedger.length === 0 ? (
|
||||||
|
<EmptyState message="Журнал операций пуст" colSpan={6} />
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UiModal
|
||||||
|
open={isModalOpen}
|
||||||
|
title="Добавить списание"
|
||||||
|
onClose={() => {
|
||||||
|
setIsModalOpen(false)
|
||||||
|
setError('')
|
||||||
|
setForm(emptyForm)
|
||||||
|
}}
|
||||||
|
size="modal-md"
|
||||||
|
>
|
||||||
|
<form className="row g-3" onSubmit={onSubmit}>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Тип списания</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={form.type}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="daily_debit">Ежедневное списание</option>
|
||||||
|
<option value="monthly_debit">Ежемесячное списание</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Аккаунт</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={form.providerAccountId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, providerAccountId: e.target.value, vpsId: '' }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Выберите аккаунт</option>
|
||||||
|
{db.providerAccounts.map((account) => (
|
||||||
|
<option key={account.id} value={account.id}>
|
||||||
|
{account.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">VPS (необязательно)</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={form.vpsId}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, vpsId: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">Без привязки</option>
|
||||||
|
{vpsOptions.map((vps) => (
|
||||||
|
<option key={vps.id} value={vps.id}>
|
||||||
|
{vps.dns || vps.ip}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-sm-6">
|
||||||
|
<label className="form-label">Дата</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
type="date"
|
||||||
|
value={form.date}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-sm-6">
|
||||||
|
<label className="form-label">Валюта</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={form.currency}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, currency: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option>USD</option>
|
||||||
|
<option>EUR</option>
|
||||||
|
<option>RUB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Сумма</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
className="form-control"
|
||||||
|
value={form.amount}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, amount: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Комментарий</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
value={form.note}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, note: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? <div className="col-12 text-danger small">{error}</div> : null}
|
||||||
|
<div className="col-12 d-flex gap-2 justify-content-end">
|
||||||
|
<button type="button" className="btn btn-outline-secondary" onClick={() => setIsModalOpen(false)}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" type="submit">
|
||||||
|
Добавить списание
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</UiModal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
billingModeLabel,
|
||||||
|
convertCurrency,
|
||||||
|
formatCurrency,
|
||||||
|
monthKey,
|
||||||
|
} from '../lib/utils'
|
||||||
|
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||||
|
import { EmptyState } from '../components/EmptyState'
|
||||||
|
import { ExpenseChart } from '../components/ExpenseChart'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { ProviderPieChart } from '../components/ProviderPieChart'
|
||||||
|
import {
|
||||||
|
IconCash,
|
||||||
|
IconClockHour4,
|
||||||
|
IconServer,
|
||||||
|
IconWallet,
|
||||||
|
} from '@tabler/icons-react'
|
||||||
|
|
||||||
|
export function DashboardPage({ db = {}, settings, ratesData }) {
|
||||||
|
const vps = Array.isArray(db.vps) ? db.vps : []
|
||||||
|
const providerAccounts = Array.isArray(db.providerAccounts) ? db.providerAccounts : []
|
||||||
|
const balanceLedger = Array.isArray(db.balanceLedger) ? db.balanceLedger : []
|
||||||
|
const payments = Array.isArray(db.payments) ? db.payments : []
|
||||||
|
const providers = Array.isArray(db.providers) ? db.providers : []
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
|
||||||
|
|
||||||
|
const activeVpsCount = vps.filter((item) => item.status === 'active').length
|
||||||
|
|
||||||
|
const monthForecast = useMemo(() => {
|
||||||
|
return vps
|
||||||
|
.filter((item) => item.status === 'active')
|
||||||
|
.reduce((acc, item) => {
|
||||||
|
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
||||||
|
const amount =
|
||||||
|
tariffType === 'daily'
|
||||||
|
? Number(item.dailyRate || 0) * 30
|
||||||
|
: Number(item.monthlyRate || 0)
|
||||||
|
return acc + convertCurrency(amount, item.currency || 'USD', baseCurrency, ratesData)
|
||||||
|
}, 0)
|
||||||
|
}, [vps, baseCurrency, ratesData])
|
||||||
|
|
||||||
|
const monthExpenses = useMemo(() => {
|
||||||
|
return [...payments, ...balanceLedger]
|
||||||
|
.filter((item) => monthKey(item.date) === currentMonth)
|
||||||
|
.filter((item) => {
|
||||||
|
if (item.type === 'provider_balance_topup') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(acc, item) =>
|
||||||
|
acc + convertCurrency(item.amount || 0, item.currency || 'USD', baseCurrency, ratesData),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}, [payments, balanceLedger, currentMonth, baseCurrency, ratesData])
|
||||||
|
|
||||||
|
const prevMonthKey = useMemo(() => {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
}, [currentMonth])
|
||||||
|
|
||||||
|
const prevMonthExpenses = useMemo(() => {
|
||||||
|
return [...payments, ...balanceLedger]
|
||||||
|
.filter((item) => monthKey(item.date) === prevMonthKey)
|
||||||
|
.filter((item) => item.type !== 'provider_balance_topup')
|
||||||
|
.reduce(
|
||||||
|
(acc, item) =>
|
||||||
|
acc + convertCurrency(item.amount || 0, item.currency || 'USD', baseCurrency, ratesData),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}, [payments, balanceLedger, prevMonthKey, baseCurrency, ratesData])
|
||||||
|
|
||||||
|
const monthlyExpenseData = useMemo(() => {
|
||||||
|
const months = []
|
||||||
|
const now = new Date()
|
||||||
|
for (let i = 11; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||||
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
const amount = [...payments, ...balanceLedger]
|
||||||
|
.filter((item) => monthKey(item.date) === key)
|
||||||
|
.filter((item) => item.type !== 'provider_balance_topup')
|
||||||
|
.reduce(
|
||||||
|
(acc, item) =>
|
||||||
|
acc + convertCurrency(item.amount || 0, item.currency || 'USD', baseCurrency, ratesData),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
months.push({
|
||||||
|
monthKey: key,
|
||||||
|
monthLabel: d.toLocaleDateString('ru-RU', { month: 'short', year: '2-digit' }),
|
||||||
|
amount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return months
|
||||||
|
}, [payments, balanceLedger, baseCurrency, ratesData])
|
||||||
|
|
||||||
|
const providerExpenseData = useMemo(() => {
|
||||||
|
const byProvider = {}
|
||||||
|
;[...payments, ...balanceLedger]
|
||||||
|
.filter((item) => item.type !== 'provider_balance_topup')
|
||||||
|
.forEach((item) => {
|
||||||
|
const vpsItem = item.vpsId ? vps.find((v) => v.id === item.vpsId) : null
|
||||||
|
const providerId = vpsItem?.providerId || (item.providerAccountId
|
||||||
|
? providerAccounts.find((a) => a.id === item.providerAccountId)?.providerId
|
||||||
|
: null)
|
||||||
|
const pid = providerId || 'unknown'
|
||||||
|
if (!byProvider[pid]) byProvider[pid] = 0
|
||||||
|
byProvider[pid] += convertCurrency(
|
||||||
|
item.amount || 0,
|
||||||
|
item.currency || 'USD',
|
||||||
|
baseCurrency,
|
||||||
|
ratesData,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return Object.entries(byProvider).map(([providerId, amount]) => ({
|
||||||
|
providerId,
|
||||||
|
providerName: providerId === 'unknown' ? '—' : (providers.find((p) => p.id === providerId)?.name || providerId),
|
||||||
|
amount,
|
||||||
|
}))
|
||||||
|
}, [payments, balanceLedger, vps, providerAccounts, providers, baseCurrency, ratesData])
|
||||||
|
|
||||||
|
const accountBalances = providerAccounts.map((account) => {
|
||||||
|
const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === account.id)
|
||||||
|
const credits = ledgerRows
|
||||||
|
.filter((row) => row.direction === 'credit')
|
||||||
|
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||||
|
const debits = ledgerRows
|
||||||
|
.filter((row) => row.direction === 'debit')
|
||||||
|
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||||
|
return { ...account, balance: credits - debits }
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalBalance = accountBalances.reduce(
|
||||||
|
(acc, row) => acc + convertCurrency(row.balance, row.currency, baseCurrency, ratesData),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const upcoming = providerAccounts
|
||||||
|
.map((account) => ({
|
||||||
|
...account,
|
||||||
|
nextDate:
|
||||||
|
account.billingMode === 'daily'
|
||||||
|
? new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||||
|
: new Date(now.getFullYear(), now.getMonth() + 1, 1),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.nextDate - b.nextDate)
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader pretitle="Обзор" title="Дашборд" />
|
||||||
|
<div className="row row-cards">
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<div className="card metric-card metric-blue h-100">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center justify-content-between">
|
||||||
|
<div className="text-secondary">Активные VPS</div>
|
||||||
|
<span className="metric-icon bg-blue-lt text-blue">
|
||||||
|
<IconServer size={18} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-value">{activeVpsCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<div className="card metric-card metric-green h-100">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center justify-content-between">
|
||||||
|
<div className="text-secondary">Расходы за месяц</div>
|
||||||
|
<span className="metric-icon bg-green-lt text-green">
|
||||||
|
<IconCash size={18} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-value">{formatCurrency(monthExpenses, baseCurrency)}</div>
|
||||||
|
<div className="text-secondary small mt-1">
|
||||||
|
Прогноз: {formatCurrency(monthForecast, baseCurrency)}
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary small">
|
||||||
|
Прошлый месяц: {formatCurrency(prevMonthExpenses, baseCurrency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<div className="card metric-card metric-yellow h-100">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center justify-content-between">
|
||||||
|
<div className="text-secondary">Аккаунтов хостеров</div>
|
||||||
|
<span className="metric-icon bg-yellow-lt text-yellow">
|
||||||
|
<IconClockHour4 size={18} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-value">{providerAccounts.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6 col-lg-3">
|
||||||
|
<div className="card metric-card metric-purple h-100">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center justify-content-between">
|
||||||
|
<div className="text-secondary">Суммарный баланс</div>
|
||||||
|
<span className="metric-icon bg-purple-lt text-purple">
|
||||||
|
<IconWallet size={18} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-value">{formatCurrency(totalBalance, baseCurrency)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 col-xl-6">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Расходы по месяцам</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<ExpenseChart
|
||||||
|
data={monthlyExpenseData}
|
||||||
|
baseCurrency={baseCurrency}
|
||||||
|
formatCurrency={formatCurrency}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 col-xl-6">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Расходы по хостеру</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<ProviderPieChart
|
||||||
|
data={providerExpenseData}
|
||||||
|
baseCurrency={baseCurrency}
|
||||||
|
formatCurrency={formatCurrency}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 col-xl-7">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Остатки по аккаунтам хостеров</h3>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Аккаунт</th>
|
||||||
|
<th>Валюта</th>
|
||||||
|
<th>Баланс</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{accountBalances.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>{item.name}</td>
|
||||||
|
<td>{item.currency}</td>
|
||||||
|
<td>
|
||||||
|
<ConvertedAmount
|
||||||
|
amount={item.balance}
|
||||||
|
currency={item.currency}
|
||||||
|
provider={db.providers.find((provider) => provider.id === item.providerId)}
|
||||||
|
settings={settings}
|
||||||
|
ratesData={ratesData}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{accountBalances.length === 0 ? (
|
||||||
|
<EmptyState message="Пока нет аккаунтов" colSpan={3} />
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 col-xl-5">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Ближайшие списания</h3>
|
||||||
|
</div>
|
||||||
|
<div className="list-group list-group-flush">
|
||||||
|
{upcoming.map((item) => (
|
||||||
|
<div key={item.id} className="list-group-item">
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div className="fw-medium">{item.name}</div>
|
||||||
|
<div className="text-secondary small">{billingModeLabel(item.billingMode)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary">
|
||||||
|
{item.nextDate.toLocaleDateString('ru-RU')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{upcoming.length === 0 ? (
|
||||||
|
<div className="list-group-item text-secondary text-center py-4">Списаний пока нет</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { paymentTypeLabel } from '../lib/utils'
|
||||||
|
import { UiModal } from '../components/UiModal'
|
||||||
|
import { EmptyState } from '../components/EmptyState'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
type: 'direct_vps_payment',
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
amount: '',
|
||||||
|
currency: 'USD',
|
||||||
|
providerAccountId: '',
|
||||||
|
vpsId: '',
|
||||||
|
note: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentsPage({ db, actions, settings, ratesData }) {
|
||||||
|
const [form, setForm] = useState(emptyForm)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const vpsOptions = useMemo(
|
||||||
|
() => db.vps.filter((item) => !form.providerAccountId || item.providerAccountId === form.providerAccountId),
|
||||||
|
[db.vps, form.providerAccountId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onSubmit = (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const amount = Number(form.amount)
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
setError('Сумма должна быть больше 0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.providerAccountId) {
|
||||||
|
setError('Выберите аккаунт хостера')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.type === 'direct_vps_payment' && !form.vpsId) {
|
||||||
|
setError('Для прямого платежа выберите VPS')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
actions.create('payments', {
|
||||||
|
type: form.type,
|
||||||
|
date: form.date,
|
||||||
|
amount,
|
||||||
|
currency: form.currency,
|
||||||
|
providerAccountId: form.providerAccountId,
|
||||||
|
vpsId: form.vpsId || '',
|
||||||
|
note: form.note,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (form.type === 'provider_balance_topup') {
|
||||||
|
actions.create('balanceLedger', {
|
||||||
|
type: 'provider_balance_topup',
|
||||||
|
date: form.date,
|
||||||
|
amount,
|
||||||
|
currency: form.currency,
|
||||||
|
direction: 'credit',
|
||||||
|
providerAccountId: form.providerAccountId,
|
||||||
|
vpsId: '',
|
||||||
|
note: form.note || 'Пополнение баланса',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setForm(emptyForm)
|
||||||
|
setIsModalOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader pretitle="Финансы" title="Платежи" />
|
||||||
|
<div className="row row-cards">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">История платежей</h3>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setIsModalOpen(true)}>
|
||||||
|
Добавить платеж
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Аккаунт / VPS</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{db.payments.map((payment) => {
|
||||||
|
const account = db.providerAccounts.find((item) => item.id === payment.providerAccountId)
|
||||||
|
const provider = db.providers.find((item) => item.id === account?.providerId)
|
||||||
|
const vps = db.vps.find((item) => item.id === payment.vpsId)
|
||||||
|
return (
|
||||||
|
<tr key={payment.id}>
|
||||||
|
<td>{payment.date}</td>
|
||||||
|
<td>{paymentTypeLabel(payment.type)}</td>
|
||||||
|
<td>
|
||||||
|
<div>{account?.name || '-'}</div>
|
||||||
|
<div className="text-secondary">{vps?.dns || vps?.ip || '-'}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ConvertedAmount
|
||||||
|
amount={payment.amount}
|
||||||
|
currency={payment.currency}
|
||||||
|
provider={provider}
|
||||||
|
settings={settings}
|
||||||
|
ratesData={ratesData}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<div className="table-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
onClick={() => actions.remove('payments', payment.id)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{db.payments.length === 0 ? (
|
||||||
|
<EmptyState message="Нет платежей" colSpan={5} />
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UiModal
|
||||||
|
open={isModalOpen}
|
||||||
|
title="Новая операция платежа"
|
||||||
|
onClose={() => {
|
||||||
|
setIsModalOpen(false)
|
||||||
|
setError('')
|
||||||
|
setForm(emptyForm)
|
||||||
|
}}
|
||||||
|
size="modal-md"
|
||||||
|
>
|
||||||
|
<form className="row g-3" onSubmit={onSubmit}>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Тип</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={form.type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: e.target.value,
|
||||||
|
vpsId: '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="direct_vps_payment">Прямой платеж за VPS</option>
|
||||||
|
<option value="provider_balance_topup">Пополнение баланса хостера</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Аккаунт хостера</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={form.providerAccountId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
providerAccountId: e.target.value,
|
||||||
|
vpsId: '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Выберите аккаунт</option>
|
||||||
|
{db.providerAccounts.map((account) => (
|
||||||
|
<option key={account.id} value={account.id}>
|
||||||
|
{account.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{form.type === 'direct_vps_payment' ? (
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">VPS</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={form.vpsId}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, vpsId: e.target.value }))}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Выберите VPS</option>
|
||||||
|
{vpsOptions.map((vps) => (
|
||||||
|
<option key={vps.id} value={vps.id}>
|
||||||
|
{vps.dns || vps.ip}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="col-12 col-sm-6">
|
||||||
|
<label className="form-label">Дата</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-control"
|
||||||
|
value={form.date}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-sm-6">
|
||||||
|
<label className="form-label">Валюта</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={form.currency}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, currency: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option>USD</option>
|
||||||
|
<option>EUR</option>
|
||||||
|
<option>RUB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Сумма</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
className="form-control"
|
||||||
|
value={form.amount}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, amount: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Комментарий</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
value={form.note}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, note: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? <div className="col-12 text-danger small">{error}</div> : null}
|
||||||
|
<div className="col-12 d-flex gap-2 justify-content-end">
|
||||||
|
<button type="button" className="btn btn-outline-secondary" onClick={() => setIsModalOpen(false)}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</UiModal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { UiModal } from '../components/UiModal'
|
||||||
|
import { EmptyState } from '../components/EmptyState'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { faviconUrlFromWebsite } from '../lib/utils'
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
name: '',
|
||||||
|
website: '',
|
||||||
|
contact: '',
|
||||||
|
baseCurrency: 'RUB',
|
||||||
|
usdRate: '',
|
||||||
|
eurRate: '',
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProvidersPage({ db, actions }) {
|
||||||
|
const [form, setForm] = useState(emptyForm)
|
||||||
|
const [editingId, setEditingId] = useState(null)
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const onSubmit = (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (editingId) {
|
||||||
|
actions.update('providers', editingId, form)
|
||||||
|
} else {
|
||||||
|
actions.create('providers', form)
|
||||||
|
}
|
||||||
|
setForm(emptyForm)
|
||||||
|
setEditingId(null)
|
||||||
|
setIsModalOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEdit = (provider) => {
|
||||||
|
setForm({
|
||||||
|
name: provider.name || '',
|
||||||
|
website: provider.website || '',
|
||||||
|
contact: provider.contact || '',
|
||||||
|
baseCurrency: provider.baseCurrency || 'RUB',
|
||||||
|
usdRate: provider.usdRate || '',
|
||||||
|
eurRate: provider.eurRate || '',
|
||||||
|
notes: provider.notes || '',
|
||||||
|
})
|
||||||
|
setEditingId(provider.id)
|
||||||
|
setIsModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader pretitle="Справочники" title="Хостеры" />
|
||||||
|
<div className="row row-cards">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Список хостеров</h3>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setForm(emptyForm)
|
||||||
|
setEditingId(null)
|
||||||
|
setIsModalOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Добавить хостера
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Сайт</th>
|
||||||
|
<th>Валюта / курсы</th>
|
||||||
|
<th>Контакт</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{db.providers.map((provider) => (
|
||||||
|
<tr key={provider.id}>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{faviconUrlFromWebsite(provider.website) ? (
|
||||||
|
<img
|
||||||
|
src={faviconUrlFromWebsite(provider.website)}
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span>{provider.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{provider.website || '-'}</td>
|
||||||
|
<td>
|
||||||
|
<div>{provider.baseCurrency || 'RUB'}</div>
|
||||||
|
<div className="text-secondary small">
|
||||||
|
USD: {provider.usdRate || 'auto'} / EUR: {provider.eurRate || 'auto'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{provider.contact || '-'}</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<div className="table-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-primary"
|
||||||
|
onClick={() => onEdit(provider)}
|
||||||
|
>
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
onClick={() => actions.remove('providers', provider.id)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{db.providers.length === 0 ? (
|
||||||
|
<EmptyState message="Пока нет хостеров" colSpan={5} />
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UiModal
|
||||||
|
open={isModalOpen}
|
||||||
|
title={editingId ? 'Редактировать хостера' : 'Добавить хостера'}
|
||||||
|
onClose={() => {
|
||||||
|
setIsModalOpen(false)
|
||||||
|
setEditingId(null)
|
||||||
|
setForm(emptyForm)
|
||||||
|
}}
|
||||||
|
size="modal-md"
|
||||||
|
>
|
||||||
|
<form onSubmit={onSubmit} className="row g-3">
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Название</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Сайт</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
value={form.website}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, website: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Контакт</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
value={form.contact}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, contact: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-sm-4">
|
||||||
|
<label className="form-label">Валюта приёма платежей</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={form.baseCurrency}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, baseCurrency: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option>RUB</option>
|
||||||
|
<option>USD</option>
|
||||||
|
<option>EUR</option>
|
||||||
|
</select>
|
||||||
|
<div className="text-secondary small">Валюта, в которой хостер принимает платежи</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-sm-4">
|
||||||
|
<label className="form-label">Курс USD</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.0001"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="auto"
|
||||||
|
value={form.usdRate}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, usdRate: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-sm-4">
|
||||||
|
<label className="form-label">Курс EUR</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.0001"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="auto"
|
||||||
|
value={form.eurRate}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, eurRate: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Заметки</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 d-flex gap-2 justify-content-end">
|
||||||
|
<button type="button" className="btn btn-outline-secondary" onClick={() => setIsModalOpen(false)}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
{editingId ? 'Сохранить' : 'Добавить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</UiModal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
convertCurrency,
|
||||||
|
downloadTextFile,
|
||||||
|
formatCurrency,
|
||||||
|
monthKey,
|
||||||
|
toCsv,
|
||||||
|
vpsStatusLabel,
|
||||||
|
} from '../lib/utils'
|
||||||
|
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||||
|
import { EmptyState } from '../components/EmptyState'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
|
||||||
|
export function ReportsPage({ db, settings, ratesData }) {
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
providerId: '',
|
||||||
|
country: '',
|
||||||
|
month: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
return db.vps
|
||||||
|
.filter((vps) => {
|
||||||
|
const byProvider = !filters.providerId || vps.providerId === filters.providerId
|
||||||
|
const byCountry =
|
||||||
|
!filters.country || vps.country?.toLowerCase().includes(filters.country.toLowerCase())
|
||||||
|
return byProvider && byCountry
|
||||||
|
})
|
||||||
|
.map((vps) => {
|
||||||
|
const provider = db.providers.find((item) => item.id === vps.providerId)
|
||||||
|
const payments = db.payments.filter((item) => item.vpsId === vps.id)
|
||||||
|
const monthlyPayments = filters.month
|
||||||
|
? payments.filter((item) => monthKey(item.date) === filters.month)
|
||||||
|
: payments
|
||||||
|
const total = monthlyPayments.reduce((acc, item) => acc + Number(item.amount || 0), 0)
|
||||||
|
return {
|
||||||
|
providerId: vps.providerId,
|
||||||
|
provider: provider?.name || '-',
|
||||||
|
vps: vps.dns || vps.ip,
|
||||||
|
ip: vps.ip,
|
||||||
|
country: vps.country || '',
|
||||||
|
city: vps.city || '',
|
||||||
|
status: vps.status,
|
||||||
|
expense: Number(total.toFixed(2)),
|
||||||
|
currency: vps.currency || 'USD',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [db.payments, db.providers, db.vps, filters])
|
||||||
|
|
||||||
|
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
|
||||||
|
const totalExpense = rows.reduce(
|
||||||
|
(acc, row) => acc + convertCurrency(row.expense, row.currency, baseCurrency, ratesData),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader pretitle="Аналитика" title="Отчёты" />
|
||||||
|
<div className="row row-cards">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Фильтры и экспорт</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row g-2 align-items-end">
|
||||||
|
<div className="col-12 col-md-6 col-xl-3">
|
||||||
|
<label className="form-label">Хостер</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={filters.providerId}
|
||||||
|
onChange={(e) => setFilters((prev) => ({ ...prev, providerId: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">Все хостеры</option>
|
||||||
|
{db.providers.map((provider) => (
|
||||||
|
<option key={provider.id} value={provider.id}>
|
||||||
|
{provider.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 col-xl-3">
|
||||||
|
<label className="form-label">Страна</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
value={filters.country}
|
||||||
|
onChange={(e) => setFilters((prev) => ({ ...prev, country: e.target.value }))}
|
||||||
|
placeholder="например Германия"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 col-xl-3">
|
||||||
|
<label className="form-label">Период (YYYY-MM)</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
value={filters.month}
|
||||||
|
onChange={(e) => setFilters((prev) => ({ ...prev, month: e.target.value }))}
|
||||||
|
placeholder="2026-03"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 col-xl-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary w-100"
|
||||||
|
onClick={() => {
|
||||||
|
const csv = toCsv(rows)
|
||||||
|
downloadTextFile('vps-report.csv', csv)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Экспорт CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 col-xl-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary w-100"
|
||||||
|
onClick={() => {
|
||||||
|
downloadTextFile(
|
||||||
|
'vps-tracker-backup.json',
|
||||||
|
JSON.stringify(db, null, 2),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Резервная копия JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Сводный отчет</h3>
|
||||||
|
<div className="card-actions text-secondary">
|
||||||
|
Итого: {formatCurrency(totalExpense, baseCurrency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Хостер</th>
|
||||||
|
<th>VPS</th>
|
||||||
|
<th>Локация</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Расход</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={`${row.ip}-${row.vps}`}>
|
||||||
|
<td>{row.provider}</td>
|
||||||
|
<td>
|
||||||
|
<div>{row.vps}</div>
|
||||||
|
<div className="text-secondary">{row.ip}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{row.country} / {row.city}
|
||||||
|
</td>
|
||||||
|
<td>{vpsStatusLabel(row.status)}</td>
|
||||||
|
<td>
|
||||||
|
<ConvertedAmount
|
||||||
|
amount={row.expense}
|
||||||
|
currency={row.currency}
|
||||||
|
provider={db.providers.find((item) => item.id === row.providerId)}
|
||||||
|
settings={settings}
|
||||||
|
ratesData={ratesData}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<EmptyState message="Нет данных под фильтр" colSpan={5} />
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { IconPlus, IconTrash } from '@tabler/icons-react'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
|
||||||
|
const defaultSettings = {
|
||||||
|
baseCurrency: 'RUB',
|
||||||
|
ratesUrl: 'https://www.cbr-xml-daily.ru/latest.js',
|
||||||
|
autoConvert: true,
|
||||||
|
syncEnabled: false,
|
||||||
|
syncIntervalMinutes: 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||||
|
const current = db.settings?.[0] || defaultSettings
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
baseCurrency: current.baseCurrency || 'RUB',
|
||||||
|
ratesUrl: current.ratesUrl || 'https://www.cbr-xml-daily.ru/latest.js',
|
||||||
|
autoConvert: current.autoConvert !== false,
|
||||||
|
syncEnabled: current.syncEnabled !== false && Boolean(current.syncEnabled),
|
||||||
|
syncIntervalMinutes: current.syncIntervalMinutes ?? 60,
|
||||||
|
})
|
||||||
|
const [newFieldLabel, setNewFieldLabel] = useState('')
|
||||||
|
const customFields = Array.isArray(current.customFields) ? current.customFields : []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
/* eslint-disable-next-line react-hooks/set-state-in-effect -- sync form when settings change from parent */
|
||||||
|
setForm({
|
||||||
|
baseCurrency: current.baseCurrency || 'RUB',
|
||||||
|
ratesUrl: current.ratesUrl || 'https://www.cbr-xml-daily.ru/latest.js',
|
||||||
|
autoConvert: current.autoConvert !== false,
|
||||||
|
syncEnabled: Boolean(current.syncEnabled),
|
||||||
|
syncIntervalMinutes: current.syncIntervalMinutes ?? 60,
|
||||||
|
})
|
||||||
|
}, [current.baseCurrency, current.ratesUrl, current.autoConvert, current.syncEnabled, current.syncIntervalMinutes])
|
||||||
|
|
||||||
|
const availableCurrencies = useMemo(() => {
|
||||||
|
const list = new Set(['RUB', 'USD', 'EUR'])
|
||||||
|
if (ratesData?.rates) {
|
||||||
|
Object.keys(ratesData.rates).forEach((code) => list.add(code))
|
||||||
|
}
|
||||||
|
return [...list].sort()
|
||||||
|
}, [ratesData])
|
||||||
|
|
||||||
|
const onSubmit = (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
actions.upsertSettings({
|
||||||
|
baseCurrency: form.baseCurrency,
|
||||||
|
ratesUrl: form.ratesUrl,
|
||||||
|
autoConvert: form.autoConvert,
|
||||||
|
ratesUpdatedAt: ratesData?.date || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSyncSettingsSubmit = (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
actions.upsertSettings({
|
||||||
|
syncEnabled: form.syncEnabled,
|
||||||
|
syncIntervalMinutes: Math.max(15, Number(form.syncIntervalMinutes) || 60),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomField = () => {
|
||||||
|
const label = newFieldLabel.trim()
|
||||||
|
if (!label) return
|
||||||
|
const nextIndex = customFields.reduce((max, f) => {
|
||||||
|
const n = parseInt(f.key?.replace('cf_', '') || '0', 10)
|
||||||
|
return Math.max(max, n)
|
||||||
|
}, -1) + 1
|
||||||
|
const key = `cf_${nextIndex}`
|
||||||
|
actions.upsertSettings({ customFields: [...customFields, { key, label }] })
|
||||||
|
setNewFieldLabel('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCustomField = (key) => {
|
||||||
|
actions.upsertSettings({
|
||||||
|
customFields: customFields.filter((f) => f.key !== key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader pretitle="Система" title="Настройки" />
|
||||||
|
<div className="row row-cards">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Настройки валют</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<form className="row g-3" onSubmit={onSubmit}>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label">Валюта отображения</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={form.baseCurrency}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, baseCurrency: e.target.value }))}
|
||||||
|
>
|
||||||
|
{availableCurrencies.map((code) => (
|
||||||
|
<option key={code} value={code}>
|
||||||
|
{code}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label">Автоконвертация</label>
|
||||||
|
<label className="form-check">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.autoConvert}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, autoConvert: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">Показывать суммы в валюте отображения</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label">Ссылка на курсы валют</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
value={form.ratesUrl}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, ratesUrl: e.target.value }))}
|
||||||
|
placeholder="https://www.cbr-xml-daily.ru/latest.js"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 d-flex justify-content-end">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Сохранить настройки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="text-secondary small">
|
||||||
|
Валюта отображения — в какой валюте показывать суммы на дашбордах. Курсы хостера (если указаны)
|
||||||
|
имеют приоритет над глобальными курсами по ссылке выше.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 col-lg-6">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Дополнительные поля VPS</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-secondary small mb-3">
|
||||||
|
Текстовые поля для расширенного режима просмотра списка VPS. Отображаются как колонки в таблице и в форме редактирования.
|
||||||
|
</p>
|
||||||
|
<div className="d-flex gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Название поля (например: Контакт, ID заказа)"
|
||||||
|
value={newFieldLabel}
|
||||||
|
onChange={(e) => setNewFieldLabel(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustomField())}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={addCustomField}
|
||||||
|
>
|
||||||
|
<IconPlus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{customFields.length > 0 ? (
|
||||||
|
<ul className="list-group list-group-flush">
|
||||||
|
{customFields.map((f) => (
|
||||||
|
<li key={f.key} className="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span>{f.label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
onClick={() => removeCustomField(f.key)}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="text-secondary small">Нет дополнительных полей</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 col-lg-6">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Синхронизация с API хостеров</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-secondary small mb-3">
|
||||||
|
Периодическая синхронизация данных (VPS, платежи) из BILLmanager для аккаунтов с настроенным API.
|
||||||
|
</p>
|
||||||
|
<form className="row g-3" onSubmit={onSyncSettingsSubmit}>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-check">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.syncEnabled}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, syncEnabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">Включить периодическую синхронизацию</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label">Интервал (минуты)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="15"
|
||||||
|
className="form-control"
|
||||||
|
value={form.syncIntervalMinutes}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, syncIntervalMinutes: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 d-flex justify-content-end">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 col-lg-6">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Статус источника курсов</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-secondary">Источник:</span> {current.ratesUrl}
|
||||||
|
</div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-secondary">Дата курсов:</span> {ratesData?.date || '-'}
|
||||||
|
</div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-secondary">База API:</span> {ratesData?.base || '-'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-primary btn-sm mb-3"
|
||||||
|
onClick={() => actions.upsertSettings({ ratesUpdatedAt: new Date().toISOString() })}
|
||||||
|
>
|
||||||
|
Обновить курсы сейчас
|
||||||
|
</button>
|
||||||
|
{ratesError ? <div className="alert alert-danger py-2">{ratesError}</div> : null}
|
||||||
|
{!ratesError && ratesData ? (
|
||||||
|
<div className="alert alert-success py-2 mb-0">
|
||||||
|
Курсы загружены. Текущая конвертация работает автоматически.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { convertCurrency, faviconUrlFromWebsite, normalizeWebsiteUrl } from '../lib/utils'
|
||||||
|
import {
|
||||||
|
IconArrowDown,
|
||||||
|
IconArrowUp,
|
||||||
|
IconMapPin,
|
||||||
|
IconRefresh,
|
||||||
|
IconSearch,
|
||||||
|
IconServer,
|
||||||
|
} from '@tabler/icons-react'
|
||||||
|
import { syncAccount } from '../lib/api'
|
||||||
|
import { EmptyState } from '../components/EmptyState'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
|
||||||
|
const SORT_COLUMNS = ['name', 'vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization', 'channel', 'country', 'location', 'price']
|
||||||
|
|
||||||
|
function SortHeader({ column, children, onSort, sortBy, sortDir }) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onSort(column)}
|
||||||
|
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onSort(column)}
|
||||||
|
style={SORT_COLUMNS.includes(column) ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{sortBy === column && (sortDir === 'asc' ? <IconArrowUp size={14} className="ms-1" /> : <IconArrowDown size={14} className="ms-1" />)}
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePrice(priceStr) {
|
||||||
|
if (!priceStr || typeof priceStr !== 'string') return { amount: 0, currency: 'RUB' }
|
||||||
|
const match = priceStr.match(/([\d\s.,]+)\s*(RUB|USD|EUR|€|₽|\$)/i) || priceStr.match(/([\d\s.,]+)\s+([A-Z]{3})\b/i)
|
||||||
|
if (!match) return { amount: 0, currency: 'RUB' }
|
||||||
|
const amount = parseFloat(String(match[1]).replace(/\s/g, '').replace(',', '.')) || 0
|
||||||
|
let currency = 'RUB'
|
||||||
|
if (match[2]) {
|
||||||
|
if (match[2] === '€') currency = 'EUR'
|
||||||
|
else if (match[2] === '₽' || match[2].toUpperCase() === 'RUB') currency = 'RUB'
|
||||||
|
else if (match[2] === '$' || match[2].toUpperCase() === 'USD') currency = 'USD'
|
||||||
|
else currency = match[2].toUpperCase()
|
||||||
|
}
|
||||||
|
return { amount, currency }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TariffsPage({ db, actions, settings, ratesData }) {
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
search: '',
|
||||||
|
providerId: '',
|
||||||
|
providerAccountId: '',
|
||||||
|
country: '',
|
||||||
|
orderAvailable: 'all',
|
||||||
|
})
|
||||||
|
const [syncLoading, setSyncLoading] = useState(false)
|
||||||
|
const [syncMessage, setSyncMessage] = useState(null)
|
||||||
|
const [sortBy, setSortBy] = useState('name')
|
||||||
|
const [sortDir, setSortDir] = useState('asc')
|
||||||
|
|
||||||
|
const baseCurrency = (settings?.[0]?.baseCurrency || 'RUB').toUpperCase()
|
||||||
|
|
||||||
|
const billmanagerAccounts = useMemo(
|
||||||
|
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
|
||||||
|
[db.providerAccounts],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredAndSortedTariffs = useMemo(() => {
|
||||||
|
const filtered = db.activeTariffs.filter((item) => {
|
||||||
|
const search = filters.search.toLowerCase()
|
||||||
|
const bySearch =
|
||||||
|
!search ||
|
||||||
|
item.name?.toLowerCase().includes(search) ||
|
||||||
|
item.desc?.toLowerCase().includes(search) ||
|
||||||
|
item.location?.toLowerCase().includes(search) ||
|
||||||
|
item.country?.toLowerCase().includes(search) ||
|
||||||
|
item.datacenterName?.toLowerCase().includes(search) ||
|
||||||
|
item.cpuModel?.toLowerCase().includes(search) ||
|
||||||
|
String(item.vcpu || '').includes(search) ||
|
||||||
|
String(item.ramGb || '').includes(search) ||
|
||||||
|
String(item.diskGb || '').includes(search) ||
|
||||||
|
item.diskType?.toLowerCase().includes(search) ||
|
||||||
|
item.virtualization?.toLowerCase().includes(search)
|
||||||
|
const byProvider = !filters.providerId || item.providerId === filters.providerId
|
||||||
|
const byAccount =
|
||||||
|
!filters.providerAccountId || item.providerAccountId === filters.providerAccountId
|
||||||
|
const byCountry =
|
||||||
|
!filters.country || item.country === filters.country
|
||||||
|
const byOrderAvailable =
|
||||||
|
filters.orderAvailable === 'all' ||
|
||||||
|
(filters.orderAvailable === 'yes' && item.orderAvailable) ||
|
||||||
|
(filters.orderAvailable === 'no' && !item.orderAvailable)
|
||||||
|
return bySearch && byProvider && byAccount && byCountry && byOrderAvailable
|
||||||
|
})
|
||||||
|
|
||||||
|
const sorted = [...filtered].sort((a, b) => {
|
||||||
|
let cmp = 0
|
||||||
|
if (sortBy === 'price') {
|
||||||
|
const pa = parsePrice(a.price)
|
||||||
|
const pb = parsePrice(b.price)
|
||||||
|
const va = convertCurrency(pa.amount, pa.currency, baseCurrency, ratesData)
|
||||||
|
const vb = convertCurrency(pb.amount, pb.currency, baseCurrency, ratesData)
|
||||||
|
cmp = va - vb
|
||||||
|
} else if (['vcpu', 'ramGb', 'diskGb'].includes(sortBy)) {
|
||||||
|
const va = Number(a[sortBy]) || 0
|
||||||
|
const vb = Number(b[sortBy]) || 0
|
||||||
|
cmp = va - vb
|
||||||
|
} else {
|
||||||
|
const va = String(a[sortBy] ?? '').toLowerCase()
|
||||||
|
const vb = String(b[sortBy] ?? '').toLowerCase()
|
||||||
|
cmp = va.localeCompare(vb)
|
||||||
|
}
|
||||||
|
return sortDir === 'asc' ? cmp : -cmp
|
||||||
|
})
|
||||||
|
return sorted
|
||||||
|
}, [db.activeTariffs, filters, sortBy, sortDir, baseCurrency, ratesData])
|
||||||
|
|
||||||
|
const handleSort = (col) => {
|
||||||
|
if (!SORT_COLUMNS.includes(col)) return
|
||||||
|
setSortBy(col)
|
||||||
|
setSortDir((prev) => (sortBy === col && prev === 'asc' ? 'desc' : 'asc'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountFilterOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
db.providerAccounts.filter(
|
||||||
|
(account) => !filters.providerId || account.providerId === filters.providerId,
|
||||||
|
),
|
||||||
|
[db.providerAccounts, filters.providerId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const availableCountries = useMemo(() => {
|
||||||
|
const filtered = db.activeTariffs.filter((item) => {
|
||||||
|
const byProvider = !filters.providerId || item.providerId === filters.providerId
|
||||||
|
const byAccount =
|
||||||
|
!filters.providerAccountId || item.providerAccountId === filters.providerAccountId
|
||||||
|
return byProvider && byAccount && item.country
|
||||||
|
})
|
||||||
|
const countries = [...new Set(filtered.map((t) => t.country).filter(Boolean))].sort()
|
||||||
|
return countries
|
||||||
|
}, [db.activeTariffs, filters.providerId, filters.providerAccountId])
|
||||||
|
|
||||||
|
const onSync = async () => {
|
||||||
|
if (billmanagerAccounts.length === 0) return
|
||||||
|
setSyncLoading(true)
|
||||||
|
setSyncMessage(null)
|
||||||
|
let totalTariffs = 0
|
||||||
|
let lastError = null
|
||||||
|
for (const account of billmanagerAccounts) {
|
||||||
|
try {
|
||||||
|
const result = await syncAccount(account.id)
|
||||||
|
if (result.ok) {
|
||||||
|
totalTariffs += result.synced?.tariffsCount ?? 0
|
||||||
|
} else {
|
||||||
|
lastError = result.error
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastError && totalTariffs === 0) {
|
||||||
|
setSyncMessage(lastError)
|
||||||
|
} else {
|
||||||
|
setSyncMessage(
|
||||||
|
totalTariffs > 0
|
||||||
|
? `Синхронизировано: ${totalTariffs} тарифов${lastError ? `. Ошибки: ${lastError}` : ''}`
|
||||||
|
: lastError
|
||||||
|
? `Ошибка: ${lastError}`
|
||||||
|
: 'Нет новых тарифов для синхронизации',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (totalTariffs > 0) await actions.refreshData()
|
||||||
|
setSyncLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setFilters({
|
||||||
|
search: '',
|
||||||
|
providerId: '',
|
||||||
|
providerAccountId: '',
|
||||||
|
country: '',
|
||||||
|
orderAvailable: 'all',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncOptionsByAccount = useMemo(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const opt of db.tariffSyncOptions || []) {
|
||||||
|
map[opt.providerAccountId] = opt
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [db.tariffSyncOptions])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader pretitle="Каталог хостера" title="Активные тарифы" />
|
||||||
|
<div className="row row-cards">
|
||||||
|
<div className="col-12 card-stack">
|
||||||
|
{Object.keys(syncOptionsByAccount).length > 0 ? (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">
|
||||||
|
<IconMapPin size={18} className="me-1" />
|
||||||
|
Доступные датацентры и страны
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row g-3">
|
||||||
|
{Object.entries(syncOptionsByAccount).map(([accountId, opt]) => {
|
||||||
|
const account = db.providerAccounts.find((a) => a.id === accountId)
|
||||||
|
const provider = db.providers.find((p) => p.id === account?.providerId)
|
||||||
|
const dcs = opt.datacenters || []
|
||||||
|
const periods = opt.periods || []
|
||||||
|
if (dcs.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div key={accountId} className="col-12 col-md-6 col-lg-4">
|
||||||
|
<div className="border rounded p-3">
|
||||||
|
<div className="fw-medium mb-2">
|
||||||
|
{provider?.name} / {account?.name}
|
||||||
|
</div>
|
||||||
|
<div className="d-flex flex-wrap gap-1">
|
||||||
|
{dcs.map((dc) => (
|
||||||
|
<span
|
||||||
|
key={dc.k}
|
||||||
|
className="badge bg-blue-lt"
|
||||||
|
title={`ID: ${dc.k}`}
|
||||||
|
>
|
||||||
|
{dc.v}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{periods.length > 0 ? (
|
||||||
|
<div className="mt-2 text-secondary small">
|
||||||
|
Периоды: {periods.map((p) => p.v).join(', ')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row g-2">
|
||||||
|
<div className="col-xl-3 col-lg-4 col-md-6">
|
||||||
|
<div className="input-icon">
|
||||||
|
<span className="input-icon-addon">
|
||||||
|
<IconSearch size={16} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Поиск по названию, ресурсам..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters((prev) => ({ ...prev, search: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-xl-2 col-lg-4 col-md-6">
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={filters.providerId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
providerId: e.target.value,
|
||||||
|
providerAccountId: '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Все хостеры</option>
|
||||||
|
{db.providers.map((provider) => (
|
||||||
|
<option key={provider.id} value={provider.id}>
|
||||||
|
{provider.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-xl-2 col-lg-4 col-md-6">
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={filters.providerAccountId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
providerAccountId: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Все аккаунты</option>
|
||||||
|
{accountFilterOptions.map((account) => (
|
||||||
|
<option key={account.id} value={account.id}>
|
||||||
|
{account.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-xl-2 col-lg-4 col-md-6">
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={filters.country}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters((prev) => ({ ...prev, country: e.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Все страны</option>
|
||||||
|
{availableCountries.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-xl-2 col-lg-4 col-md-6">
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={filters.orderAvailable}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters((prev) => ({ ...prev, orderAvailable: e.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="all">Доступность: все</option>
|
||||||
|
<option value="yes">Можно заказать</option>
|
||||||
|
<option value="no">Нельзя заказать</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-xl-2 col-lg-4 col-md-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
onClick={resetFilters}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">Список тарифов</h3>
|
||||||
|
{syncMessage ? (
|
||||||
|
<div
|
||||||
|
className={`alert alert-${
|
||||||
|
syncMessage.startsWith('Синхронизировано')
|
||||||
|
? 'success'
|
||||||
|
: syncMessage.startsWith('Ошибка')
|
||||||
|
? 'warning'
|
||||||
|
: 'secondary'
|
||||||
|
} py-2 mb-0 me-2`}
|
||||||
|
>
|
||||||
|
{syncMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="card-actions">
|
||||||
|
{billmanagerAccounts.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={onSync}
|
||||||
|
disabled={syncLoading}
|
||||||
|
title="Синхронизировать тарифы из BILLmanager"
|
||||||
|
>
|
||||||
|
{syncLoading ? (
|
||||||
|
<span className="spinner-border spinner-border-sm me-1" role="status" />
|
||||||
|
) : (
|
||||||
|
<IconRefresh size={16} className="me-1" />
|
||||||
|
)}
|
||||||
|
Синхронизировать
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary small">
|
||||||
|
Добавьте аккаунт BILLmanager для синхронизации тарифов
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table card-table table-vcenter">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortHeader column="name" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Тариф</SortHeader>
|
||||||
|
<th>Хостер / Аккаунт</th>
|
||||||
|
<SortHeader column="vcpu" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>vCPU</SortHeader>
|
||||||
|
<SortHeader column="ramGb" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>RAM</SortHeader>
|
||||||
|
<SortHeader column="diskGb" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Диск</SortHeader>
|
||||||
|
<SortHeader column="diskType" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Тип диска</SortHeader>
|
||||||
|
<SortHeader column="virtualization" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Виртуализация</SortHeader>
|
||||||
|
<SortHeader column="channel" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Канал</SortHeader>
|
||||||
|
<SortHeader column="country" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Страна</SortHeader>
|
||||||
|
<SortHeader column="location" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Локация</SortHeader>
|
||||||
|
<th>CPU</th>
|
||||||
|
<SortHeader column="price" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Цена</SortHeader>
|
||||||
|
<th>Заказ</th>
|
||||||
|
<th>Панель</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredAndSortedTariffs.map((item) => {
|
||||||
|
const provider = db.providers.find((p) => p.id === item.providerId)
|
||||||
|
const account = db.providerAccounts.find(
|
||||||
|
(a) => a.id === item.providerAccountId,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<span className="avatar avatar-sm bg-blue-lt">
|
||||||
|
<IconServer size={16} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="fw-medium">{item.name || '—'}</div>
|
||||||
|
{item.desc ? (
|
||||||
|
<div
|
||||||
|
className="text-secondary small text-truncate"
|
||||||
|
style={{ maxWidth: 280 }}
|
||||||
|
title={item.desc}
|
||||||
|
>
|
||||||
|
{item.desc}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{faviconUrlFromWebsite(provider?.website) ? (
|
||||||
|
<img
|
||||||
|
src={faviconUrlFromWebsite(provider?.website)}
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span>{provider?.name || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary small">{account?.name || '—'}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge bg-azure-lt">{item.vcpu || '—'}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge bg-lime-lt">
|
||||||
|
{item.ramGb ? `${item.ramGb} GB` : '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge bg-orange-lt">
|
||||||
|
{item.diskGb ? `${item.diskGb} GB` : '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{item.diskType || '—'}</td>
|
||||||
|
<td>{item.virtualization || '—'}</td>
|
||||||
|
<td>{item.channel || '—'}</td>
|
||||||
|
<td>
|
||||||
|
{item.country ? (
|
||||||
|
<span className="badge bg-cyan-lt">{item.country}</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{item.location || item.datacenterName || '—'}</td>
|
||||||
|
<td>
|
||||||
|
{item.cpuModel ? (
|
||||||
|
<span className="text-secondary small" title={item.cpuModel}>
|
||||||
|
{item.cpuModel.length > 20 ? `${item.cpuModel.slice(0, 20)}…` : item.cpuModel}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
item.orderAvailable ? 'text-success' : 'text-secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.price || '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
item.orderAvailable ? 'bg-green-lt text-green' : 'bg-secondary-lt'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.orderAvailable ? 'Да' : 'Нет'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{normalizeWebsiteUrl(account?.panelUrl || provider?.website) ? (
|
||||||
|
<a
|
||||||
|
href={normalizeWebsiteUrl(account?.panelUrl || provider?.website)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
>
|
||||||
|
Открыть
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-secondary">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{filteredAndSortedTariffs.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
message={
|
||||||
|
db.activeTariffs.length === 0
|
||||||
|
? 'Нет данных. Синхронизируйте тарифы из BILLmanager.'
|
||||||
|
: 'По фильтрам ничего не найдено'
|
||||||
|
}
|
||||||
|
colSpan={14}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user