init commit
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
billingModeLabel,
|
||||
faviconUrlFromWebsite,
|
||||
} from '../lib/utils'
|
||||
import { UiModal } from '../components/UiModal'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||
import { syncAccount, testApiConnection, fetchAccountBalance } from '../lib/api'
|
||||
import { IconRefresh, IconPlugConnected } from '@tabler/icons-react'
|
||||
|
||||
const emptyForm = {
|
||||
providerId: '',
|
||||
name: '',
|
||||
panelUrl: '',
|
||||
currency: 'USD',
|
||||
billingMode: 'monthly',
|
||||
notes: '',
|
||||
apiType: '',
|
||||
apiBaseUrl: '',
|
||||
apiLogin: '',
|
||||
apiPassword: '',
|
||||
}
|
||||
|
||||
export function AccountsPage({ db, actions, settings, ratesData }) {
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [syncLoadingId, setSyncLoadingId] = useState(null)
|
||||
const [syncLoadingAll, setSyncLoadingAll] = useState(false)
|
||||
const [syncMessage, setSyncMessage] = useState(null)
|
||||
const [balanceLoadingId, setBalanceLoadingId] = useState(null)
|
||||
const [saveError, setSaveError] = useState(null)
|
||||
|
||||
const billmanagerAccounts = useMemo(
|
||||
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
|
||||
[db.providerAccounts],
|
||||
)
|
||||
const [testConnectionLoading, setTestConnectionLoading] = useState(false)
|
||||
const [testConnectionResult, setTestConnectionResult] = useState(null)
|
||||
|
||||
const balances = useMemo(() => {
|
||||
return db.providerAccounts.map((account) => {
|
||||
const rows = db.balanceLedger.filter((row) => row.providerAccountId === account.id)
|
||||
const credits = rows
|
||||
.filter((row) => row.direction === 'credit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
const debits = rows
|
||||
.filter((row) => row.direction === 'debit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
return { accountId: account.id, balance: credits - debits }
|
||||
})
|
||||
}, [db.balanceLedger, db.providerAccounts])
|
||||
|
||||
const getBalance = (accountId) => balances.find((item) => item.accountId === accountId)?.balance || 0
|
||||
|
||||
const getDisplayBalance = (account) => {
|
||||
if (account.apiType === 'billmanager' && account.balance_api != null) {
|
||||
return account.balance_api
|
||||
}
|
||||
return getBalance(account.id)
|
||||
}
|
||||
|
||||
const getDisplayCurrency = (account) => account.balance_currency || account.currency || 'USD'
|
||||
|
||||
const onFetchBalance = async (accountId) => {
|
||||
setBalanceLoadingId(accountId)
|
||||
try {
|
||||
await fetchAccountBalance(accountId)
|
||||
await actions.refreshData()
|
||||
} catch (err) {
|
||||
console.error('Balance fetch failed:', err)
|
||||
} finally {
|
||||
setBalanceLoadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!form.providerId || !form.name.trim()) {
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
providerId: form.providerId,
|
||||
name: form.name,
|
||||
panelUrl: form.panelUrl,
|
||||
currency: form.currency,
|
||||
billingMode: form.billingMode,
|
||||
notes: form.notes,
|
||||
apiType: form.apiType || '',
|
||||
apiBaseUrl: form.apiType === 'billmanager' ? form.apiBaseUrl : '',
|
||||
}
|
||||
if (form.apiType === 'billmanager' && form.apiLogin && form.apiPassword) {
|
||||
payload.apiCredentials = `${form.apiLogin}:${form.apiPassword}`
|
||||
}
|
||||
setSaveError(null)
|
||||
try {
|
||||
if (editingId) {
|
||||
await actions.update('providerAccounts', editingId, payload)
|
||||
} else {
|
||||
await actions.create('providerAccounts', payload)
|
||||
}
|
||||
setForm(emptyForm)
|
||||
setEditingId(null)
|
||||
setIsModalOpen(false)
|
||||
} catch (err) {
|
||||
setSaveError(err.message || 'Ошибка сохранения')
|
||||
}
|
||||
}
|
||||
|
||||
const onTestConnection = async () => {
|
||||
if (!form.apiBaseUrl?.trim() || !form.apiLogin?.trim() || !form.apiPassword?.trim()) {
|
||||
setTestConnectionResult({ ok: false, error: 'Заполните URL, логин и пароль' })
|
||||
return
|
||||
}
|
||||
setTestConnectionLoading(true)
|
||||
setTestConnectionResult(null)
|
||||
try {
|
||||
const result = await testApiConnection(form.apiBaseUrl, `${form.apiLogin}:${form.apiPassword}`)
|
||||
setTestConnectionResult(result)
|
||||
} catch (err) {
|
||||
setTestConnectionResult({ ok: false, error: err.message || 'Ошибка проверки' })
|
||||
} finally {
|
||||
setTestConnectionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onEdit = (account) => {
|
||||
setForm({
|
||||
providerId: account.providerId || '',
|
||||
name: account.name || '',
|
||||
panelUrl: account.panelUrl || '',
|
||||
currency: account.currency || 'USD',
|
||||
billingMode: account.billingMode || 'monthly',
|
||||
notes: account.notes || '',
|
||||
apiType: account.apiType || '',
|
||||
apiBaseUrl: account.apiBaseUrl || '',
|
||||
apiLogin: '',
|
||||
apiPassword: '',
|
||||
})
|
||||
setEditingId(account.id)
|
||||
setIsModalOpen(true)
|
||||
setTestConnectionResult(null)
|
||||
}
|
||||
|
||||
const editingAccount = editingId ? db.providerAccounts.find((a) => a.id === editingId) : null
|
||||
const canTestConnection = form.apiType === 'billmanager' && form.apiBaseUrl?.trim() && form.apiLogin?.trim() && form.apiPassword?.trim()
|
||||
|
||||
const onSync = async (accountId) => {
|
||||
setSyncLoadingId(accountId)
|
||||
setSyncMessage(null)
|
||||
try {
|
||||
const result = await syncAccount(accountId)
|
||||
setSyncMessage(result.ok ? `Синхронизировано: ${result.synced?.vpsCount ?? 0} VPS, ${result.synced?.paymentsCount ?? 0} платежей${result.synced?.balance ? ', баланс обновлён' : ''}` : result.error || 'Ошибка')
|
||||
if (result.ok) await actions.refreshData()
|
||||
} catch (err) {
|
||||
setSyncMessage(err.message || 'Ошибка синхронизации')
|
||||
} finally {
|
||||
setSyncLoadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onSyncAll = async () => {
|
||||
if (billmanagerAccounts.length === 0) return
|
||||
setSyncLoadingAll(true)
|
||||
setSyncMessage(null)
|
||||
let totalVps = 0
|
||||
let totalPayments = 0
|
||||
let lastError = null
|
||||
for (const account of billmanagerAccounts) {
|
||||
try {
|
||||
const result = await syncAccount(account.id)
|
||||
if (result.ok) {
|
||||
totalVps += result.synced?.vpsCount ?? 0
|
||||
totalPayments += result.synced?.paymentsCount ?? 0
|
||||
} else {
|
||||
lastError = result.error
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err.message
|
||||
}
|
||||
}
|
||||
if (lastError && totalVps === 0 && totalPayments === 0) {
|
||||
setSyncMessage(lastError)
|
||||
} else {
|
||||
setSyncMessage(`Синхронизировано: ${totalVps} VPS, ${totalPayments} платежей${lastError ? `. Ошибки: ${lastError}` : ''}`)
|
||||
}
|
||||
if (totalVps > 0 || totalPayments > 0) await actions.refreshData()
|
||||
setSyncLoadingAll(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader pretitle="Справочники" title="Аккаунты хостеров" />
|
||||
<div className="row row-cards">
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Аккаунты и привязанные VPS</h3>
|
||||
{syncMessage ? (
|
||||
<div className={`alert alert-${syncMessage.startsWith('Синхронизировано') ? 'success' : 'warning'} py-2 mb-0 me-2`}>
|
||||
{syncMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="card-actions d-flex gap-2">
|
||||
{billmanagerAccounts.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
onClick={onSyncAll}
|
||||
disabled={syncLoadingAll}
|
||||
title="Синхронизировать VPS и платежи со всех BILLmanager аккаунтов"
|
||||
>
|
||||
{syncLoadingAll ? (
|
||||
<span className="spinner-border spinner-border-sm me-1" role="status" />
|
||||
) : (
|
||||
<IconRefresh size={16} className="me-1" />
|
||||
)}
|
||||
Синхронизировать VPS
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
setForm(emptyForm)
|
||||
setEditingId(null)
|
||||
setIsModalOpen(true)
|
||||
}}
|
||||
>
|
||||
Добавить аккаунт
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Аккаунт</th>
|
||||
<th>Хостер</th>
|
||||
<th>VPS</th>
|
||||
<th>Баланс</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{db.providerAccounts.map((account) => {
|
||||
const provider = db.providers.find((item) => item.id === account.providerId)
|
||||
const linkedVps = db.vps.filter((item) => item.providerAccountId === account.id)
|
||||
return (
|
||||
<tr key={account.id}>
|
||||
<td>{account.name}</td>
|
||||
<td>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{faviconUrlFromWebsite(provider?.website) ? (
|
||||
<img
|
||||
src={faviconUrlFromWebsite(provider?.website)}
|
||||
alt=""
|
||||
width="16"
|
||||
height="16"
|
||||
className="rounded"
|
||||
/>
|
||||
) : null}
|
||||
<span>{provider?.name || '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{linkedVps.map((item) => item.dns || item.ip).join(', ') || '-'}</td>
|
||||
<td>
|
||||
<ConvertedAmount
|
||||
amount={getDisplayBalance(account)}
|
||||
currency={getDisplayCurrency(account)}
|
||||
provider={provider}
|
||||
settings={settings}
|
||||
ratesData={ratesData}
|
||||
/>
|
||||
{account.balance_updated_at ? (
|
||||
<div className="text-secondary small mt-1">
|
||||
Обновлено: {new Date(account.balance_updated_at).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
) : null}
|
||||
{account.enoughmoneyto ? (
|
||||
<div className="text-secondary small mt-1">
|
||||
Хватит до: {account.enoughmoneyto}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<div className="table-actions d-flex gap-1 flex-wrap justify-content-end">
|
||||
{account.apiType === 'billmanager' && account.apiBaseUrl ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => onFetchBalance(account.id)}
|
||||
disabled={balanceLoadingId === account.id}
|
||||
title="Обновить баланс из API"
|
||||
>
|
||||
{balanceLoadingId === account.id ? (
|
||||
<span className="spinner-border spinner-border-sm me-1" role="status" />
|
||||
) : (
|
||||
<IconRefresh size={14} className="me-1" />
|
||||
)}
|
||||
Баланс
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => onSync(account.id)}
|
||||
disabled={syncLoadingId === account.id}
|
||||
title="Синхронизировать VPS и платежи с BILLmanager"
|
||||
>
|
||||
{syncLoadingId === account.id ? (
|
||||
<span className="spinner-border spinner-border-sm me-1" role="status" />
|
||||
) : (
|
||||
<IconRefresh size={14} className="me-1" />
|
||||
)}
|
||||
Синхронизировать
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={() => onEdit(account)}
|
||||
>
|
||||
Изменить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => actions.remove('providerAccounts', account.id)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{db.providerAccounts.length === 0 ? (
|
||||
<EmptyState message="Нет аккаунтов хостеров" colSpan={5} />
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiModal
|
||||
open={isModalOpen}
|
||||
title={editingId ? 'Редактировать аккаунт' : 'Новый аккаунт хостера'}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false)
|
||||
setEditingId(null)
|
||||
setForm(emptyForm)
|
||||
setTestConnectionResult(null)
|
||||
setSaveError(null)
|
||||
}}
|
||||
size="modal-md"
|
||||
>
|
||||
<form onSubmit={onSubmit} className="row g-3">
|
||||
<div className="col-12">
|
||||
<label className="form-label">Хостер</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.providerId}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, providerId: e.target.value }))}
|
||||
required
|
||||
>
|
||||
<option value="">Выберите хостера</option>
|
||||
{db.providers.map((provider) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Имя / Псевдоним аккаунта</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Ссылка на панель управления</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="https://..."
|
||||
value={form.panelUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, panelUrl: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-sm-6">
|
||||
<label className="form-label">Валюта</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.currency}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, currency: e.target.value }))}
|
||||
>
|
||||
<option>USD</option>
|
||||
<option>EUR</option>
|
||||
<option>RUB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-sm-6">
|
||||
<label className="form-label">Режим списания</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.billingMode}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, billingMode: e.target.value }))}
|
||||
>
|
||||
<option value="daily">{billingModeLabel('daily')}</option>
|
||||
<option value="monthly">{billingModeLabel('monthly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<hr className="my-2" />
|
||||
<h6 className="text-secondary mb-2">Интеграция API</h6>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Тип API</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.apiType}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiType: e.target.value, apiBaseUrl: '', apiLogin: '', apiPassword: '' }))}
|
||||
>
|
||||
<option value="">— Не использовать —</option>
|
||||
<option value="billmanager">BILLmanager</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.apiType === 'billmanager' ? (
|
||||
<>
|
||||
<div className="col-12">
|
||||
<label className="form-label">URL API BILLmanager</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="https://bill.example.com:1500/billmgr"
|
||||
value={form.apiBaseUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiBaseUrl: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-sm-6">
|
||||
<label className="form-label">Логин</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="admin"
|
||||
value={form.apiLogin}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiLogin: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-sm-6">
|
||||
<label className="form-label">Пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
placeholder={editingAccount?.apiCredentialsSet ? 'Оставьте пустым, чтобы не менять' : ''}
|
||||
value={form.apiPassword}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiPassword: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 d-flex align-items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
onClick={onTestConnection}
|
||||
disabled={!canTestConnection || testConnectionLoading}
|
||||
>
|
||||
{testConnectionLoading ? (
|
||||
<span className="spinner-border spinner-border-sm me-1" role="status" />
|
||||
) : (
|
||||
<IconPlugConnected size={14} className="me-1" />
|
||||
)}
|
||||
Проверить соединение
|
||||
</button>
|
||||
{testConnectionResult ? (
|
||||
<span className={testConnectionResult.ok ? 'text-success small' : 'text-danger small'}>
|
||||
{testConnectionResult.ok
|
||||
? `Соединение успешно${testConnectionResult.vdsCount != null ? `, VDS: ${testConnectionResult.vdsCount}` : ''}`
|
||||
: testConnectionResult.error}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="col-12">
|
||||
<label className="form-label">Комментарий</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
{saveError ? (
|
||||
<div className="col-12">
|
||||
<div className="alert alert-danger py-2">{saveError}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="col-12 d-flex gap-2 justify-content-end">
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={() => setIsModalOpen(false)}>
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{editingId ? 'Сохранить' : 'Добавить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</UiModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
billingModeLabel,
|
||||
paymentTypeLabel,
|
||||
} from '../lib/utils'
|
||||
import { UiModal } from '../components/UiModal'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||
|
||||
const emptyForm = {
|
||||
type: 'daily_debit',
|
||||
providerAccountId: '',
|
||||
vpsId: '',
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
amount: '',
|
||||
currency: 'USD',
|
||||
note: '',
|
||||
}
|
||||
|
||||
export function BalancePage({ db, actions, settings, ratesData }) {
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [error, setError] = useState('')
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const vpsOptions = useMemo(
|
||||
() => db.vps.filter((vps) => !form.providerAccountId || vps.providerAccountId === form.providerAccountId),
|
||||
[db.vps, form.providerAccountId],
|
||||
)
|
||||
|
||||
const accountBalances = useMemo(() => {
|
||||
return db.providerAccounts.map((account) => {
|
||||
const records = db.balanceLedger.filter((item) => item.providerAccountId === account.id)
|
||||
const value = records.reduce((acc, row) => {
|
||||
const amount = Number(row.amount || 0)
|
||||
return row.direction === 'credit' ? acc + amount : acc - amount
|
||||
}, 0)
|
||||
return { ...account, balance: value }
|
||||
})
|
||||
}, [db.balanceLedger, db.providerAccounts])
|
||||
|
||||
const onSubmit = (event) => {
|
||||
event.preventDefault()
|
||||
const amount = Number(form.amount)
|
||||
if (!form.providerAccountId) {
|
||||
setError('Выберите аккаунт')
|
||||
return
|
||||
}
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setError('Сумма должна быть больше 0')
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
actions.create('balanceLedger', {
|
||||
type: form.type,
|
||||
providerAccountId: form.providerAccountId,
|
||||
vpsId: form.vpsId || '',
|
||||
date: form.date,
|
||||
amount,
|
||||
currency: form.currency,
|
||||
direction: 'debit',
|
||||
note: form.note,
|
||||
})
|
||||
setForm(emptyForm)
|
||||
setIsModalOpen(false)
|
||||
}
|
||||
|
||||
const directionLabel = (direction) => (direction === 'credit' ? 'Пополнение' : 'Списание')
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader pretitle="Финансы" title="Баланс и списания" />
|
||||
<div className="row row-cards">
|
||||
<div className="col-12 card-stack">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Баланс по аккаунтам</h3>
|
||||
<div className="card-actions">
|
||||
<button className="btn btn-primary" type="button" onClick={() => setIsModalOpen(true)}>
|
||||
Добавить списание
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Аккаунт</th>
|
||||
<th>Режим</th>
|
||||
<th>Баланс</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{accountBalances.map((account) => (
|
||||
<tr key={account.id}>
|
||||
<td>{account.name}</td>
|
||||
<td>{billingModeLabel(account.billingMode)}</td>
|
||||
<td>
|
||||
<ConvertedAmount
|
||||
amount={account.balance}
|
||||
currency={account.currency}
|
||||
provider={db.providers.find((item) => item.id === account.providerId)}
|
||||
settings={settings}
|
||||
ratesData={ratesData}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{accountBalances.length === 0 ? (
|
||||
<EmptyState message="Нет аккаунтов" colSpan={3} />
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Журнал операций</h3>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Тип</th>
|
||||
<th>Направление</th>
|
||||
<th>Аккаунт / VPS</th>
|
||||
<th>Сумма</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{db.balanceLedger.map((row) => {
|
||||
const account = db.providerAccounts.find((item) => item.id === row.providerAccountId)
|
||||
const provider = db.providers.find((item) => item.id === account?.providerId)
|
||||
const vps = db.vps.find((item) => item.id === row.vpsId)
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td>{row.date}</td>
|
||||
<td>{paymentTypeLabel(row.type)}</td>
|
||||
<td>
|
||||
<span className={`badge ${row.direction === 'credit' ? 'bg-green-lt' : 'bg-red-lt'}`}>
|
||||
{directionLabel(row.direction)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div>{account?.name || '-'}</div>
|
||||
<div className="text-secondary">{vps?.dns || vps?.ip || '-'}</div>
|
||||
</td>
|
||||
<td>
|
||||
<ConvertedAmount
|
||||
amount={row.amount}
|
||||
currency={row.currency}
|
||||
provider={provider}
|
||||
settings={settings}
|
||||
ratesData={ratesData}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<div className="table-actions">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
type="button"
|
||||
onClick={() => actions.remove('balanceLedger', row.id)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{db.balanceLedger.length === 0 ? (
|
||||
<EmptyState message="Журнал операций пуст" colSpan={6} />
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiModal
|
||||
open={isModalOpen}
|
||||
title="Добавить списание"
|
||||
onClose={() => {
|
||||
setIsModalOpen(false)
|
||||
setError('')
|
||||
setForm(emptyForm)
|
||||
}}
|
||||
size="modal-md"
|
||||
>
|
||||
<form className="row g-3" onSubmit={onSubmit}>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Тип списания</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.type}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value }))}
|
||||
>
|
||||
<option value="daily_debit">Ежедневное списание</option>
|
||||
<option value="monthly_debit">Ежемесячное списание</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Аккаунт</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.providerAccountId}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, providerAccountId: e.target.value, vpsId: '' }))
|
||||
}
|
||||
>
|
||||
<option value="">Выберите аккаунт</option>
|
||||
{db.providerAccounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">VPS (необязательно)</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.vpsId}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, vpsId: e.target.value }))}
|
||||
>
|
||||
<option value="">Без привязки</option>
|
||||
{vpsOptions.map((vps) => (
|
||||
<option key={vps.id} value={vps.id}>
|
||||
{vps.dns || vps.ip}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-sm-6">
|
||||
<label className="form-label">Дата</label>
|
||||
<input
|
||||
className="form-control"
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-sm-6">
|
||||
<label className="form-label">Валюта</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.currency}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, currency: e.target.value }))}
|
||||
>
|
||||
<option>USD</option>
|
||||
<option>EUR</option>
|
||||
<option>RUB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Сумма</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
className="form-control"
|
||||
value={form.amount}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, amount: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Комментарий</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
value={form.note}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, note: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
{error ? <div className="col-12 text-danger small">{error}</div> : null}
|
||||
<div className="col-12 d-flex gap-2 justify-content-end">
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={() => setIsModalOpen(false)}>
|
||||
Отмена
|
||||
</button>
|
||||
<button className="btn btn-primary" type="submit">
|
||||
Добавить списание
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</UiModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
billingModeLabel,
|
||||
convertCurrency,
|
||||
formatCurrency,
|
||||
monthKey,
|
||||
} from '../lib/utils'
|
||||
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { ExpenseChart } from '../components/ExpenseChart'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { ProviderPieChart } from '../components/ProviderPieChart'
|
||||
import {
|
||||
IconCash,
|
||||
IconClockHour4,
|
||||
IconServer,
|
||||
IconWallet,
|
||||
} from '@tabler/icons-react'
|
||||
|
||||
export function DashboardPage({ db = {}, settings, ratesData }) {
|
||||
const vps = Array.isArray(db.vps) ? db.vps : []
|
||||
const providerAccounts = Array.isArray(db.providerAccounts) ? db.providerAccounts : []
|
||||
const balanceLedger = Array.isArray(db.balanceLedger) ? db.balanceLedger : []
|
||||
const payments = Array.isArray(db.payments) ? db.payments : []
|
||||
const providers = Array.isArray(db.providers) ? db.providers : []
|
||||
|
||||
const now = new Date()
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
|
||||
|
||||
const activeVpsCount = vps.filter((item) => item.status === 'active').length
|
||||
|
||||
const monthForecast = useMemo(() => {
|
||||
return vps
|
||||
.filter((item) => item.status === 'active')
|
||||
.reduce((acc, item) => {
|
||||
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
||||
const amount =
|
||||
tariffType === 'daily'
|
||||
? Number(item.dailyRate || 0) * 30
|
||||
: Number(item.monthlyRate || 0)
|
||||
return acc + convertCurrency(amount, item.currency || 'USD', baseCurrency, ratesData)
|
||||
}, 0)
|
||||
}, [vps, baseCurrency, ratesData])
|
||||
|
||||
const monthExpenses = useMemo(() => {
|
||||
return [...payments, ...balanceLedger]
|
||||
.filter((item) => monthKey(item.date) === currentMonth)
|
||||
.filter((item) => {
|
||||
if (item.type === 'provider_balance_topup') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.reduce(
|
||||
(acc, item) =>
|
||||
acc + convertCurrency(item.amount || 0, item.currency || 'USD', baseCurrency, ratesData),
|
||||
0,
|
||||
)
|
||||
}, [payments, balanceLedger, currentMonth, baseCurrency, ratesData])
|
||||
|
||||
const prevMonthKey = useMemo(() => {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}, [currentMonth])
|
||||
|
||||
const prevMonthExpenses = useMemo(() => {
|
||||
return [...payments, ...balanceLedger]
|
||||
.filter((item) => monthKey(item.date) === prevMonthKey)
|
||||
.filter((item) => item.type !== 'provider_balance_topup')
|
||||
.reduce(
|
||||
(acc, item) =>
|
||||
acc + convertCurrency(item.amount || 0, item.currency || 'USD', baseCurrency, ratesData),
|
||||
0,
|
||||
)
|
||||
}, [payments, balanceLedger, prevMonthKey, baseCurrency, ratesData])
|
||||
|
||||
const monthlyExpenseData = useMemo(() => {
|
||||
const months = []
|
||||
const now = new Date()
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
const amount = [...payments, ...balanceLedger]
|
||||
.filter((item) => monthKey(item.date) === key)
|
||||
.filter((item) => item.type !== 'provider_balance_topup')
|
||||
.reduce(
|
||||
(acc, item) =>
|
||||
acc + convertCurrency(item.amount || 0, item.currency || 'USD', baseCurrency, ratesData),
|
||||
0,
|
||||
)
|
||||
months.push({
|
||||
monthKey: key,
|
||||
monthLabel: d.toLocaleDateString('ru-RU', { month: 'short', year: '2-digit' }),
|
||||
amount,
|
||||
})
|
||||
}
|
||||
return months
|
||||
}, [payments, balanceLedger, baseCurrency, ratesData])
|
||||
|
||||
const providerExpenseData = useMemo(() => {
|
||||
const byProvider = {}
|
||||
;[...payments, ...balanceLedger]
|
||||
.filter((item) => item.type !== 'provider_balance_topup')
|
||||
.forEach((item) => {
|
||||
const vpsItem = item.vpsId ? vps.find((v) => v.id === item.vpsId) : null
|
||||
const providerId = vpsItem?.providerId || (item.providerAccountId
|
||||
? providerAccounts.find((a) => a.id === item.providerAccountId)?.providerId
|
||||
: null)
|
||||
const pid = providerId || 'unknown'
|
||||
if (!byProvider[pid]) byProvider[pid] = 0
|
||||
byProvider[pid] += convertCurrency(
|
||||
item.amount || 0,
|
||||
item.currency || 'USD',
|
||||
baseCurrency,
|
||||
ratesData,
|
||||
)
|
||||
})
|
||||
return Object.entries(byProvider).map(([providerId, amount]) => ({
|
||||
providerId,
|
||||
providerName: providerId === 'unknown' ? '—' : (providers.find((p) => p.id === providerId)?.name || providerId),
|
||||
amount,
|
||||
}))
|
||||
}, [payments, balanceLedger, vps, providerAccounts, providers, baseCurrency, ratesData])
|
||||
|
||||
const accountBalances = providerAccounts.map((account) => {
|
||||
const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === account.id)
|
||||
const credits = ledgerRows
|
||||
.filter((row) => row.direction === 'credit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
const debits = ledgerRows
|
||||
.filter((row) => row.direction === 'debit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
return { ...account, balance: credits - debits }
|
||||
})
|
||||
|
||||
const totalBalance = accountBalances.reduce(
|
||||
(acc, row) => acc + convertCurrency(row.balance, row.currency, baseCurrency, ratesData),
|
||||
0,
|
||||
)
|
||||
const upcoming = providerAccounts
|
||||
.map((account) => ({
|
||||
...account,
|
||||
nextDate:
|
||||
account.billingMode === 'daily'
|
||||
? new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||
: new Date(now.getFullYear(), now.getMonth() + 1, 1),
|
||||
}))
|
||||
.sort((a, b) => a.nextDate - b.nextDate)
|
||||
.slice(0, 5)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader pretitle="Обзор" title="Дашборд" />
|
||||
<div className="row row-cards">
|
||||
<div className="col-sm-6 col-lg-3">
|
||||
<div className="card metric-card metric-blue h-100">
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div className="text-secondary">Активные VPS</div>
|
||||
<span className="metric-icon bg-blue-lt text-blue">
|
||||
<IconServer size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat-value">{activeVpsCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-6 col-lg-3">
|
||||
<div className="card metric-card metric-green h-100">
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div className="text-secondary">Расходы за месяц</div>
|
||||
<span className="metric-icon bg-green-lt text-green">
|
||||
<IconCash size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat-value">{formatCurrency(monthExpenses, baseCurrency)}</div>
|
||||
<div className="text-secondary small mt-1">
|
||||
Прогноз: {formatCurrency(monthForecast, baseCurrency)}
|
||||
</div>
|
||||
<div className="text-secondary small">
|
||||
Прошлый месяц: {formatCurrency(prevMonthExpenses, baseCurrency)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-6 col-lg-3">
|
||||
<div className="card metric-card metric-yellow h-100">
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div className="text-secondary">Аккаунтов хостеров</div>
|
||||
<span className="metric-icon bg-yellow-lt text-yellow">
|
||||
<IconClockHour4 size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat-value">{providerAccounts.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-6 col-lg-3">
|
||||
<div className="card metric-card metric-purple h-100">
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div className="text-secondary">Суммарный баланс</div>
|
||||
<span className="metric-icon bg-purple-lt text-purple">
|
||||
<IconWallet size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="stat-value">{formatCurrency(totalBalance, baseCurrency)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-xl-6">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Расходы по месяцам</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<ExpenseChart
|
||||
data={monthlyExpenseData}
|
||||
baseCurrency={baseCurrency}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-xl-6">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Расходы по хостеру</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<ProviderPieChart
|
||||
data={providerExpenseData}
|
||||
baseCurrency={baseCurrency}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-xl-7">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Остатки по аккаунтам хостеров</h3>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Аккаунт</th>
|
||||
<th>Валюта</th>
|
||||
<th>Баланс</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{accountBalances.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.name}</td>
|
||||
<td>{item.currency}</td>
|
||||
<td>
|
||||
<ConvertedAmount
|
||||
amount={item.balance}
|
||||
currency={item.currency}
|
||||
provider={db.providers.find((provider) => provider.id === item.providerId)}
|
||||
settings={settings}
|
||||
ratesData={ratesData}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{accountBalances.length === 0 ? (
|
||||
<EmptyState message="Пока нет аккаунтов" colSpan={3} />
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-xl-5">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Ближайшие списания</h3>
|
||||
</div>
|
||||
<div className="list-group list-group-flush">
|
||||
{upcoming.map((item) => (
|
||||
<div key={item.id} className="list-group-item">
|
||||
<div className="d-flex justify-content-between">
|
||||
<div>
|
||||
<div className="fw-medium">{item.name}</div>
|
||||
<div className="text-secondary small">{billingModeLabel(item.billingMode)}</div>
|
||||
</div>
|
||||
<div className="text-secondary">
|
||||
{item.nextDate.toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{upcoming.length === 0 ? (
|
||||
<div className="list-group-item text-secondary text-center py-4">Списаний пока нет</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { paymentTypeLabel } from '../lib/utils'
|
||||
import { UiModal } from '../components/UiModal'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||
|
||||
const emptyForm = {
|
||||
type: 'direct_vps_payment',
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
amount: '',
|
||||
currency: 'USD',
|
||||
providerAccountId: '',
|
||||
vpsId: '',
|
||||
note: '',
|
||||
}
|
||||
|
||||
export function PaymentsPage({ db, actions, settings, ratesData }) {
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [error, setError] = useState('')
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const vpsOptions = useMemo(
|
||||
() => db.vps.filter((item) => !form.providerAccountId || item.providerAccountId === form.providerAccountId),
|
||||
[db.vps, form.providerAccountId],
|
||||
)
|
||||
|
||||
const onSubmit = (event) => {
|
||||
event.preventDefault()
|
||||
const amount = Number(form.amount)
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setError('Сумма должна быть больше 0')
|
||||
return
|
||||
}
|
||||
if (!form.providerAccountId) {
|
||||
setError('Выберите аккаунт хостера')
|
||||
return
|
||||
}
|
||||
if (form.type === 'direct_vps_payment' && !form.vpsId) {
|
||||
setError('Для прямого платежа выберите VPS')
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
|
||||
actions.create('payments', {
|
||||
type: form.type,
|
||||
date: form.date,
|
||||
amount,
|
||||
currency: form.currency,
|
||||
providerAccountId: form.providerAccountId,
|
||||
vpsId: form.vpsId || '',
|
||||
note: form.note,
|
||||
})
|
||||
|
||||
if (form.type === 'provider_balance_topup') {
|
||||
actions.create('balanceLedger', {
|
||||
type: 'provider_balance_topup',
|
||||
date: form.date,
|
||||
amount,
|
||||
currency: form.currency,
|
||||
direction: 'credit',
|
||||
providerAccountId: form.providerAccountId,
|
||||
vpsId: '',
|
||||
note: form.note || 'Пополнение баланса',
|
||||
})
|
||||
}
|
||||
|
||||
setForm(emptyForm)
|
||||
setIsModalOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader pretitle="Финансы" title="Платежи" />
|
||||
<div className="row row-cards">
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">История платежей</h3>
|
||||
<div className="card-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => setIsModalOpen(true)}>
|
||||
Добавить платеж
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Тип</th>
|
||||
<th>Аккаунт / VPS</th>
|
||||
<th>Сумма</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{db.payments.map((payment) => {
|
||||
const account = db.providerAccounts.find((item) => item.id === payment.providerAccountId)
|
||||
const provider = db.providers.find((item) => item.id === account?.providerId)
|
||||
const vps = db.vps.find((item) => item.id === payment.vpsId)
|
||||
return (
|
||||
<tr key={payment.id}>
|
||||
<td>{payment.date}</td>
|
||||
<td>{paymentTypeLabel(payment.type)}</td>
|
||||
<td>
|
||||
<div>{account?.name || '-'}</div>
|
||||
<div className="text-secondary">{vps?.dns || vps?.ip || '-'}</div>
|
||||
</td>
|
||||
<td>
|
||||
<ConvertedAmount
|
||||
amount={payment.amount}
|
||||
currency={payment.currency}
|
||||
provider={provider}
|
||||
settings={settings}
|
||||
ratesData={ratesData}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<div className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => actions.remove('payments', payment.id)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{db.payments.length === 0 ? (
|
||||
<EmptyState message="Нет платежей" colSpan={5} />
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiModal
|
||||
open={isModalOpen}
|
||||
title="Новая операция платежа"
|
||||
onClose={() => {
|
||||
setIsModalOpen(false)
|
||||
setError('')
|
||||
setForm(emptyForm)
|
||||
}}
|
||||
size="modal-md"
|
||||
>
|
||||
<form className="row g-3" onSubmit={onSubmit}>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Тип</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.type}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
type: e.target.value,
|
||||
vpsId: '',
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="direct_vps_payment">Прямой платеж за VPS</option>
|
||||
<option value="provider_balance_topup">Пополнение баланса хостера</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Аккаунт хостера</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.providerAccountId}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
providerAccountId: e.target.value,
|
||||
vpsId: '',
|
||||
}))
|
||||
}
|
||||
required
|
||||
>
|
||||
<option value="">Выберите аккаунт</option>
|
||||
{db.providerAccounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{form.type === 'direct_vps_payment' ? (
|
||||
<div className="col-12">
|
||||
<label className="form-label">VPS</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.vpsId}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, vpsId: e.target.value }))}
|
||||
required
|
||||
>
|
||||
<option value="">Выберите VPS</option>
|
||||
{vpsOptions.map((vps) => (
|
||||
<option key={vps.id} value={vps.id}>
|
||||
{vps.dns || vps.ip}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="col-12 col-sm-6">
|
||||
<label className="form-label">Дата</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-sm-6">
|
||||
<label className="form-label">Валюта</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.currency}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, currency: e.target.value }))}
|
||||
>
|
||||
<option>USD</option>
|
||||
<option>EUR</option>
|
||||
<option>RUB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Сумма</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
className="form-control"
|
||||
value={form.amount}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, amount: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Комментарий</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
value={form.note}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, note: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
{error ? <div className="col-12 text-danger small">{error}</div> : null}
|
||||
<div className="col-12 d-flex gap-2 justify-content-end">
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={() => setIsModalOpen(false)}>
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</UiModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useState } from 'react'
|
||||
import { UiModal } from '../components/UiModal'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { faviconUrlFromWebsite } from '../lib/utils'
|
||||
|
||||
const emptyForm = {
|
||||
name: '',
|
||||
website: '',
|
||||
contact: '',
|
||||
baseCurrency: 'RUB',
|
||||
usdRate: '',
|
||||
eurRate: '',
|
||||
notes: '',
|
||||
}
|
||||
|
||||
export function ProvidersPage({ db, actions }) {
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const onSubmit = (event) => {
|
||||
event.preventDefault()
|
||||
if (!form.name.trim()) {
|
||||
return
|
||||
}
|
||||
if (editingId) {
|
||||
actions.update('providers', editingId, form)
|
||||
} else {
|
||||
actions.create('providers', form)
|
||||
}
|
||||
setForm(emptyForm)
|
||||
setEditingId(null)
|
||||
setIsModalOpen(false)
|
||||
}
|
||||
|
||||
const onEdit = (provider) => {
|
||||
setForm({
|
||||
name: provider.name || '',
|
||||
website: provider.website || '',
|
||||
contact: provider.contact || '',
|
||||
baseCurrency: provider.baseCurrency || 'RUB',
|
||||
usdRate: provider.usdRate || '',
|
||||
eurRate: provider.eurRate || '',
|
||||
notes: provider.notes || '',
|
||||
})
|
||||
setEditingId(provider.id)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader pretitle="Справочники" title="Хостеры" />
|
||||
<div className="row row-cards">
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Список хостеров</h3>
|
||||
<div className="card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
setForm(emptyForm)
|
||||
setEditingId(null)
|
||||
setIsModalOpen(true)
|
||||
}}
|
||||
>
|
||||
Добавить хостера
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Сайт</th>
|
||||
<th>Валюта / курсы</th>
|
||||
<th>Контакт</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{db.providers.map((provider) => (
|
||||
<tr key={provider.id}>
|
||||
<td>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{faviconUrlFromWebsite(provider.website) ? (
|
||||
<img
|
||||
src={faviconUrlFromWebsite(provider.website)}
|
||||
alt=""
|
||||
width="16"
|
||||
height="16"
|
||||
className="rounded"
|
||||
/>
|
||||
) : null}
|
||||
<span>{provider.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{provider.website || '-'}</td>
|
||||
<td>
|
||||
<div>{provider.baseCurrency || 'RUB'}</div>
|
||||
<div className="text-secondary small">
|
||||
USD: {provider.usdRate || 'auto'} / EUR: {provider.eurRate || 'auto'}
|
||||
</div>
|
||||
</td>
|
||||
<td>{provider.contact || '-'}</td>
|
||||
<td className="text-end">
|
||||
<div className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={() => onEdit(provider)}
|
||||
>
|
||||
Изменить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => actions.remove('providers', provider.id)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{db.providers.length === 0 ? (
|
||||
<EmptyState message="Пока нет хостеров" colSpan={5} />
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiModal
|
||||
open={isModalOpen}
|
||||
title={editingId ? 'Редактировать хостера' : 'Добавить хостера'}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false)
|
||||
setEditingId(null)
|
||||
setForm(emptyForm)
|
||||
}}
|
||||
size="modal-md"
|
||||
>
|
||||
<form onSubmit={onSubmit} className="row g-3">
|
||||
<div className="col-12">
|
||||
<label className="form-label">Название</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Сайт</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.website}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, website: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Контакт</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.contact}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, contact: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-sm-4">
|
||||
<label className="form-label">Валюта приёма платежей</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.baseCurrency}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseCurrency: e.target.value }))}
|
||||
>
|
||||
<option>RUB</option>
|
||||
<option>USD</option>
|
||||
<option>EUR</option>
|
||||
</select>
|
||||
<div className="text-secondary small">Валюта, в которой хостер принимает платежи</div>
|
||||
</div>
|
||||
<div className="col-12 col-sm-4">
|
||||
<label className="form-label">Курс USD</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0001"
|
||||
className="form-control"
|
||||
placeholder="auto"
|
||||
value={form.usdRate}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, usdRate: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-sm-4">
|
||||
<label className="form-label">Курс EUR</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0001"
|
||||
className="form-control"
|
||||
placeholder="auto"
|
||||
value={form.eurRate}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, eurRate: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Заметки</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 d-flex gap-2 justify-content-end">
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={() => setIsModalOpen(false)}>
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{editingId ? 'Сохранить' : 'Добавить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</UiModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
convertCurrency,
|
||||
downloadTextFile,
|
||||
formatCurrency,
|
||||
monthKey,
|
||||
toCsv,
|
||||
vpsStatusLabel,
|
||||
} from '../lib/utils'
|
||||
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
|
||||
export function ReportsPage({ db, settings, ratesData }) {
|
||||
const [filters, setFilters] = useState({
|
||||
providerId: '',
|
||||
country: '',
|
||||
month: '',
|
||||
})
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return db.vps
|
||||
.filter((vps) => {
|
||||
const byProvider = !filters.providerId || vps.providerId === filters.providerId
|
||||
const byCountry =
|
||||
!filters.country || vps.country?.toLowerCase().includes(filters.country.toLowerCase())
|
||||
return byProvider && byCountry
|
||||
})
|
||||
.map((vps) => {
|
||||
const provider = db.providers.find((item) => item.id === vps.providerId)
|
||||
const payments = db.payments.filter((item) => item.vpsId === vps.id)
|
||||
const monthlyPayments = filters.month
|
||||
? payments.filter((item) => monthKey(item.date) === filters.month)
|
||||
: payments
|
||||
const total = monthlyPayments.reduce((acc, item) => acc + Number(item.amount || 0), 0)
|
||||
return {
|
||||
providerId: vps.providerId,
|
||||
provider: provider?.name || '-',
|
||||
vps: vps.dns || vps.ip,
|
||||
ip: vps.ip,
|
||||
country: vps.country || '',
|
||||
city: vps.city || '',
|
||||
status: vps.status,
|
||||
expense: Number(total.toFixed(2)),
|
||||
currency: vps.currency || 'USD',
|
||||
}
|
||||
})
|
||||
}, [db.payments, db.providers, db.vps, filters])
|
||||
|
||||
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
|
||||
const totalExpense = rows.reduce(
|
||||
(acc, row) => acc + convertCurrency(row.expense, row.currency, baseCurrency, ratesData),
|
||||
0,
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader pretitle="Аналитика" title="Отчёты" />
|
||||
<div className="row row-cards">
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Фильтры и экспорт</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row g-2 align-items-end">
|
||||
<div className="col-12 col-md-6 col-xl-3">
|
||||
<label className="form-label">Хостер</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={filters.providerId}
|
||||
onChange={(e) => setFilters((prev) => ({ ...prev, providerId: e.target.value }))}
|
||||
>
|
||||
<option value="">Все хостеры</option>
|
||||
{db.providers.map((provider) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6 col-xl-3">
|
||||
<label className="form-label">Страна</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={filters.country}
|
||||
onChange={(e) => setFilters((prev) => ({ ...prev, country: e.target.value }))}
|
||||
placeholder="например Германия"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6 col-xl-3">
|
||||
<label className="form-label">Период (YYYY-MM)</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={filters.month}
|
||||
onChange={(e) => setFilters((prev) => ({ ...prev, month: e.target.value }))}
|
||||
placeholder="2026-03"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6 col-xl-3">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary w-100"
|
||||
onClick={() => {
|
||||
const csv = toCsv(rows)
|
||||
downloadTextFile('vps-report.csv', csv)
|
||||
}}
|
||||
>
|
||||
Экспорт CSV
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-12 col-md-6 col-xl-3">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary w-100"
|
||||
onClick={() => {
|
||||
downloadTextFile(
|
||||
'vps-tracker-backup.json',
|
||||
JSON.stringify(db, null, 2),
|
||||
)
|
||||
}}
|
||||
>
|
||||
Резервная копия JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Сводный отчет</h3>
|
||||
<div className="card-actions text-secondary">
|
||||
Итого: {formatCurrency(totalExpense, baseCurrency)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Хостер</th>
|
||||
<th>VPS</th>
|
||||
<th>Локация</th>
|
||||
<th>Статус</th>
|
||||
<th>Расход</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={`${row.ip}-${row.vps}`}>
|
||||
<td>{row.provider}</td>
|
||||
<td>
|
||||
<div>{row.vps}</div>
|
||||
<div className="text-secondary">{row.ip}</div>
|
||||
</td>
|
||||
<td>
|
||||
{row.country} / {row.city}
|
||||
</td>
|
||||
<td>{vpsStatusLabel(row.status)}</td>
|
||||
<td>
|
||||
<ConvertedAmount
|
||||
amount={row.expense}
|
||||
currency={row.currency}
|
||||
provider={db.providers.find((item) => item.id === row.providerId)}
|
||||
settings={settings}
|
||||
ratesData={ratesData}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState message="Нет данных под фильтр" colSpan={5} />
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { IconPlus, IconTrash } from '@tabler/icons-react'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
|
||||
const defaultSettings = {
|
||||
baseCurrency: 'RUB',
|
||||
ratesUrl: 'https://www.cbr-xml-daily.ru/latest.js',
|
||||
autoConvert: true,
|
||||
syncEnabled: false,
|
||||
syncIntervalMinutes: 60,
|
||||
}
|
||||
|
||||
export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||
const current = db.settings?.[0] || defaultSettings
|
||||
const [form, setForm] = useState({
|
||||
baseCurrency: current.baseCurrency || 'RUB',
|
||||
ratesUrl: current.ratesUrl || 'https://www.cbr-xml-daily.ru/latest.js',
|
||||
autoConvert: current.autoConvert !== false,
|
||||
syncEnabled: current.syncEnabled !== false && Boolean(current.syncEnabled),
|
||||
syncIntervalMinutes: current.syncIntervalMinutes ?? 60,
|
||||
})
|
||||
const [newFieldLabel, setNewFieldLabel] = useState('')
|
||||
const customFields = Array.isArray(current.customFields) ? current.customFields : []
|
||||
|
||||
useEffect(() => {
|
||||
/* eslint-disable-next-line react-hooks/set-state-in-effect -- sync form when settings change from parent */
|
||||
setForm({
|
||||
baseCurrency: current.baseCurrency || 'RUB',
|
||||
ratesUrl: current.ratesUrl || 'https://www.cbr-xml-daily.ru/latest.js',
|
||||
autoConvert: current.autoConvert !== false,
|
||||
syncEnabled: Boolean(current.syncEnabled),
|
||||
syncIntervalMinutes: current.syncIntervalMinutes ?? 60,
|
||||
})
|
||||
}, [current.baseCurrency, current.ratesUrl, current.autoConvert, current.syncEnabled, current.syncIntervalMinutes])
|
||||
|
||||
const availableCurrencies = useMemo(() => {
|
||||
const list = new Set(['RUB', 'USD', 'EUR'])
|
||||
if (ratesData?.rates) {
|
||||
Object.keys(ratesData.rates).forEach((code) => list.add(code))
|
||||
}
|
||||
return [...list].sort()
|
||||
}, [ratesData])
|
||||
|
||||
const onSubmit = (event) => {
|
||||
event.preventDefault()
|
||||
actions.upsertSettings({
|
||||
baseCurrency: form.baseCurrency,
|
||||
ratesUrl: form.ratesUrl,
|
||||
autoConvert: form.autoConvert,
|
||||
ratesUpdatedAt: ratesData?.date || '',
|
||||
})
|
||||
}
|
||||
|
||||
const onSyncSettingsSubmit = (event) => {
|
||||
event.preventDefault()
|
||||
actions.upsertSettings({
|
||||
syncEnabled: form.syncEnabled,
|
||||
syncIntervalMinutes: Math.max(15, Number(form.syncIntervalMinutes) || 60),
|
||||
})
|
||||
}
|
||||
|
||||
const addCustomField = () => {
|
||||
const label = newFieldLabel.trim()
|
||||
if (!label) return
|
||||
const nextIndex = customFields.reduce((max, f) => {
|
||||
const n = parseInt(f.key?.replace('cf_', '') || '0', 10)
|
||||
return Math.max(max, n)
|
||||
}, -1) + 1
|
||||
const key = `cf_${nextIndex}`
|
||||
actions.upsertSettings({ customFields: [...customFields, { key, label }] })
|
||||
setNewFieldLabel('')
|
||||
}
|
||||
|
||||
const removeCustomField = (key) => {
|
||||
actions.upsertSettings({
|
||||
customFields: customFields.filter((f) => f.key !== key),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader pretitle="Система" title="Настройки" />
|
||||
<div className="row row-cards">
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Настройки валют</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form className="row g-3" onSubmit={onSubmit}>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label">Валюта отображения</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.baseCurrency}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseCurrency: e.target.value }))}
|
||||
>
|
||||
{availableCurrencies.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label">Автоконвертация</label>
|
||||
<label className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={form.autoConvert}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, autoConvert: e.target.checked }))}
|
||||
/>
|
||||
<span className="form-check-label">Показывать суммы в валюте отображения</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Ссылка на курсы валют</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.ratesUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, ratesUrl: e.target.value }))}
|
||||
placeholder="https://www.cbr-xml-daily.ru/latest.js"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 d-flex justify-content-end">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Сохранить настройки
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<div className="text-secondary small">
|
||||
Валюта отображения — в какой валюте показывать суммы на дашбордах. Курсы хостера (если указаны)
|
||||
имеют приоритет над глобальными курсами по ссылке выше.
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-lg-6">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Дополнительные поля VPS</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary small mb-3">
|
||||
Текстовые поля для расширенного режима просмотра списка VPS. Отображаются как колонки в таблице и в форме редактирования.
|
||||
</p>
|
||||
<div className="d-flex gap-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Название поля (например: Контакт, ID заказа)"
|
||||
value={newFieldLabel}
|
||||
onChange={(e) => setNewFieldLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustomField())}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={addCustomField}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{customFields.length > 0 ? (
|
||||
<ul className="list-group list-group-flush">
|
||||
{customFields.map((f) => (
|
||||
<li key={f.key} className="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>{f.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => removeCustomField(f.key)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-secondary small">Нет дополнительных полей</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-lg-6">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Синхронизация с API хостеров</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary small mb-3">
|
||||
Периодическая синхронизация данных (VPS, платежи) из BILLmanager для аккаунтов с настроенным API.
|
||||
</p>
|
||||
<form className="row g-3" onSubmit={onSyncSettingsSubmit}>
|
||||
<div className="col-12">
|
||||
<label className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={form.syncEnabled}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, syncEnabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="form-check-label">Включить периодическую синхронизацию</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label">Интервал (минуты)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="15"
|
||||
className="form-control"
|
||||
value={form.syncIntervalMinutes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, syncIntervalMinutes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 d-flex justify-content-end">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-lg-6">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Статус источника курсов</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="mb-2">
|
||||
<span className="text-secondary">Источник:</span> {current.ratesUrl}
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="text-secondary">Дата курсов:</span> {ratesData?.date || '-'}
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="text-secondary">База API:</span> {ratesData?.base || '-'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary btn-sm mb-3"
|
||||
onClick={() => actions.upsertSettings({ ratesUpdatedAt: new Date().toISOString() })}
|
||||
>
|
||||
Обновить курсы сейчас
|
||||
</button>
|
||||
{ratesError ? <div className="alert alert-danger py-2">{ratesError}</div> : null}
|
||||
{!ratesError && ratesData ? (
|
||||
<div className="alert alert-success py-2 mb-0">
|
||||
Курсы загружены. Текущая конвертация работает автоматически.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { convertCurrency, faviconUrlFromWebsite, normalizeWebsiteUrl } from '../lib/utils'
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
IconMapPin,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconServer,
|
||||
} from '@tabler/icons-react'
|
||||
import { syncAccount } from '../lib/api'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
|
||||
const SORT_COLUMNS = ['name', 'vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization', 'channel', 'country', 'location', 'price']
|
||||
|
||||
function SortHeader({ column, children, onSort, sortBy, sortDir }) {
|
||||
return (
|
||||
<th
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSort(column)}
|
||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onSort(column)}
|
||||
style={SORT_COLUMNS.includes(column) ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||
>
|
||||
{children}
|
||||
{sortBy === column && (sortDir === 'asc' ? <IconArrowUp size={14} className="ms-1" /> : <IconArrowDown size={14} className="ms-1" />)}
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
function parsePrice(priceStr) {
|
||||
if (!priceStr || typeof priceStr !== 'string') return { amount: 0, currency: 'RUB' }
|
||||
const match = priceStr.match(/([\d\s.,]+)\s*(RUB|USD|EUR|€|₽|\$)/i) || priceStr.match(/([\d\s.,]+)\s+([A-Z]{3})\b/i)
|
||||
if (!match) return { amount: 0, currency: 'RUB' }
|
||||
const amount = parseFloat(String(match[1]).replace(/\s/g, '').replace(',', '.')) || 0
|
||||
let currency = 'RUB'
|
||||
if (match[2]) {
|
||||
if (match[2] === '€') currency = 'EUR'
|
||||
else if (match[2] === '₽' || match[2].toUpperCase() === 'RUB') currency = 'RUB'
|
||||
else if (match[2] === '$' || match[2].toUpperCase() === 'USD') currency = 'USD'
|
||||
else currency = match[2].toUpperCase()
|
||||
}
|
||||
return { amount, currency }
|
||||
}
|
||||
|
||||
export function TariffsPage({ db, actions, settings, ratesData }) {
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
providerId: '',
|
||||
providerAccountId: '',
|
||||
country: '',
|
||||
orderAvailable: 'all',
|
||||
})
|
||||
const [syncLoading, setSyncLoading] = useState(false)
|
||||
const [syncMessage, setSyncMessage] = useState(null)
|
||||
const [sortBy, setSortBy] = useState('name')
|
||||
const [sortDir, setSortDir] = useState('asc')
|
||||
|
||||
const baseCurrency = (settings?.[0]?.baseCurrency || 'RUB').toUpperCase()
|
||||
|
||||
const billmanagerAccounts = useMemo(
|
||||
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
|
||||
[db.providerAccounts],
|
||||
)
|
||||
|
||||
const filteredAndSortedTariffs = useMemo(() => {
|
||||
const filtered = db.activeTariffs.filter((item) => {
|
||||
const search = filters.search.toLowerCase()
|
||||
const bySearch =
|
||||
!search ||
|
||||
item.name?.toLowerCase().includes(search) ||
|
||||
item.desc?.toLowerCase().includes(search) ||
|
||||
item.location?.toLowerCase().includes(search) ||
|
||||
item.country?.toLowerCase().includes(search) ||
|
||||
item.datacenterName?.toLowerCase().includes(search) ||
|
||||
item.cpuModel?.toLowerCase().includes(search) ||
|
||||
String(item.vcpu || '').includes(search) ||
|
||||
String(item.ramGb || '').includes(search) ||
|
||||
String(item.diskGb || '').includes(search) ||
|
||||
item.diskType?.toLowerCase().includes(search) ||
|
||||
item.virtualization?.toLowerCase().includes(search)
|
||||
const byProvider = !filters.providerId || item.providerId === filters.providerId
|
||||
const byAccount =
|
||||
!filters.providerAccountId || item.providerAccountId === filters.providerAccountId
|
||||
const byCountry =
|
||||
!filters.country || item.country === filters.country
|
||||
const byOrderAvailable =
|
||||
filters.orderAvailable === 'all' ||
|
||||
(filters.orderAvailable === 'yes' && item.orderAvailable) ||
|
||||
(filters.orderAvailable === 'no' && !item.orderAvailable)
|
||||
return bySearch && byProvider && byAccount && byCountry && byOrderAvailable
|
||||
})
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
let cmp = 0
|
||||
if (sortBy === 'price') {
|
||||
const pa = parsePrice(a.price)
|
||||
const pb = parsePrice(b.price)
|
||||
const va = convertCurrency(pa.amount, pa.currency, baseCurrency, ratesData)
|
||||
const vb = convertCurrency(pb.amount, pb.currency, baseCurrency, ratesData)
|
||||
cmp = va - vb
|
||||
} else if (['vcpu', 'ramGb', 'diskGb'].includes(sortBy)) {
|
||||
const va = Number(a[sortBy]) || 0
|
||||
const vb = Number(b[sortBy]) || 0
|
||||
cmp = va - vb
|
||||
} else {
|
||||
const va = String(a[sortBy] ?? '').toLowerCase()
|
||||
const vb = String(b[sortBy] ?? '').toLowerCase()
|
||||
cmp = va.localeCompare(vb)
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
return sorted
|
||||
}, [db.activeTariffs, filters, sortBy, sortDir, baseCurrency, ratesData])
|
||||
|
||||
const handleSort = (col) => {
|
||||
if (!SORT_COLUMNS.includes(col)) return
|
||||
setSortBy(col)
|
||||
setSortDir((prev) => (sortBy === col && prev === 'asc' ? 'desc' : 'asc'))
|
||||
}
|
||||
|
||||
const accountFilterOptions = useMemo(
|
||||
() =>
|
||||
db.providerAccounts.filter(
|
||||
(account) => !filters.providerId || account.providerId === filters.providerId,
|
||||
),
|
||||
[db.providerAccounts, filters.providerId],
|
||||
)
|
||||
|
||||
const availableCountries = useMemo(() => {
|
||||
const filtered = db.activeTariffs.filter((item) => {
|
||||
const byProvider = !filters.providerId || item.providerId === filters.providerId
|
||||
const byAccount =
|
||||
!filters.providerAccountId || item.providerAccountId === filters.providerAccountId
|
||||
return byProvider && byAccount && item.country
|
||||
})
|
||||
const countries = [...new Set(filtered.map((t) => t.country).filter(Boolean))].sort()
|
||||
return countries
|
||||
}, [db.activeTariffs, filters.providerId, filters.providerAccountId])
|
||||
|
||||
const onSync = async () => {
|
||||
if (billmanagerAccounts.length === 0) return
|
||||
setSyncLoading(true)
|
||||
setSyncMessage(null)
|
||||
let totalTariffs = 0
|
||||
let lastError = null
|
||||
for (const account of billmanagerAccounts) {
|
||||
try {
|
||||
const result = await syncAccount(account.id)
|
||||
if (result.ok) {
|
||||
totalTariffs += result.synced?.tariffsCount ?? 0
|
||||
} else {
|
||||
lastError = result.error
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err.message
|
||||
}
|
||||
}
|
||||
if (lastError && totalTariffs === 0) {
|
||||
setSyncMessage(lastError)
|
||||
} else {
|
||||
setSyncMessage(
|
||||
totalTariffs > 0
|
||||
? `Синхронизировано: ${totalTariffs} тарифов${lastError ? `. Ошибки: ${lastError}` : ''}`
|
||||
: lastError
|
||||
? `Ошибка: ${lastError}`
|
||||
: 'Нет новых тарифов для синхронизации',
|
||||
)
|
||||
}
|
||||
if (totalTariffs > 0) await actions.refreshData()
|
||||
setSyncLoading(false)
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
providerId: '',
|
||||
providerAccountId: '',
|
||||
country: '',
|
||||
orderAvailable: 'all',
|
||||
})
|
||||
}
|
||||
|
||||
const syncOptionsByAccount = useMemo(() => {
|
||||
const map = {}
|
||||
for (const opt of db.tariffSyncOptions || []) {
|
||||
map[opt.providerAccountId] = opt
|
||||
}
|
||||
return map
|
||||
}, [db.tariffSyncOptions])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader pretitle="Каталог хостера" title="Активные тарифы" />
|
||||
<div className="row row-cards">
|
||||
<div className="col-12 card-stack">
|
||||
{Object.keys(syncOptionsByAccount).length > 0 ? (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconMapPin size={18} className="me-1" />
|
||||
Доступные датацентры и страны
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row g-3">
|
||||
{Object.entries(syncOptionsByAccount).map(([accountId, opt]) => {
|
||||
const account = db.providerAccounts.find((a) => a.id === accountId)
|
||||
const provider = db.providers.find((p) => p.id === account?.providerId)
|
||||
const dcs = opt.datacenters || []
|
||||
const periods = opt.periods || []
|
||||
if (dcs.length === 0) return null
|
||||
return (
|
||||
<div key={accountId} className="col-12 col-md-6 col-lg-4">
|
||||
<div className="border rounded p-3">
|
||||
<div className="fw-medium mb-2">
|
||||
{provider?.name} / {account?.name}
|
||||
</div>
|
||||
<div className="d-flex flex-wrap gap-1">
|
||||
{dcs.map((dc) => (
|
||||
<span
|
||||
key={dc.k}
|
||||
className="badge bg-blue-lt"
|
||||
title={`ID: ${dc.k}`}
|
||||
>
|
||||
{dc.v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{periods.length > 0 ? (
|
||||
<div className="mt-2 text-secondary small">
|
||||
Периоды: {periods.map((p) => p.v).join(', ')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="row g-2">
|
||||
<div className="col-xl-3 col-lg-4 col-md-6">
|
||||
<div className="input-icon">
|
||||
<span className="input-icon-addon">
|
||||
<IconSearch size={16} />
|
||||
</span>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Поиск по названию, ресурсам..."
|
||||
value={filters.search}
|
||||
onChange={(e) =>
|
||||
setFilters((prev) => ({ ...prev, search: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-xl-2 col-lg-4 col-md-6">
|
||||
<select
|
||||
className="form-select"
|
||||
value={filters.providerId}
|
||||
onChange={(e) =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
providerId: e.target.value,
|
||||
providerAccountId: '',
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">Все хостеры</option>
|
||||
{db.providers.map((provider) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-xl-2 col-lg-4 col-md-6">
|
||||
<select
|
||||
className="form-select"
|
||||
value={filters.providerAccountId}
|
||||
onChange={(e) =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
providerAccountId: e.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">Все аккаунты</option>
|
||||
{accountFilterOptions.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-xl-2 col-lg-4 col-md-6">
|
||||
<select
|
||||
className="form-select"
|
||||
value={filters.country}
|
||||
onChange={(e) =>
|
||||
setFilters((prev) => ({ ...prev, country: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="">Все страны</option>
|
||||
{availableCountries.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-xl-2 col-lg-4 col-md-6">
|
||||
<select
|
||||
className="form-select"
|
||||
value={filters.orderAvailable}
|
||||
onChange={(e) =>
|
||||
setFilters((prev) => ({ ...prev, orderAvailable: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="all">Доступность: все</option>
|
||||
<option value="yes">Можно заказать</option>
|
||||
<option value="no">Нельзя заказать</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-xl-2 col-lg-4 col-md-6">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Список тарифов</h3>
|
||||
{syncMessage ? (
|
||||
<div
|
||||
className={`alert alert-${
|
||||
syncMessage.startsWith('Синхронизировано')
|
||||
? 'success'
|
||||
: syncMessage.startsWith('Ошибка')
|
||||
? 'warning'
|
||||
: 'secondary'
|
||||
} py-2 mb-0 me-2`}
|
||||
>
|
||||
{syncMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="card-actions">
|
||||
{billmanagerAccounts.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={onSync}
|
||||
disabled={syncLoading}
|
||||
title="Синхронизировать тарифы из BILLmanager"
|
||||
>
|
||||
{syncLoading ? (
|
||||
<span className="spinner-border spinner-border-sm me-1" role="status" />
|
||||
) : (
|
||||
<IconRefresh size={16} className="me-1" />
|
||||
)}
|
||||
Синхронизировать
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-secondary small">
|
||||
Добавьте аккаунт BILLmanager для синхронизации тарифов
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortHeader column="name" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Тариф</SortHeader>
|
||||
<th>Хостер / Аккаунт</th>
|
||||
<SortHeader column="vcpu" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>vCPU</SortHeader>
|
||||
<SortHeader column="ramGb" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>RAM</SortHeader>
|
||||
<SortHeader column="diskGb" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Диск</SortHeader>
|
||||
<SortHeader column="diskType" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Тип диска</SortHeader>
|
||||
<SortHeader column="virtualization" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Виртуализация</SortHeader>
|
||||
<SortHeader column="channel" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Канал</SortHeader>
|
||||
<SortHeader column="country" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Страна</SortHeader>
|
||||
<SortHeader column="location" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Локация</SortHeader>
|
||||
<th>CPU</th>
|
||||
<SortHeader column="price" onSort={handleSort} sortBy={sortBy} sortDir={sortDir}>Цена</SortHeader>
|
||||
<th>Заказ</th>
|
||||
<th>Панель</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAndSortedTariffs.map((item) => {
|
||||
const provider = db.providers.find((p) => p.id === item.providerId)
|
||||
const account = db.providerAccounts.find(
|
||||
(a) => a.id === item.providerAccountId,
|
||||
)
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<span className="avatar avatar-sm bg-blue-lt">
|
||||
<IconServer size={16} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="fw-medium">{item.name || '—'}</div>
|
||||
{item.desc ? (
|
||||
<div
|
||||
className="text-secondary small text-truncate"
|
||||
style={{ maxWidth: 280 }}
|
||||
title={item.desc}
|
||||
>
|
||||
{item.desc}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{faviconUrlFromWebsite(provider?.website) ? (
|
||||
<img
|
||||
src={faviconUrlFromWebsite(provider?.website)}
|
||||
alt=""
|
||||
width="16"
|
||||
height="16"
|
||||
className="rounded"
|
||||
/>
|
||||
) : null}
|
||||
<span>{provider?.name || '—'}</span>
|
||||
</div>
|
||||
<div className="text-secondary small">{account?.name || '—'}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge bg-azure-lt">{item.vcpu || '—'}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge bg-lime-lt">
|
||||
{item.ramGb ? `${item.ramGb} GB` : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge bg-orange-lt">
|
||||
{item.diskGb ? `${item.diskGb} GB` : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{item.diskType || '—'}</td>
|
||||
<td>{item.virtualization || '—'}</td>
|
||||
<td>{item.channel || '—'}</td>
|
||||
<td>
|
||||
{item.country ? (
|
||||
<span className="badge bg-cyan-lt">{item.country}</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td>{item.location || item.datacenterName || '—'}</td>
|
||||
<td>
|
||||
{item.cpuModel ? (
|
||||
<span className="text-secondary small" title={item.cpuModel}>
|
||||
{item.cpuModel.length > 20 ? `${item.cpuModel.slice(0, 20)}…` : item.cpuModel}
|
||||
</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
item.orderAvailable ? 'text-success' : 'text-secondary'
|
||||
}
|
||||
>
|
||||
{item.price || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${
|
||||
item.orderAvailable ? 'bg-green-lt text-green' : 'bg-secondary-lt'
|
||||
}`}
|
||||
>
|
||||
{item.orderAvailable ? 'Да' : 'Нет'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{normalizeWebsiteUrl(account?.panelUrl || provider?.website) ? (
|
||||
<a
|
||||
href={normalizeWebsiteUrl(account?.panelUrl || provider?.website)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
>
|
||||
Открыть
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-secondary">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{filteredAndSortedTariffs.length === 0 ? (
|
||||
<EmptyState
|
||||
message={
|
||||
db.activeTariffs.length === 0
|
||||
? 'Нет данных. Синхронизируйте тарифы из BILLmanager.'
|
||||
: 'По фильтрам ничего не найдено'
|
||||
}
|
||||
colSpan={14}
|
||||
/>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user