diff --git a/server/adapters/billmanager/sync.js b/server/adapters/billmanager/sync.js index 09488a0..4d0a0f5 100644 --- a/server/adapters/billmanager/sync.js +++ b/server/adapters/billmanager/sync.js @@ -9,28 +9,33 @@ 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 } + * @param {object} [opts] - { paymentDaysBack, skipTariffs, skipVpsPayments } * @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 if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) { throw new Error('API URL and credentials are required') } const authinfo = apiCredentials.trim() + const fetchVpsPayments = !skipVpsPayments + const fetchTariffs = !skipTariffs + 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) => { + fetchVpsPayments ? fetchVds(apiBaseUrl, authinfo) : [], + fetchVpsPayments ? fetchPayments(apiBaseUrl, authinfo, {}) : [], + fetchVpsPayments ? fetchDashboardInfo(apiBaseUrl, authinfo, { fallbackCurrency: account.currency }).catch(() => null) : null, + fetchTariffs ? fetchVdsOrderPricelistAllDatacenters(apiBaseUrl, authinfo).catch((err) => { console.warn('fetchVdsOrderPricelistAllDatacenters failed:', err.message) return { tariffItems: [], slist: {} } - }), + }) : { tariffItems: [], slist: {} }, ]) const { tariffItems = [], slist = {} } = tariffResult || {} 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 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++ } + } let paymentsCount = 0 + if (fetchVpsPayments) { const existingPayments = new Set( 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) paymentsCount++ } + } - if (dashboardInfo) { + if (fetchVpsPayments && dashboardInfo) { db.run( 'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?', dashboardInfo.balance, @@ -164,6 +172,7 @@ export async function syncFromBillmanager(account, db, _opts = {}) { } let tariffsCount = 0 + if (fetchTariffs) { 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) @@ -209,6 +218,7 @@ export async function syncFromBillmanager(account, db, _opts = {}) { syncedAt, ) } + } return { vpsCount, paymentsCount, tariffsCount, balance: dashboardInfo } } diff --git a/server/db/migrations.js b/server/db/migrations.js index cc7eba2..0425548 100644 --- a/server/db/migrations.js +++ b/server/db/migrations.js @@ -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', run(db) { diff --git a/server/db/schema.js b/server/db/schema.js index e04f0b1..eca155b 100644 --- a/server/db/schema.js +++ b/server/db/schema.js @@ -100,7 +100,9 @@ CREATE TABLE IF NOT EXISTS settings ( autoConvert INTEGER, ratesUpdatedAt TEXT, syncEnabled INTEGER, - syncIntervalMinutes INTEGER + syncIntervalMinutes INTEGER, + syncTariffsIntervalMinutes INTEGER, + customFields TEXT ); CREATE TABLE IF NOT EXISTS sync_log ( diff --git a/server/routes/settings.js b/server/routes/settings.js index 79ee514..e4465eb 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -6,10 +6,19 @@ const router = Router() export function rowToSettings(row) { if (!row) return null + let customFields = [] + if (row.customFields) { + try { + customFields = JSON.parse(row.customFields) + } catch { + customFields = [] + } + } return { ...row, autoConvert: Boolean(row.autoConvert), 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) => { try { const db = getDb() @@ -31,10 +47,12 @@ router.put('/:id', (req, res) => { 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) + const syncTariffsIntervalMinutes = r.syncTariffsIntervalMinutes !== undefined ? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440) : (existing?.syncTariffsIntervalMinutes ?? 1440) + const customFields = serializeCustomFields(r.customFields ?? existing?.customFields) if (existing) { db.prepare(` UPDATE settings SET - baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ? + baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ?, syncTariffsIntervalMinutes = ?, customFields = ? WHERE id = ? `).run( r.baseCurrency ?? existing.baseCurrency ?? 'RUB', @@ -43,12 +61,14 @@ router.put('/:id', (req, res) => { r.ratesUpdatedAt ?? existing.ratesUpdatedAt ?? '', syncEnabled, syncIntervalMinutes, + syncTariffsIntervalMinutes, + customFields, id, ) } else { db.prepare(` - INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, customFields) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, r.baseCurrency ?? 'RUB', @@ -57,6 +77,8 @@ router.put('/:id', (req, res) => { r.ratesUpdatedAt ?? '', syncEnabled, syncIntervalMinutes, + syncTariffsIntervalMinutes, + customFields, ) } startScheduler() @@ -74,9 +96,11 @@ router.post('/', (req, res) => { const id = r.id ?? 'settings-main' const syncEnabled = r.syncEnabled ? 1 : 0 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(` - INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, customFields) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, r.baseCurrency ?? 'RUB', @@ -85,6 +109,8 @@ router.post('/', (req, res) => { r.ratesUpdatedAt ?? '', syncEnabled, syncIntervalMinutes, + syncTariffsIntervalMinutes, + customFields, ) startScheduler() const row = db.prepare('SELECT * FROM settings WHERE id = ?').get(id) diff --git a/server/routes/sync.js b/server/routes/sync.js index 740a68b..3e6470d 100644 --- a/server/routes/sync.js +++ b/server/routes/sync.js @@ -21,7 +21,7 @@ router.get('/status', (req, res) => { try { const db = getDb() 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 `).all() res.json(rows) diff --git a/server/routes/vps.js b/server/routes/vps.js index 7ac98c9..aeab3ec 100644 --- a/server/routes/vps.js +++ b/server/routes/vps.js @@ -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 diff --git a/server/sync-scheduler.js b/server/sync-scheduler.js index 14dcd55..43391ea 100644 --- a/server/sync-scheduler.js +++ b/server/sync-scheduler.js @@ -2,6 +2,7 @@ import { getDb } from './db.js' import { syncFromBillmanager } from './adapters/billmanager/index.js' let syncIntervalId = null +let syncTariffsIntervalId = null export function runScheduledSync() { 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 != '' `).all() for (const account of accounts) { - syncFromBillmanager(account, db).catch((err) => { - console.warn(`Sync failed for account ${account.id}:`, err.message) + syncFromBillmanager(account, db, { skipTariffs: true }).catch((err) => { + console.warn(`Sync VPS/payments failed for account ${account.id}:`, err.message) }) } } 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() { if (syncIntervalId) clearInterval(syncIntervalId) syncIntervalId = null + if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId) + syncTariffsIntervalId = 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) + const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440) 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 { // ignore } diff --git a/src/components/SyncLogTable.jsx b/src/components/SyncLogTable.jsx new file mode 100644 index 0000000..b952821 --- /dev/null +++ b/src/components/SyncLogTable.jsx @@ -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 ( +
+
+

Журнал синхронизации

+
+
+ + + + + + + + + + + + + + {syncLog.map((row) => ( + + + + + + + + + + ))} + {syncLog.length === 0 ? ( + + ) : null} + +
АккаунтНачалоОкончаниеСтатусVPSПлатежиОшибка
{getAccountName(row.accountId)}{formatDate(row.startedAt)}{formatDate(row.finishedAt)} + + {row.status === 'ok' ? 'OK' : row.status === 'error' ? 'Ошибка' : row.status === 'running' ? 'Выполняется' : row.status || '—'} + + {row.vpsCount ?? '—'}{row.paymentsCount ?? '—'} + {row.error ? ( + + {row.error.length > 50 ? `${row.error.slice(0, 50)}…` : row.error} + + ) : ( + '—' + )} +
+
+
+ ) +} diff --git a/src/lib/api.js b/src/lib/api.js index bd103d7..d97cb8d 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -128,6 +128,13 @@ export async function deleteRecord(collectionName, id) { 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) { return fetchApi(`/api/sync/${encodeURIComponent(accountId)}`, { method: 'POST' }) } @@ -142,3 +149,7 @@ export async function testApiConnection(apiBaseUrl, apiCredentials) { body: JSON.stringify({ apiBaseUrl, apiCredentials }), }) } + +export async function fetchSyncStatus() { + return fetchApi('/api/sync/status') +} diff --git a/src/pages/AccountsPage.jsx b/src/pages/AccountsPage.jsx index 1d0ad9d..4bb2a5c 100644 --- a/src/pages/AccountsPage.jsx +++ b/src/pages/AccountsPage.jsx @@ -1,13 +1,14 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { billingModeLabel, faviconUrlFromWebsite, } from '../lib/utils' import { UiModal } from '../components/UiModal' import { EmptyState } from '../components/EmptyState' +import { SyncLogTable } from '../components/SyncLogTable' import { PageHeader } from '../components/PageHeader' 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' const emptyForm = { @@ -33,12 +34,23 @@ export function AccountsPage({ db, actions, settings, ratesData }) { const [balanceLoadingId, setBalanceLoadingId] = useState(null) const [saveError, setSaveError] = useState(null) + const loadSyncLog = () => { + fetchSyncStatus() + .then(setSyncLog) + .catch(() => setSyncLog([])) + } + + useEffect(() => { + loadSyncLog() + }, []) + 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 [syncLog, setSyncLog] = useState([]) const balances = useMemo(() => { return db.providerAccounts.map((account) => { @@ -153,7 +165,10 @@ export function AccountsPage({ db, actions, settings, ratesData }) { 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() + if (result.ok) { + await actions.refreshData() + loadSyncLog() + } } catch (err) { setSyncMessage(err.message || 'Ошибка синхронизации') } finally { @@ -186,7 +201,10 @@ export function AccountsPage({ db, actions, settings, ratesData }) { } else { 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) } @@ -346,6 +364,10 @@ export function AccountsPage({ db, actions, settings, ratesData }) { + +
+ +
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) + + const UPCOMING_DAYS = 7 + const getAccountBalance = (accountId) => { + const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId) + const credits = ledgerRows + .filter((row) => row.direction === 'credit') + .reduce((acc, row) => acc + Number(row.amount || 0), 0) + const debits = ledgerRows + .filter((row) => row.direction === 'debit') + .reduce((acc, row) => acc + Number(row.amount || 0), 0) + 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 ( <> @@ -284,24 +327,32 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
-

Ближайшие списания

+

Истекающая оплата (ближайшие {UPCOMING_DAYS} дней)

- {upcoming.map((item) => ( -
-
-
-
{item.name}
-
{billingModeLabel(item.billingMode)}
-
-
- {item.nextDate.toLocaleDateString('ru-RU')} + {upcoming.map(({ vps: item, paidUntil }) => { + const provider = providers.find((p) => p.id === item.providerId) + const account = providerAccounts.find((a) => a.id === item.providerAccountId) + return ( +
+
+
+
{item.dns || item.ip}
+
+ {provider?.name || '—'} / {account?.name || '—'} +
+
+
+ {paidUntil.toLocaleDateString('ru-RU')} +
-
- ))} + ) + })} {upcoming.length === 0 ? ( -
Списаний пока нет
+
+ Нет VPS с истекающей оплатой в ближайшие {UPCOMING_DAYS} дней +
) : null}
diff --git a/src/pages/PaymentsPage.jsx b/src/pages/PaymentsPage.jsx index 2dde307..2a41615 100644 --- a/src/pages/PaymentsPage.jsx +++ b/src/pages/PaymentsPage.jsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react' +import { IconEdit } from '@tabler/icons-react' import { paymentTypeLabel } from '../lib/utils' import { UiModal } from '../components/UiModal' import { EmptyState } from '../components/EmptyState' @@ -17,6 +18,7 @@ const emptyForm = { export function PaymentsPage({ db, actions, settings, ratesData }) { const [form, setForm] = useState(emptyForm) + const [editingId, setEditingId] = useState(null) const [error, setError] = useState('') const [isModalOpen, setIsModalOpen] = useState(false) @@ -42,7 +44,7 @@ export function PaymentsPage({ db, actions, settings, ratesData }) { } setError('') - actions.create('payments', { + const payload = { type: form.type, date: form.date, amount, @@ -50,25 +52,46 @@ export function PaymentsPage({ db, actions, settings, ratesData }) { 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 || 'Пополнение баланса', - }) + if (editingId) { + actions.update('payments', editingId, payload) + } else { + actions.create('payments', payload) + 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) + setEditingId(null) 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 ( <> @@ -78,7 +101,15 @@ export function PaymentsPage({ db, actions, settings, ratesData }) {

История платежей

-
@@ -118,6 +149,14 @@ export function PaymentsPage({ db, actions, settings, ratesData }) {
+
diff --git a/src/pages/ReportsPage.jsx b/src/pages/ReportsPage.jsx index fa99252..75f010e 100644 --- a/src/pages/ReportsPage.jsx +++ b/src/pages/ReportsPage.jsx @@ -11,14 +11,32 @@ import { ConvertedAmount } from '../components/ConvertedAmount' import { EmptyState } from '../components/EmptyState' 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 }) { const [filters, setFilters] = useState({ providerId: '', country: '', month: '', + dateFrom: '', + dateTo: '', }) 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 .filter((vps) => { const byProvider = !filters.providerId || vps.providerId === filters.providerId @@ -28,11 +46,15 @@ export function ReportsPage({ db, settings, ratesData }) { }) .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) + const payments = db.payments + .filter((item) => item.vpsId === vps.id && item.type !== 'provider_balance_topup') + .filter((item) => !dateFrom || !dateTo || isDateInRange(item.date, dateFrom, dateTo)) + const debits = db.balanceLedger + .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 { providerId: vps.providerId, provider: provider?.name || '-', @@ -45,7 +67,7 @@ export function ReportsPage({ db, settings, ratesData }) { 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 totalExpense = rows.reduce( @@ -88,15 +110,52 @@ export function ReportsPage({ db, settings, ratesData }) { placeholder="например Германия" />
-
+
setFilters((prev) => ({ ...prev, month: e.target.value }))} + onChange={(e) => + setFilters((prev) => ({ + ...prev, + month: e.target.value, + dateFrom: '', + dateTo: '', + })) + } placeholder="2026-03" />
+
+ + + setFilters((prev) => ({ + ...prev, + dateFrom: e.target.value, + month: '', + })) + } + /> +
+
+ + + setFilters((prev) => ({ + ...prev, + dateTo: e.target.value, + month: '', + })) + } + /> +

- Периодическая синхронизация данных (VPS, платежи) из BILLmanager для аккаунтов с настроенным API. + Периодическая синхронизация данных из BILLmanager для аккаунтов с настроенным API. + Два независимых интервала: VPS и платежи обновляются чаще, тарифы — реже.

@@ -209,15 +214,30 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
- + setForm((prev) => ({ ...prev, syncIntervalMinutes: e.target.value }))} + placeholder="60" />
+
+ + setForm((prev) => ({ ...prev, syncTariffsIntervalMinutes: e.target.value }))} + placeholder="1440" + /> +
+ Тарифы меняются редко, можно ставить 24 ч (1440) и больше +
+
+ + + +
+ +
+ ) : null} {syncMessage ? (
{syncMessage} @@ -673,6 +769,15 @@ export function VpsPage({ db, actions, settings, ratesData }) { + @@ -720,6 +825,14 @@ export function VpsPage({ db, actions, settings, ratesData }) { !nearlyEqual(relatedPaymentSum, tariffAmount) return ( +
+ 0 && selectedIds.size === filteredVps.length} + onChange={toggleSelectAll} + title="Выбрать все на странице" + /> + IP / DNS Хостер / Аккаунт Локация
+ toggleSelect(item.id)} + /> +
{item.ip}
{Array.isArray(item.additionalIps) @@ -876,7 +989,7 @@ export function VpsPage({ db, actions, settings, ratesData }) { {filteredVps.length === 0 ? ( ) : null}