Add scheduled tariff synchronization and bulk update functionality
- Introduced `runScheduledSyncTariffs` function to handle tariff synchronization independently from VPS and payment sync. - Updated `syncFromBillmanager` to accept options for skipping tariff and VPS payment syncs. - Added new database columns for `syncTariffsIntervalMinutes` and `customFields` in settings. - Enhanced settings page to configure tariff sync interval. - Implemented bulk update functionality for VPS status and deletion in the VPS management page. - Updated various components to support new features, including sync log display and improved payment handling.
This commit is contained in:
@@ -9,28 +9,33 @@ import { mapVdsToVps, mapPaymentToPayment } from './mappers.js'
|
|||||||
* Sync BILLmanager data into vps-tracker DB
|
* Sync BILLmanager data into vps-tracker DB
|
||||||
* @param {object} account - provider_account with apiBaseUrl, apiCredentials
|
* @param {object} account - provider_account with apiBaseUrl, apiCredentials
|
||||||
* @param {object} db - getDb() wrapper
|
* @param {object} db - getDb() wrapper
|
||||||
* @param {object} [opts] - { paymentDaysBack }
|
* @param {object} [opts] - { paymentDaysBack, skipTariffs, skipVpsPayments }
|
||||||
* @returns {{ vpsCount: number, paymentsCount: number, tariffsCount: number, balance?: object }}
|
* @returns {{ vpsCount: number, paymentsCount: number, tariffsCount: number, balance?: object }}
|
||||||
*/
|
*/
|
||||||
export async function syncFromBillmanager(account, db, _opts = {}) {
|
export async function syncFromBillmanager(account, db, opts = {}) {
|
||||||
|
const { skipTariffs = false, skipVpsPayments = false } = opts
|
||||||
const { apiBaseUrl, apiCredentials, providerId, id: accountId } = account
|
const { apiBaseUrl, apiCredentials, providerId, id: accountId } = account
|
||||||
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
||||||
throw new Error('API URL and credentials are required')
|
throw new Error('API URL and credentials are required')
|
||||||
}
|
}
|
||||||
const authinfo = apiCredentials.trim()
|
const authinfo = apiCredentials.trim()
|
||||||
|
|
||||||
|
const fetchVpsPayments = !skipVpsPayments
|
||||||
|
const fetchTariffs = !skipTariffs
|
||||||
|
|
||||||
const [vdsItems, paymentItems, dashboardInfo, tariffResult] = await Promise.all([
|
const [vdsItems, paymentItems, dashboardInfo, tariffResult] = await Promise.all([
|
||||||
fetchVds(apiBaseUrl, authinfo),
|
fetchVpsPayments ? fetchVds(apiBaseUrl, authinfo) : [],
|
||||||
fetchPayments(apiBaseUrl, authinfo, {}),
|
fetchVpsPayments ? fetchPayments(apiBaseUrl, authinfo, {}) : [],
|
||||||
fetchDashboardInfo(apiBaseUrl, authinfo, { fallbackCurrency: account.currency }).catch(() => null),
|
fetchVpsPayments ? fetchDashboardInfo(apiBaseUrl, authinfo, { fallbackCurrency: account.currency }).catch(() => null) : null,
|
||||||
fetchVdsOrderPricelistAllDatacenters(apiBaseUrl, authinfo).catch((err) => {
|
fetchTariffs ? fetchVdsOrderPricelistAllDatacenters(apiBaseUrl, authinfo).catch((err) => {
|
||||||
console.warn('fetchVdsOrderPricelistAllDatacenters failed:', err.message)
|
console.warn('fetchVdsOrderPricelistAllDatacenters failed:', err.message)
|
||||||
return { tariffItems: [], slist: {} }
|
return { tariffItems: [], slist: {} }
|
||||||
}),
|
}) : { tariffItems: [], slist: {} },
|
||||||
])
|
])
|
||||||
const { tariffItems = [], slist = {} } = tariffResult || {}
|
const { tariffItems = [], slist = {} } = tariffResult || {}
|
||||||
|
|
||||||
let vpsCount = 0
|
let vpsCount = 0
|
||||||
|
if (fetchVpsPayments) {
|
||||||
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)
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
const vpsUpdateSql = `UPDATE vps SET ip=?, ipv6=?, additionalIps=?, dns=?, country=?, city=?, datacenter=?, os=?, status=?, tariffType=?, currency=?, dailyRate=?, monthlyRate=?, paidUntil=?, notes=?
|
const vpsUpdateSql = `UPDATE vps SET ip=?, ipv6=?, additionalIps=?, dns=?, country=?, city=?, datacenter=?, os=?, status=?, tariffType=?, currency=?, dailyRate=?, monthlyRate=?, paidUntil=?, notes=?
|
||||||
@@ -133,8 +138,10 @@ export async function syncFromBillmanager(account, db, _opts = {}) {
|
|||||||
}
|
}
|
||||||
vpsCount++
|
vpsCount++
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let paymentsCount = 0
|
let paymentsCount = 0
|
||||||
|
if (fetchVpsPayments) {
|
||||||
const existingPayments = new Set(
|
const existingPayments = new Set(
|
||||||
db.prepare('SELECT note FROM payments WHERE providerAccountId = ?').all(accountId).map((r) => r.note),
|
db.prepare('SELECT note FROM payments WHERE providerAccountId = ?').all(accountId).map((r) => r.note),
|
||||||
)
|
)
|
||||||
@@ -151,8 +158,9 @@ export async function syncFromBillmanager(account, db, _opts = {}) {
|
|||||||
existingPayments.add(note)
|
existingPayments.add(note)
|
||||||
paymentsCount++
|
paymentsCount++
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (dashboardInfo) {
|
if (fetchVpsPayments && dashboardInfo) {
|
||||||
db.run(
|
db.run(
|
||||||
'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?',
|
'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?',
|
||||||
dashboardInfo.balance,
|
dashboardInfo.balance,
|
||||||
@@ -164,6 +172,7 @@ export async function syncFromBillmanager(account, db, _opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let tariffsCount = 0
|
let tariffsCount = 0
|
||||||
|
if (fetchTariffs) {
|
||||||
const syncedAt = new Date().toISOString()
|
const syncedAt = new Date().toISOString()
|
||||||
db.run('DELETE FROM active_tariffs WHERE providerAccountId = ?', accountId)
|
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)
|
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)
|
||||||
@@ -209,6 +218,7 @@ export async function syncFromBillmanager(account, db, _opts = {}) {
|
|||||||
syncedAt,
|
syncedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { vpsCount, paymentsCount, tariffsCount, balance: dashboardInfo }
|
return { vpsCount, paymentsCount, tariffsCount, balance: dashboardInfo }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,26 @@ export const MIGRATIONS = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'settings_customFields',
|
||||||
|
run(db) {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE settings ADD COLUMN customFields TEXT')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'settings_syncTariffsInterval',
|
||||||
|
run(db) {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE settings ADD COLUMN syncTariffsIntervalMinutes INTEGER')
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message?.includes('duplicate column')) throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'active_tariffs_country_datacenter',
|
name: 'active_tariffs_country_datacenter',
|
||||||
run(db) {
|
run(db) {
|
||||||
|
|||||||
+3
-1
@@ -100,7 +100,9 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
autoConvert INTEGER,
|
autoConvert INTEGER,
|
||||||
ratesUpdatedAt TEXT,
|
ratesUpdatedAt TEXT,
|
||||||
syncEnabled INTEGER,
|
syncEnabled INTEGER,
|
||||||
syncIntervalMinutes INTEGER
|
syncIntervalMinutes INTEGER,
|
||||||
|
syncTariffsIntervalMinutes INTEGER,
|
||||||
|
customFields TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sync_log (
|
CREATE TABLE IF NOT EXISTS sync_log (
|
||||||
|
|||||||
@@ -6,10 +6,19 @@ const router = Router()
|
|||||||
|
|
||||||
export function rowToSettings(row) {
|
export function rowToSettings(row) {
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
|
let customFields = []
|
||||||
|
if (row.customFields) {
|
||||||
|
try {
|
||||||
|
customFields = JSON.parse(row.customFields)
|
||||||
|
} catch {
|
||||||
|
customFields = []
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
autoConvert: Boolean(row.autoConvert),
|
autoConvert: Boolean(row.autoConvert),
|
||||||
syncEnabled: Boolean(row.syncEnabled),
|
syncEnabled: Boolean(row.syncEnabled),
|
||||||
|
customFields: Array.isArray(customFields) ? customFields : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +32,13 @@ router.get('/', (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function serializeCustomFields(val) {
|
||||||
|
if (val == null) return null
|
||||||
|
if (Array.isArray(val)) return JSON.stringify(val)
|
||||||
|
if (typeof val === 'string') return val || null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
@@ -31,10 +47,12 @@ router.put('/:id', (req, res) => {
|
|||||||
const existing = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
|
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 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)
|
const syncIntervalMinutes = r.syncIntervalMinutes !== undefined ? Math.max(15, Number(r.syncIntervalMinutes) || 60) : (existing?.syncIntervalMinutes ?? 60)
|
||||||
|
const syncTariffsIntervalMinutes = r.syncTariffsIntervalMinutes !== undefined ? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440) : (existing?.syncTariffsIntervalMinutes ?? 1440)
|
||||||
|
const customFields = serializeCustomFields(r.customFields ?? existing?.customFields)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE settings SET
|
UPDATE settings SET
|
||||||
baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ?
|
baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ?, syncTariffsIntervalMinutes = ?, customFields = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
r.baseCurrency ?? existing.baseCurrency ?? 'RUB',
|
r.baseCurrency ?? existing.baseCurrency ?? 'RUB',
|
||||||
@@ -43,12 +61,14 @@ router.put('/:id', (req, res) => {
|
|||||||
r.ratesUpdatedAt ?? existing.ratesUpdatedAt ?? '',
|
r.ratesUpdatedAt ?? existing.ratesUpdatedAt ?? '',
|
||||||
syncEnabled,
|
syncEnabled,
|
||||||
syncIntervalMinutes,
|
syncIntervalMinutes,
|
||||||
|
syncTariffsIntervalMinutes,
|
||||||
|
customFields,
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes)
|
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, customFields)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
id,
|
id,
|
||||||
r.baseCurrency ?? 'RUB',
|
r.baseCurrency ?? 'RUB',
|
||||||
@@ -57,6 +77,8 @@ router.put('/:id', (req, res) => {
|
|||||||
r.ratesUpdatedAt ?? '',
|
r.ratesUpdatedAt ?? '',
|
||||||
syncEnabled,
|
syncEnabled,
|
||||||
syncIntervalMinutes,
|
syncIntervalMinutes,
|
||||||
|
syncTariffsIntervalMinutes,
|
||||||
|
customFields,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
startScheduler()
|
startScheduler()
|
||||||
@@ -74,9 +96,11 @@ router.post('/', (req, res) => {
|
|||||||
const id = r.id ?? 'settings-main'
|
const id = r.id ?? 'settings-main'
|
||||||
const syncEnabled = r.syncEnabled ? 1 : 0
|
const syncEnabled = r.syncEnabled ? 1 : 0
|
||||||
const syncIntervalMinutes = Math.max(15, Number(r.syncIntervalMinutes) || 60)
|
const syncIntervalMinutes = Math.max(15, Number(r.syncIntervalMinutes) || 60)
|
||||||
|
const syncTariffsIntervalMinutes = Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440)
|
||||||
|
const customFields = serializeCustomFields(r.customFields)
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes)
|
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, customFields)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
id,
|
id,
|
||||||
r.baseCurrency ?? 'RUB',
|
r.baseCurrency ?? 'RUB',
|
||||||
@@ -85,6 +109,8 @@ router.post('/', (req, res) => {
|
|||||||
r.ratesUpdatedAt ?? '',
|
r.ratesUpdatedAt ?? '',
|
||||||
syncEnabled,
|
syncEnabled,
|
||||||
syncIntervalMinutes,
|
syncIntervalMinutes,
|
||||||
|
syncTariffsIntervalMinutes,
|
||||||
|
customFields,
|
||||||
)
|
)
|
||||||
startScheduler()
|
startScheduler()
|
||||||
const row = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
|
const row = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ router.get('/status', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT accountId, startedAt, finishedAt, status, vpsCount, paymentsCount, error
|
SELECT id, accountId, startedAt, finishedAt, status, vpsCount, paymentsCount, error
|
||||||
FROM sync_log ORDER BY startedAt DESC LIMIT 50
|
FROM sync_log ORDER BY startedAt DESC LIMIT 50
|
||||||
`).all()
|
`).all()
|
||||||
res.json(rows)
|
res.json(rows)
|
||||||
|
|||||||
@@ -194,4 +194,37 @@ router.delete('/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.patch('/bulk', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
const { ids = [], action, value } = req.body
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'ids must be a non-empty array' })
|
||||||
|
}
|
||||||
|
if (action === 'status' && value) {
|
||||||
|
const validStatus = ['active', 'paused', 'archived']
|
||||||
|
if (!validStatus.includes(value)) {
|
||||||
|
return res.status(400).json({ error: 'value must be active, paused, or archived' })
|
||||||
|
}
|
||||||
|
const stmt = db.prepare('UPDATE vps SET status = ? WHERE id = ?')
|
||||||
|
for (const id of ids) {
|
||||||
|
stmt.run(value, id)
|
||||||
|
}
|
||||||
|
return res.json({ updated: ids.length, status: value })
|
||||||
|
}
|
||||||
|
if (action === 'delete') {
|
||||||
|
const stmt = db.prepare('DELETE FROM vps WHERE id = ?')
|
||||||
|
let deleted = 0
|
||||||
|
for (const id of ids) {
|
||||||
|
const result = stmt.run(id)
|
||||||
|
if (result.changes > 0) deleted++
|
||||||
|
}
|
||||||
|
return res.json({ deleted })
|
||||||
|
}
|
||||||
|
return res.status(400).json({ error: 'action must be status or delete' })
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getDb } from './db.js'
|
|||||||
import { syncFromBillmanager } from './adapters/billmanager/index.js'
|
import { syncFromBillmanager } from './adapters/billmanager/index.js'
|
||||||
|
|
||||||
let syncIntervalId = null
|
let syncIntervalId = null
|
||||||
|
let syncTariffsIntervalId = null
|
||||||
|
|
||||||
export function runScheduledSync() {
|
export function runScheduledSync() {
|
||||||
try {
|
try {
|
||||||
@@ -13,8 +14,8 @@ export function runScheduledSync() {
|
|||||||
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != ''
|
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != ''
|
||||||
`).all()
|
`).all()
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
syncFromBillmanager(account, db).catch((err) => {
|
syncFromBillmanager(account, db, { skipTariffs: true }).catch((err) => {
|
||||||
console.warn(`Sync failed for account ${account.id}:`, err.message)
|
console.warn(`Sync VPS/payments failed for account ${account.id}:`, err.message)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -22,16 +23,39 @@ export function runScheduledSync() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function runScheduledSyncTariffs() {
|
||||||
|
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, { skipVpsPayments: true }).catch((err) => {
|
||||||
|
console.warn(`Sync tariffs failed for account ${account.id}:`, err.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Scheduled sync tariffs error:', err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function startScheduler() {
|
export function startScheduler() {
|
||||||
if (syncIntervalId) clearInterval(syncIntervalId)
|
if (syncIntervalId) clearInterval(syncIntervalId)
|
||||||
syncIntervalId = null
|
syncIntervalId = null
|
||||||
|
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
||||||
|
syncTariffsIntervalId = null
|
||||||
try {
|
try {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
|
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
|
||||||
if (!settings?.syncEnabled) return
|
if (!settings?.syncEnabled) return
|
||||||
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
|
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
|
||||||
|
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
|
||||||
syncIntervalId = setInterval(runScheduledSync, interval * 60 * 1000)
|
syncIntervalId = setInterval(runScheduledSync, interval * 60 * 1000)
|
||||||
console.log(`Scheduled sync enabled: every ${interval} min`)
|
syncTariffsIntervalId = setInterval(runScheduledSyncTariffs, tariffsInterval * 60 * 1000)
|
||||||
|
console.log(`Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min`)
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { EmptyState } from './EmptyState'
|
||||||
|
|
||||||
|
export function SyncLogTable({ syncLog = [], providerAccounts = [] }) {
|
||||||
|
const getAccountName = (accountId) => {
|
||||||
|
const account = providerAccounts.find((a) => a.id === accountId)
|
||||||
|
return account?.name || accountId
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '—'
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return Number.isNaN(d.getTime()) ? dateStr : d.toLocaleString('ru-RU')
|
||||||
|
} catch {
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadge = (status) => {
|
||||||
|
if (status === 'ok') return 'bg-green-lt text-green'
|
||||||
|
if (status === 'error') return 'bg-red-lt text-red'
|
||||||
|
if (status === 'running') return 'bg-blue-lt text-blue'
|
||||||
|
return 'bg-secondary-lt'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>Статус</th>
|
||||||
|
<th>VPS</th>
|
||||||
|
<th>Платежи</th>
|
||||||
|
<th>Ошибка</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{syncLog.map((row) => (
|
||||||
|
<tr key={row.id || `${row.accountId}-${row.startedAt}`}>
|
||||||
|
<td>{getAccountName(row.accountId)}</td>
|
||||||
|
<td>{formatDate(row.startedAt)}</td>
|
||||||
|
<td>{formatDate(row.finishedAt)}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${statusBadge(row.status)}`}>
|
||||||
|
{row.status === 'ok' ? 'OK' : row.status === 'error' ? 'Ошибка' : row.status === 'running' ? 'Выполняется' : row.status || '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{row.vpsCount ?? '—'}</td>
|
||||||
|
<td>{row.paymentsCount ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
{row.error ? (
|
||||||
|
<span className="text-danger small" title={row.error}>
|
||||||
|
{row.error.length > 50 ? `${row.error.slice(0, 50)}…` : row.error}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{syncLog.length === 0 ? (
|
||||||
|
<EmptyState message="Нет записей синхронизации" colSpan={7} />
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -128,6 +128,13 @@ export async function deleteRecord(collectionName, id) {
|
|||||||
return fetchCollection(collectionName)
|
return fetchCollection(collectionName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function bulkUpdateVps(ids, action, value) {
|
||||||
|
return fetchApi('/api/vps/bulk', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ ids, action, value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function syncAccount(accountId) {
|
export async function syncAccount(accountId) {
|
||||||
return fetchApi(`/api/sync/${encodeURIComponent(accountId)}`, { method: 'POST' })
|
return fetchApi(`/api/sync/${encodeURIComponent(accountId)}`, { method: 'POST' })
|
||||||
}
|
}
|
||||||
@@ -142,3 +149,7 @@ export async function testApiConnection(apiBaseUrl, apiCredentials) {
|
|||||||
body: JSON.stringify({ apiBaseUrl, apiCredentials }),
|
body: JSON.stringify({ apiBaseUrl, apiCredentials }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchSyncStatus() {
|
||||||
|
return fetchApi('/api/sync/status')
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
billingModeLabel,
|
billingModeLabel,
|
||||||
faviconUrlFromWebsite,
|
faviconUrlFromWebsite,
|
||||||
} from '../lib/utils'
|
} from '../lib/utils'
|
||||||
import { UiModal } from '../components/UiModal'
|
import { UiModal } from '../components/UiModal'
|
||||||
import { EmptyState } from '../components/EmptyState'
|
import { EmptyState } from '../components/EmptyState'
|
||||||
|
import { SyncLogTable } from '../components/SyncLogTable'
|
||||||
import { PageHeader } from '../components/PageHeader'
|
import { PageHeader } from '../components/PageHeader'
|
||||||
import { ConvertedAmount } from '../components/ConvertedAmount'
|
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||||
import { syncAccount, testApiConnection, fetchAccountBalance } from '../lib/api'
|
import { syncAccount, testApiConnection, fetchAccountBalance, fetchSyncStatus } from '../lib/api'
|
||||||
import { IconRefresh, IconPlugConnected } from '@tabler/icons-react'
|
import { IconRefresh, IconPlugConnected } from '@tabler/icons-react'
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
@@ -33,12 +34,23 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
|||||||
const [balanceLoadingId, setBalanceLoadingId] = useState(null)
|
const [balanceLoadingId, setBalanceLoadingId] = useState(null)
|
||||||
const [saveError, setSaveError] = useState(null)
|
const [saveError, setSaveError] = useState(null)
|
||||||
|
|
||||||
|
const loadSyncLog = () => {
|
||||||
|
fetchSyncStatus()
|
||||||
|
.then(setSyncLog)
|
||||||
|
.catch(() => setSyncLog([]))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSyncLog()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const billmanagerAccounts = useMemo(
|
const billmanagerAccounts = useMemo(
|
||||||
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
|
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
|
||||||
[db.providerAccounts],
|
[db.providerAccounts],
|
||||||
)
|
)
|
||||||
const [testConnectionLoading, setTestConnectionLoading] = useState(false)
|
const [testConnectionLoading, setTestConnectionLoading] = useState(false)
|
||||||
const [testConnectionResult, setTestConnectionResult] = useState(null)
|
const [testConnectionResult, setTestConnectionResult] = useState(null)
|
||||||
|
const [syncLog, setSyncLog] = useState([])
|
||||||
|
|
||||||
const balances = useMemo(() => {
|
const balances = useMemo(() => {
|
||||||
return db.providerAccounts.map((account) => {
|
return db.providerAccounts.map((account) => {
|
||||||
@@ -153,7 +165,10 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
|||||||
try {
|
try {
|
||||||
const result = await syncAccount(accountId)
|
const result = await syncAccount(accountId)
|
||||||
setSyncMessage(result.ok ? `Синхронизировано: ${result.synced?.vpsCount ?? 0} VPS, ${result.synced?.paymentsCount ?? 0} платежей${result.synced?.balance ? ', баланс обновлён' : ''}` : result.error || 'Ошибка')
|
setSyncMessage(result.ok ? `Синхронизировано: ${result.synced?.vpsCount ?? 0} VPS, ${result.synced?.paymentsCount ?? 0} платежей${result.synced?.balance ? ', баланс обновлён' : ''}` : result.error || 'Ошибка')
|
||||||
if (result.ok) await actions.refreshData()
|
if (result.ok) {
|
||||||
|
await actions.refreshData()
|
||||||
|
loadSyncLog()
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSyncMessage(err.message || 'Ошибка синхронизации')
|
setSyncMessage(err.message || 'Ошибка синхронизации')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -186,7 +201,10 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
|||||||
} else {
|
} else {
|
||||||
setSyncMessage(`Синхронизировано: ${totalVps} VPS, ${totalPayments} платежей${lastError ? `. Ошибки: ${lastError}` : ''}`)
|
setSyncMessage(`Синхронизировано: ${totalVps} VPS, ${totalPayments} платежей${lastError ? `. Ошибки: ${lastError}` : ''}`)
|
||||||
}
|
}
|
||||||
if (totalVps > 0 || totalPayments > 0) await actions.refreshData()
|
if (totalVps > 0 || totalPayments > 0) {
|
||||||
|
await actions.refreshData()
|
||||||
|
loadSyncLog()
|
||||||
|
}
|
||||||
setSyncLoadingAll(false)
|
setSyncLoadingAll(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +364,10 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 mt-3">
|
||||||
|
<SyncLogTable syncLog={syncLog} providerAccounts={db.providerAccounts} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UiModal
|
<UiModal
|
||||||
|
|||||||
+74
-23
@@ -138,16 +138,59 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
|
|||||||
(acc, row) => acc + convertCurrency(row.balance, row.currency, baseCurrency, ratesData),
|
(acc, row) => acc + convertCurrency(row.balance, row.currency, baseCurrency, ratesData),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
const upcoming = providerAccounts
|
|
||||||
.map((account) => ({
|
const UPCOMING_DAYS = 7
|
||||||
...account,
|
const getAccountBalance = (accountId) => {
|
||||||
nextDate:
|
const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId)
|
||||||
account.billingMode === 'daily'
|
const credits = ledgerRows
|
||||||
? new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
.filter((row) => row.direction === 'credit')
|
||||||
: new Date(now.getFullYear(), now.getMonth() + 1, 1),
|
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||||
}))
|
const debits = ledgerRows
|
||||||
.sort((a, b) => a.nextDate - b.nextDate)
|
.filter((row) => row.direction === 'debit')
|
||||||
.slice(0, 5)
|
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||||
|
return credits - debits
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPaidUntilDate = (item) => {
|
||||||
|
if (item.status !== 'active') return null
|
||||||
|
if (item.paidUntil) {
|
||||||
|
const d = new Date(item.paidUntil)
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d
|
||||||
|
}
|
||||||
|
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
||||||
|
const dailyRate = Number(item.dailyRate || 0)
|
||||||
|
const monthlyRate = Number(item.monthlyRate || 0)
|
||||||
|
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
|
||||||
|
if (!Number.isFinite(burnRate) || burnRate <= 0) return null
|
||||||
|
const directPayments = payments
|
||||||
|
.filter((p) => p.vpsId === item.id && p.type === 'direct_vps_payment')
|
||||||
|
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
|
||||||
|
const accountBalance = getAccountBalance(item.providerAccountId)
|
||||||
|
const activeInAccount = vps.filter(
|
||||||
|
(v) => v.providerAccountId === item.providerAccountId && v.status === 'active',
|
||||||
|
).length
|
||||||
|
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
|
||||||
|
const funds = directPayments + allocatedBalance
|
||||||
|
const coveredDays = Math.floor(funds / burnRate)
|
||||||
|
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return null
|
||||||
|
const paidUntil = new Date()
|
||||||
|
paidUntil.setDate(paidUntil.getDate() + coveredDays)
|
||||||
|
return paidUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcoming = useMemo(() => {
|
||||||
|
const threshold = new Date()
|
||||||
|
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
|
||||||
|
return vps
|
||||||
|
.filter((item) => item.status === 'active')
|
||||||
|
.map((item) => {
|
||||||
|
const date = getPaidUntilDate(item)
|
||||||
|
return { vps: item, paidUntil: date }
|
||||||
|
})
|
||||||
|
.filter(({ paidUntil }) => paidUntil && paidUntil <= threshold && paidUntil >= new Date(now.getFullYear(), now.getMonth(), now.getDate()))
|
||||||
|
.sort((a, b) => a.paidUntil - b.paidUntil)
|
||||||
|
.slice(0, 10)
|
||||||
|
}, [vps, payments, balanceLedger])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -284,24 +327,32 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
|
|||||||
<div className="col-12 col-xl-5">
|
<div className="col-12 col-xl-5">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h3 className="card-title">Ближайшие списания</h3>
|
<h3 className="card-title">Истекающая оплата (ближайшие {UPCOMING_DAYS} дней)</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="list-group list-group-flush">
|
<div className="list-group list-group-flush">
|
||||||
{upcoming.map((item) => (
|
{upcoming.map(({ vps: item, paidUntil }) => {
|
||||||
<div key={item.id} className="list-group-item">
|
const provider = providers.find((p) => p.id === item.providerId)
|
||||||
<div className="d-flex justify-content-between">
|
const account = providerAccounts.find((a) => a.id === item.providerAccountId)
|
||||||
<div>
|
return (
|
||||||
<div className="fw-medium">{item.name}</div>
|
<div key={item.id} className="list-group-item">
|
||||||
<div className="text-secondary small">{billingModeLabel(item.billingMode)}</div>
|
<div className="d-flex justify-content-between">
|
||||||
</div>
|
<div>
|
||||||
<div className="text-secondary">
|
<div className="fw-medium">{item.dns || item.ip}</div>
|
||||||
{item.nextDate.toLocaleDateString('ru-RU')}
|
<div className="text-secondary small">
|
||||||
|
{provider?.name || '—'} / {account?.name || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary">
|
||||||
|
{paidUntil.toLocaleDateString('ru-RU')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
{upcoming.length === 0 ? (
|
{upcoming.length === 0 ? (
|
||||||
<div className="list-group-item text-secondary text-center py-4">Списаний пока нет</div>
|
<div className="list-group-item text-secondary text-center py-4">
|
||||||
|
Нет VPS с истекающей оплатой в ближайшие {UPCOMING_DAYS} дней
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+56
-16
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { IconEdit } from '@tabler/icons-react'
|
||||||
import { paymentTypeLabel } from '../lib/utils'
|
import { paymentTypeLabel } from '../lib/utils'
|
||||||
import { UiModal } from '../components/UiModal'
|
import { UiModal } from '../components/UiModal'
|
||||||
import { EmptyState } from '../components/EmptyState'
|
import { EmptyState } from '../components/EmptyState'
|
||||||
@@ -17,6 +18,7 @@ const emptyForm = {
|
|||||||
|
|
||||||
export function PaymentsPage({ db, actions, settings, ratesData }) {
|
export function PaymentsPage({ db, actions, settings, ratesData }) {
|
||||||
const [form, setForm] = useState(emptyForm)
|
const [form, setForm] = useState(emptyForm)
|
||||||
|
const [editingId, setEditingId] = useState(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ export function PaymentsPage({ db, actions, settings, ratesData }) {
|
|||||||
}
|
}
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
actions.create('payments', {
|
const payload = {
|
||||||
type: form.type,
|
type: form.type,
|
||||||
date: form.date,
|
date: form.date,
|
||||||
amount,
|
amount,
|
||||||
@@ -50,25 +52,46 @@ export function PaymentsPage({ db, actions, settings, ratesData }) {
|
|||||||
providerAccountId: form.providerAccountId,
|
providerAccountId: form.providerAccountId,
|
||||||
vpsId: form.vpsId || '',
|
vpsId: form.vpsId || '',
|
||||||
note: form.note,
|
note: form.note,
|
||||||
})
|
}
|
||||||
|
|
||||||
if (form.type === 'provider_balance_topup') {
|
if (editingId) {
|
||||||
actions.create('balanceLedger', {
|
actions.update('payments', editingId, payload)
|
||||||
type: 'provider_balance_topup',
|
} else {
|
||||||
date: form.date,
|
actions.create('payments', payload)
|
||||||
amount,
|
if (form.type === 'provider_balance_topup') {
|
||||||
currency: form.currency,
|
actions.create('balanceLedger', {
|
||||||
direction: 'credit',
|
type: 'provider_balance_topup',
|
||||||
providerAccountId: form.providerAccountId,
|
date: form.date,
|
||||||
vpsId: '',
|
amount,
|
||||||
note: form.note || 'Пополнение баланса',
|
currency: form.currency,
|
||||||
})
|
direction: 'credit',
|
||||||
|
providerAccountId: form.providerAccountId,
|
||||||
|
vpsId: '',
|
||||||
|
note: form.note || 'Пополнение баланса',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setForm(emptyForm)
|
setForm(emptyForm)
|
||||||
|
setEditingId(null)
|
||||||
setIsModalOpen(false)
|
setIsModalOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onEdit = (payment) => {
|
||||||
|
setForm({
|
||||||
|
type: payment.type || 'direct_vps_payment',
|
||||||
|
date: payment.date || new Date().toISOString().slice(0, 10),
|
||||||
|
amount: payment.amount ?? '',
|
||||||
|
currency: payment.currency || 'USD',
|
||||||
|
providerAccountId: payment.providerAccountId || '',
|
||||||
|
vpsId: payment.vpsId || '',
|
||||||
|
note: payment.note || '',
|
||||||
|
})
|
||||||
|
setEditingId(payment.id)
|
||||||
|
setIsModalOpen(true)
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader pretitle="Финансы" title="Платежи" />
|
<PageHeader pretitle="Финансы" title="Платежи" />
|
||||||
@@ -78,7 +101,15 @@ export function PaymentsPage({ db, actions, settings, ratesData }) {
|
|||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h3 className="card-title">История платежей</h3>
|
<h3 className="card-title">История платежей</h3>
|
||||||
<div className="card-actions">
|
<div className="card-actions">
|
||||||
<button type="button" className="btn btn-primary" onClick={() => setIsModalOpen(true)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setForm(emptyForm)
|
||||||
|
setEditingId(null)
|
||||||
|
setIsModalOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
Добавить платеж
|
Добавить платеж
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,6 +149,14 @@ export function PaymentsPage({ db, actions, settings, ratesData }) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<div className="table-actions">
|
<div className="table-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-primary me-1"
|
||||||
|
onClick={() => onEdit(payment)}
|
||||||
|
>
|
||||||
|
<IconEdit size={14} className="me-1" />
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-sm btn-outline-danger"
|
className="btn btn-sm btn-outline-danger"
|
||||||
@@ -142,11 +181,12 @@ export function PaymentsPage({ db, actions, settings, ratesData }) {
|
|||||||
|
|
||||||
<UiModal
|
<UiModal
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
title="Новая операция платежа"
|
title={editingId ? 'Редактировать платеж' : 'Новая операция платежа'}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsModalOpen(false)
|
setIsModalOpen(false)
|
||||||
setError('')
|
setError('')
|
||||||
setForm(emptyForm)
|
setForm(emptyForm)
|
||||||
|
setEditingId(null)
|
||||||
}}
|
}}
|
||||||
size="modal-md"
|
size="modal-md"
|
||||||
>
|
>
|
||||||
@@ -256,7 +296,7 @@ export function PaymentsPage({ db, actions, settings, ratesData }) {
|
|||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
Сохранить
|
{editingId ? 'Сохранить' : 'Добавить'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -11,14 +11,32 @@ import { ConvertedAmount } from '../components/ConvertedAmount'
|
|||||||
import { EmptyState } from '../components/EmptyState'
|
import { EmptyState } from '../components/EmptyState'
|
||||||
import { PageHeader } from '../components/PageHeader'
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
|
||||||
|
function isDateInRange(dateStr, dateFrom, dateTo) {
|
||||||
|
if (!dateStr) return false
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
if (Number.isNaN(d.getTime())) return false
|
||||||
|
if (dateFrom && d < new Date(dateFrom)) return false
|
||||||
|
if (dateTo && d > new Date(dateTo + 'T23:59:59.999')) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export function ReportsPage({ db, settings, ratesData }) {
|
export function ReportsPage({ db, settings, ratesData }) {
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
providerId: '',
|
providerId: '',
|
||||||
country: '',
|
country: '',
|
||||||
month: '',
|
month: '',
|
||||||
|
dateFrom: '',
|
||||||
|
dateTo: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
|
const dateFrom = filters.dateFrom || (filters.month ? `${filters.month}-01` : '')
|
||||||
|
const dateTo = filters.dateTo || (filters.month ? (() => {
|
||||||
|
const [y, m] = filters.month.split('-').map(Number)
|
||||||
|
const lastDay = new Date(y, m, 0).getDate()
|
||||||
|
return `${filters.month}-${String(lastDay).padStart(2, '0')}`
|
||||||
|
})() : '')
|
||||||
|
|
||||||
return db.vps
|
return db.vps
|
||||||
.filter((vps) => {
|
.filter((vps) => {
|
||||||
const byProvider = !filters.providerId || vps.providerId === filters.providerId
|
const byProvider = !filters.providerId || vps.providerId === filters.providerId
|
||||||
@@ -28,11 +46,15 @@ export function ReportsPage({ db, settings, ratesData }) {
|
|||||||
})
|
})
|
||||||
.map((vps) => {
|
.map((vps) => {
|
||||||
const provider = db.providers.find((item) => item.id === vps.providerId)
|
const provider = db.providers.find((item) => item.id === vps.providerId)
|
||||||
const payments = db.payments.filter((item) => item.vpsId === vps.id)
|
const payments = db.payments
|
||||||
const monthlyPayments = filters.month
|
.filter((item) => item.vpsId === vps.id && item.type !== 'provider_balance_topup')
|
||||||
? payments.filter((item) => monthKey(item.date) === filters.month)
|
.filter((item) => !dateFrom || !dateTo || isDateInRange(item.date, dateFrom, dateTo))
|
||||||
: payments
|
const debits = db.balanceLedger
|
||||||
const total = monthlyPayments.reduce((acc, item) => acc + Number(item.amount || 0), 0)
|
.filter((item) => item.vpsId === vps.id && item.direction === 'debit')
|
||||||
|
.filter((item) => !dateFrom || !dateTo || isDateInRange(item.date, dateFrom, dateTo))
|
||||||
|
const totalPayments = payments.reduce((acc, item) => acc + Number(item.amount || 0), 0)
|
||||||
|
const totalDebits = debits.reduce((acc, item) => acc + Number(item.amount || 0), 0)
|
||||||
|
const total = totalPayments + totalDebits
|
||||||
return {
|
return {
|
||||||
providerId: vps.providerId,
|
providerId: vps.providerId,
|
||||||
provider: provider?.name || '-',
|
provider: provider?.name || '-',
|
||||||
@@ -45,7 +67,7 @@ export function ReportsPage({ db, settings, ratesData }) {
|
|||||||
currency: vps.currency || 'USD',
|
currency: vps.currency || 'USD',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [db.payments, db.providers, db.vps, filters])
|
}, [db.payments, db.balanceLedger, db.providers, db.vps, filters])
|
||||||
|
|
||||||
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
|
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
|
||||||
const totalExpense = rows.reduce(
|
const totalExpense = rows.reduce(
|
||||||
@@ -88,15 +110,52 @@ export function ReportsPage({ db, settings, ratesData }) {
|
|||||||
placeholder="например Германия"
|
placeholder="например Германия"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-12 col-md-6 col-xl-3">
|
<div className="col-12 col-md-6 col-xl-2">
|
||||||
<label className="form-label">Период (YYYY-MM)</label>
|
<label className="form-label">Период (YYYY-MM)</label>
|
||||||
<input
|
<input
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={filters.month}
|
value={filters.month}
|
||||||
onChange={(e) => setFilters((prev) => ({ ...prev, month: e.target.value }))}
|
onChange={(e) =>
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
month: e.target.value,
|
||||||
|
dateFrom: '',
|
||||||
|
dateTo: '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="2026-03"
|
placeholder="2026-03"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-12 col-md-6 col-xl-2">
|
||||||
|
<label className="form-label">Дата с</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-control"
|
||||||
|
value={filters.dateFrom}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
dateFrom: e.target.value,
|
||||||
|
month: '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 col-xl-2">
|
||||||
|
<label className="form-label">Дата по</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-control"
|
||||||
|
value={filters.dateTo}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
dateTo: e.target.value,
|
||||||
|
month: '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="col-12 col-md-6 col-xl-3">
|
<div className="col-12 col-md-6 col-xl-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const defaultSettings = {
|
|||||||
autoConvert: true,
|
autoConvert: true,
|
||||||
syncEnabled: false,
|
syncEnabled: false,
|
||||||
syncIntervalMinutes: 60,
|
syncIntervalMinutes: 60,
|
||||||
|
syncTariffsIntervalMinutes: 1440,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||||
@@ -18,6 +19,7 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
|||||||
autoConvert: current.autoConvert !== false,
|
autoConvert: current.autoConvert !== false,
|
||||||
syncEnabled: current.syncEnabled !== false && Boolean(current.syncEnabled),
|
syncEnabled: current.syncEnabled !== false && Boolean(current.syncEnabled),
|
||||||
syncIntervalMinutes: current.syncIntervalMinutes ?? 60,
|
syncIntervalMinutes: current.syncIntervalMinutes ?? 60,
|
||||||
|
syncTariffsIntervalMinutes: current.syncTariffsIntervalMinutes ?? 1440,
|
||||||
})
|
})
|
||||||
const [newFieldLabel, setNewFieldLabel] = useState('')
|
const [newFieldLabel, setNewFieldLabel] = useState('')
|
||||||
const customFields = Array.isArray(current.customFields) ? current.customFields : []
|
const customFields = Array.isArray(current.customFields) ? current.customFields : []
|
||||||
@@ -30,8 +32,9 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
|||||||
autoConvert: current.autoConvert !== false,
|
autoConvert: current.autoConvert !== false,
|
||||||
syncEnabled: Boolean(current.syncEnabled),
|
syncEnabled: Boolean(current.syncEnabled),
|
||||||
syncIntervalMinutes: current.syncIntervalMinutes ?? 60,
|
syncIntervalMinutes: current.syncIntervalMinutes ?? 60,
|
||||||
|
syncTariffsIntervalMinutes: current.syncTariffsIntervalMinutes ?? 1440,
|
||||||
})
|
})
|
||||||
}, [current.baseCurrency, current.ratesUrl, current.autoConvert, current.syncEnabled, current.syncIntervalMinutes])
|
}, [current.baseCurrency, current.ratesUrl, current.autoConvert, current.syncEnabled, current.syncIntervalMinutes, current.syncTariffsIntervalMinutes])
|
||||||
|
|
||||||
const availableCurrencies = useMemo(() => {
|
const availableCurrencies = useMemo(() => {
|
||||||
const list = new Set(['RUB', 'USD', 'EUR'])
|
const list = new Set(['RUB', 'USD', 'EUR'])
|
||||||
@@ -56,6 +59,7 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
|||||||
actions.upsertSettings({
|
actions.upsertSettings({
|
||||||
syncEnabled: form.syncEnabled,
|
syncEnabled: form.syncEnabled,
|
||||||
syncIntervalMinutes: Math.max(15, Number(form.syncIntervalMinutes) || 60),
|
syncIntervalMinutes: Math.max(15, Number(form.syncIntervalMinutes) || 60),
|
||||||
|
syncTariffsIntervalMinutes: Math.max(60, Number(form.syncTariffsIntervalMinutes) || 1440),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +198,8 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<p className="text-secondary small mb-3">
|
<p className="text-secondary small mb-3">
|
||||||
Периодическая синхронизация данных (VPS, платежи) из BILLmanager для аккаунтов с настроенным API.
|
Периодическая синхронизация данных из BILLmanager для аккаунтов с настроенным API.
|
||||||
|
Два независимых интервала: VPS и платежи обновляются чаще, тарифы — реже.
|
||||||
</p>
|
</p>
|
||||||
<form className="row g-3" onSubmit={onSyncSettingsSubmit}>
|
<form className="row g-3" onSubmit={onSyncSettingsSubmit}>
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
@@ -209,15 +214,30 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-12 col-md-6">
|
<div className="col-12 col-md-6">
|
||||||
<label className="form-label">Интервал (минуты)</label>
|
<label className="form-label">Интервал VPS и платежей (минуты)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="15"
|
min="15"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={form.syncIntervalMinutes}
|
value={form.syncIntervalMinutes}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, syncIntervalMinutes: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, syncIntervalMinutes: e.target.value }))}
|
||||||
|
placeholder="60"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label">Интервал тарифов (минуты)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="60"
|
||||||
|
className="form-control"
|
||||||
|
value={form.syncTariffsIntervalMinutes}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, syncTariffsIntervalMinutes: e.target.value }))}
|
||||||
|
placeholder="1440"
|
||||||
|
/>
|
||||||
|
<div className="text-secondary small mt-1">
|
||||||
|
Тарифы меняются редко, можно ставить 24 ч (1440) и больше
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="col-12 d-flex justify-content-end">
|
<div className="col-12 d-flex justify-content-end">
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
Сохранить
|
Сохранить
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { convertCurrency, faviconUrlFromWebsite, normalizeWebsiteUrl } from '../lib/utils'
|
import { convertCurrency, formatCurrency, faviconUrlFromWebsite, normalizeWebsiteUrl } from '../lib/utils'
|
||||||
import {
|
import {
|
||||||
IconArrowDown,
|
IconArrowDown,
|
||||||
IconArrowUp,
|
IconArrowUp,
|
||||||
@@ -395,6 +395,9 @@ export function TariffsPage({ db, actions, settings, ratesData }) {
|
|||||||
<SortHeader column="location" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Локация</SortHeader>
|
<SortHeader column="location" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Локация</SortHeader>
|
||||||
<th>CPU</th>
|
<th>CPU</th>
|
||||||
<SortHeader column="price" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Цена</SortHeader>
|
<SortHeader column="price" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Цена</SortHeader>
|
||||||
|
<th>₽/vCPU</th>
|
||||||
|
<th>₽/GB RAM</th>
|
||||||
|
<th>₽/GB диск</th>
|
||||||
<th>Заказ</th>
|
<th>Заказ</th>
|
||||||
<th>Панель</th>
|
<th>Панель</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -405,6 +408,14 @@ export function TariffsPage({ db, actions, settings, ratesData }) {
|
|||||||
const account = db.providerAccounts.find(
|
const account = db.providerAccounts.find(
|
||||||
(a) => a.id === item.providerAccountId,
|
(a) => a.id === item.providerAccountId,
|
||||||
)
|
)
|
||||||
|
const { amount: priceAmount, currency: priceCurrency } = parsePrice(item.price)
|
||||||
|
const priceInBase = convertCurrency(priceAmount, priceCurrency, baseCurrency, ratesData)
|
||||||
|
const vcpu = Number(item.vcpu) || 0
|
||||||
|
const ramGb = Number(item.ramGb) || 0
|
||||||
|
const diskGb = Number(item.diskGb) || 0
|
||||||
|
const pricePerVcpu = vcpu > 0 ? priceInBase / vcpu : null
|
||||||
|
const pricePerRam = ramGb > 0 ? priceInBase / ramGb : null
|
||||||
|
const pricePerDisk = diskGb > 0 ? priceInBase / diskGb : null
|
||||||
return (
|
return (
|
||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
<td>
|
<td>
|
||||||
@@ -483,6 +494,27 @@ export function TariffsPage({ db, actions, settings, ratesData }) {
|
|||||||
{item.price || '—'}
|
{item.price || '—'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="text-secondary small">
|
||||||
|
{pricePerVcpu != null
|
||||||
|
? formatCurrency(pricePerVcpu, baseCurrency)
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="text-secondary small">
|
||||||
|
{pricePerRam != null
|
||||||
|
? formatCurrency(pricePerRam, baseCurrency)
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="text-secondary small">
|
||||||
|
{pricePerDisk != null
|
||||||
|
? formatCurrency(pricePerDisk, baseCurrency)
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
className={`badge ${
|
className={`badge ${
|
||||||
@@ -516,7 +548,7 @@ export function TariffsPage({ db, actions, settings, ratesData }) {
|
|||||||
? 'Нет данных. Синхронизируйте тарифы из BILLmanager.'
|
? 'Нет данных. Синхронизируйте тарифы из BILLmanager.'
|
||||||
: 'По фильтрам ничего не найдено'
|
: 'По фильтрам ничего не найдено'
|
||||||
}
|
}
|
||||||
colSpan={14}
|
colSpan={17}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
+115
-2
@@ -16,7 +16,7 @@ import {
|
|||||||
IconServer,
|
IconServer,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from '@tabler/icons-react'
|
} from '@tabler/icons-react'
|
||||||
import { syncAccount } from '../lib/api'
|
import { syncAccount, bulkUpdateVps } from '../lib/api'
|
||||||
import { UiModal } from '../components/UiModal'
|
import { UiModal } from '../components/UiModal'
|
||||||
import { ConvertedAmount } from '../components/ConvertedAmount'
|
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||||
import { EmptyState } from '../components/EmptyState'
|
import { EmptyState } from '../components/EmptyState'
|
||||||
@@ -66,6 +66,8 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
|
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
|
||||||
const [syncLoading, setSyncLoading] = useState(false)
|
const [syncLoading, setSyncLoading] = useState(false)
|
||||||
const [syncMessage, setSyncMessage] = useState(null)
|
const [syncMessage, setSyncMessage] = useState(null)
|
||||||
|
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||||
|
const [bulkLoading, setBulkLoading] = useState(false)
|
||||||
|
|
||||||
const billmanagerAccounts = useMemo(
|
const billmanagerAccounts = useMemo(
|
||||||
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
|
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
|
||||||
@@ -355,6 +357,54 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
setSyncLoading(false)
|
setSyncLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (selectedIds.size === filteredVps.length) {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set(filteredVps.map((v) => v.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSelect = (id) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBulkStatus = async (status) => {
|
||||||
|
const ids = [...selectedIds]
|
||||||
|
if (ids.length === 0) return
|
||||||
|
setBulkLoading(true)
|
||||||
|
try {
|
||||||
|
await bulkUpdateVps(ids, 'status', status)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
await actions.refreshData()
|
||||||
|
} catch (err) {
|
||||||
|
setSyncMessage(err.message || 'Ошибка массового обновления')
|
||||||
|
} finally {
|
||||||
|
setBulkLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBulkDelete = async () => {
|
||||||
|
const ids = [...selectedIds]
|
||||||
|
if (ids.length === 0) return
|
||||||
|
if (!window.confirm(`Удалить ${ids.length} VPS?`)) return
|
||||||
|
setBulkLoading(true)
|
||||||
|
try {
|
||||||
|
await bulkUpdateVps(ids, 'delete')
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
await actions.refreshData()
|
||||||
|
} catch (err) {
|
||||||
|
setSyncMessage(err.message || 'Ошибка массового удаления')
|
||||||
|
} finally {
|
||||||
|
setBulkLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setFilters({
|
setFilters({
|
||||||
search: '',
|
search: '',
|
||||||
@@ -611,6 +661,52 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h3 className="card-title">VPS список</h3>
|
<h3 className="card-title">VPS список</h3>
|
||||||
|
{selectedIds.size > 0 ? (
|
||||||
|
<div className="d-flex align-items-center gap-2 me-2">
|
||||||
|
<span className="text-secondary small">Выбрано: {selectedIds.size}</span>
|
||||||
|
<div className="btn-group btn-group-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => onBulkStatus('archived')}
|
||||||
|
disabled={bulkLoading}
|
||||||
|
>
|
||||||
|
В архив
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => onBulkStatus('active')}
|
||||||
|
disabled={bulkLoading}
|
||||||
|
>
|
||||||
|
Активен
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => onBulkStatus('paused')}
|
||||||
|
disabled={bulkLoading}
|
||||||
|
>
|
||||||
|
Приостановлен
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-danger"
|
||||||
|
onClick={onBulkDelete}
|
||||||
|
disabled={bulkLoading}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
onClick={() => setSelectedIds(new Set())}
|
||||||
|
>
|
||||||
|
Снять выбор
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{syncMessage ? (
|
{syncMessage ? (
|
||||||
<div className={`alert alert-${syncMessage.startsWith('Синхронизировано') ? 'success' : 'warning'} py-2 mb-0 me-2`}>
|
<div className={`alert alert-${syncMessage.startsWith('Синхронизировано') ? 'success' : 'warning'} py-2 mb-0 me-2`}>
|
||||||
{syncMessage}
|
{syncMessage}
|
||||||
@@ -673,6 +769,15 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
<table className="table card-table table-vcenter">
|
<table className="table card-table table-vcenter">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style={{ width: 40 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
checked={filteredVps.length > 0 && selectedIds.size === filteredVps.length}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
title="Выбрать все на странице"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th>IP / DNS</th>
|
<th>IP / DNS</th>
|
||||||
<th>Хостер / Аккаунт</th>
|
<th>Хостер / Аккаунт</th>
|
||||||
<th>Локация</th>
|
<th>Локация</th>
|
||||||
@@ -720,6 +825,14 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
!nearlyEqual(relatedPaymentSum, tariffAmount)
|
!nearlyEqual(relatedPaymentSum, tariffAmount)
|
||||||
return (
|
return (
|
||||||
<tr key={item.id}>
|
<tr key={item.id}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="form-check-input"
|
||||||
|
checked={selectedIds.has(item.id)}
|
||||||
|
onChange={() => toggleSelect(item.id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="fw-medium">{item.ip}</div>
|
<div className="fw-medium">{item.ip}</div>
|
||||||
{Array.isArray(item.additionalIps)
|
{Array.isArray(item.additionalIps)
|
||||||
@@ -876,7 +989,7 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
|||||||
{filteredVps.length === 0 ? (
|
{filteredVps.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
message="По фильтрам ничего не найдено"
|
message="По фильтрам ничего не найдено"
|
||||||
colSpan={viewMode === 'extended' ? 24 + customFields.length : 8}
|
colSpan={viewMode === 'extended' ? 25 + customFields.length : 9}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user