init commit

This commit is contained in:
Denozordec
2026-03-17 23:19:28 +07:00
commit 221edcda16
58 changed files with 11938 additions and 0 deletions
+515
View File
@@ -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>
</>
)
}
+291
View File
@@ -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>
</>
)
}
+312
View File
@@ -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>
</>
)
}
+266
View File
@@ -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>
</>
)
}
+232
View File
@@ -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>
</>
)
}
+184
View File
@@ -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>
</>
)
}
+265
View File
@@ -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>
</>
)
}
+530
View File
@@ -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