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:
@@ -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)
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12 mt-3">
|
||||
<SyncLogTable syncLog={syncLog} providerAccounts={db.providerAccounts} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiModal
|
||||
|
||||
+74
-23
@@ -138,16 +138,59 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
|
||||
(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)
|
||||
|
||||
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 }) {
|
||||
<div className="col-12 col-xl-5">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Ближайшие списания</h3>
|
||||
<h3 className="card-title">Истекающая оплата (ближайшие {UPCOMING_DAYS} дней)</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')}
|
||||
{upcoming.map(({ vps: item, paidUntil }) => {
|
||||
const provider = providers.find((p) => p.id === item.providerId)
|
||||
const account = providerAccounts.find((a) => a.id === item.providerAccountId)
|
||||
return (
|
||||
<div key={item.id} className="list-group-item">
|
||||
<div className="d-flex justify-content-between">
|
||||
<div>
|
||||
<div className="fw-medium">{item.dns || item.ip}</div>
|
||||
<div className="text-secondary small">
|
||||
{provider?.name || '—'} / {account?.name || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-secondary">
|
||||
{paidUntil.toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+56
-16
@@ -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 (
|
||||
<>
|
||||
<PageHeader pretitle="Финансы" title="Платежи" />
|
||||
@@ -78,7 +101,15 @@ export function PaymentsPage({ db, actions, settings, ratesData }) {
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">История платежей</h3>
|
||||
<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>
|
||||
</div>
|
||||
@@ -118,6 +149,14 @@ export function PaymentsPage({ db, actions, settings, ratesData }) {
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<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
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
@@ -142,11 +181,12 @@ export function PaymentsPage({ db, actions, settings, ratesData }) {
|
||||
|
||||
<UiModal
|
||||
open={isModalOpen}
|
||||
title="Новая операция платежа"
|
||||
title={editingId ? 'Редактировать платеж' : 'Новая операция платежа'}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false)
|
||||
setError('')
|
||||
setForm(emptyForm)
|
||||
setEditingId(null)
|
||||
}}
|
||||
size="modal-md"
|
||||
>
|
||||
@@ -256,7 +296,7 @@ export function PaymentsPage({ db, actions, settings, ratesData }) {
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Сохранить
|
||||
{editingId ? 'Сохранить' : 'Добавить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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="например Германия"
|
||||
/>
|
||||
</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>
|
||||
<input
|
||||
className="form-control"
|
||||
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"
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,6 +8,7 @@ const defaultSettings = {
|
||||
autoConvert: true,
|
||||
syncEnabled: false,
|
||||
syncIntervalMinutes: 60,
|
||||
syncTariffsIntervalMinutes: 1440,
|
||||
}
|
||||
|
||||
export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||
@@ -18,6 +19,7 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||
autoConvert: current.autoConvert !== false,
|
||||
syncEnabled: current.syncEnabled !== false && Boolean(current.syncEnabled),
|
||||
syncIntervalMinutes: current.syncIntervalMinutes ?? 60,
|
||||
syncTariffsIntervalMinutes: current.syncTariffsIntervalMinutes ?? 1440,
|
||||
})
|
||||
const [newFieldLabel, setNewFieldLabel] = useState('')
|
||||
const customFields = Array.isArray(current.customFields) ? current.customFields : []
|
||||
@@ -30,8 +32,9 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||
autoConvert: current.autoConvert !== false,
|
||||
syncEnabled: Boolean(current.syncEnabled),
|
||||
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 list = new Set(['RUB', 'USD', 'EUR'])
|
||||
@@ -56,6 +59,7 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||
actions.upsertSettings({
|
||||
syncEnabled: form.syncEnabled,
|
||||
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 className="card-body">
|
||||
<p className="text-secondary small mb-3">
|
||||
Периодическая синхронизация данных (VPS, платежи) из BILLmanager для аккаунтов с настроенным API.
|
||||
Периодическая синхронизация данных из BILLmanager для аккаунтов с настроенным API.
|
||||
Два независимых интервала: VPS и платежи обновляются чаще, тарифы — реже.
|
||||
</p>
|
||||
<form className="row g-3" onSubmit={onSyncSettingsSubmit}>
|
||||
<div className="col-12">
|
||||
@@ -209,15 +214,30 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label">Интервал (минуты)</label>
|
||||
<label className="form-label">Интервал VPS и платежей (минуты)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="15"
|
||||
className="form-control"
|
||||
value={form.syncIntervalMinutes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, syncIntervalMinutes: e.target.value }))}
|
||||
placeholder="60"
|
||||
/>
|
||||
</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">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Сохранить
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { convertCurrency, faviconUrlFromWebsite, normalizeWebsiteUrl } from '../lib/utils'
|
||||
import { convertCurrency, formatCurrency, faviconUrlFromWebsite, normalizeWebsiteUrl } from '../lib/utils'
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
@@ -395,6 +395,9 @@ export function TariffsPage({ db, actions, settings, ratesData }) {
|
||||
<SortHeader column="location" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Локация</SortHeader>
|
||||
<th>CPU</th>
|
||||
<SortHeader column="price" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Цена</SortHeader>
|
||||
<th>₽/vCPU</th>
|
||||
<th>₽/GB RAM</th>
|
||||
<th>₽/GB диск</th>
|
||||
<th>Заказ</th>
|
||||
<th>Панель</th>
|
||||
</tr>
|
||||
@@ -405,6 +408,14 @@ export function TariffsPage({ db, actions, settings, ratesData }) {
|
||||
const account = db.providerAccounts.find(
|
||||
(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 (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
@@ -483,6 +494,27 @@ export function TariffsPage({ db, actions, settings, ratesData }) {
|
||||
{item.price || '—'}
|
||||
</span>
|
||||
</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>
|
||||
<span
|
||||
className={`badge ${
|
||||
@@ -516,7 +548,7 @@ export function TariffsPage({ db, actions, settings, ratesData }) {
|
||||
? 'Нет данных. Синхронизируйте тарифы из BILLmanager.'
|
||||
: 'По фильтрам ничего не найдено'
|
||||
}
|
||||
colSpan={14}
|
||||
colSpan={17}
|
||||
/>
|
||||
) : null}
|
||||
</tbody>
|
||||
|
||||
+115
-2
@@ -16,7 +16,7 @@ import {
|
||||
IconServer,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react'
|
||||
import { syncAccount } from '../lib/api'
|
||||
import { syncAccount, bulkUpdateVps } from '../lib/api'
|
||||
import { UiModal } from '../components/UiModal'
|
||||
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
@@ -66,6 +66,8 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
|
||||
const [syncLoading, setSyncLoading] = useState(false)
|
||||
const [syncMessage, setSyncMessage] = useState(null)
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
const [bulkLoading, setBulkLoading] = useState(false)
|
||||
|
||||
const billmanagerAccounts = useMemo(
|
||||
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
|
||||
@@ -355,6 +357,54 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
||||
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 = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
@@ -611,6 +661,52 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<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 ? (
|
||||
<div className={`alert alert-${syncMessage.startsWith('Синхронизировано') ? 'success' : 'warning'} py-2 mb-0 me-2`}>
|
||||
{syncMessage}
|
||||
@@ -673,6 +769,15 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
||||
<table className="table card-table table-vcenter">
|
||||
<thead>
|
||||
<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>Хостер / Аккаунт</th>
|
||||
<th>Локация</th>
|
||||
@@ -720,6 +825,14 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
||||
!nearlyEqual(relatedPaymentSum, tariffAmount)
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={selectedIds.has(item.id)}
|
||||
onChange={() => toggleSelect(item.id)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="fw-medium">{item.ip}</div>
|
||||
{Array.isArray(item.additionalIps)
|
||||
@@ -876,7 +989,7 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
||||
{filteredVps.length === 0 ? (
|
||||
<EmptyState
|
||||
message="По фильтрам ничего не найдено"
|
||||
colSpan={viewMode === 'extended' ? 24 + customFields.length : 8}
|
||||
colSpan={viewMode === 'extended' ? 25 + customFields.length : 9}
|
||||
/>
|
||||
) : null}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user