Files
vps-tracker/src/pages/TariffsPage.jsx
T
2026-03-21 00:52:49 +07:00

565 lines
24 KiB
React

import { useMemo, useState } from 'react'
import { convertCurrency, formatCurrency, 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'
import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps'
import { billmanagerSyncableAccounts } from '../lib/billmanager-ui'
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(
() => billmanagerSyncableAccounts(db.providerAccounts, db.providers),
[db.providerAccounts, db.providers],
)
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, { onlyTariffs: true })
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 {...noBrowserSuggestProps}
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 autoComplete="off"
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 autoComplete="off"
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 autoComplete="off"
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 autoComplete="off"
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>/vCPU</th>
<th>/GB RAM</th>
<th>/GB диск</th>
<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,
)
const { amount: priceAmount, currency: priceCurrency } = parsePrice(item.price)
const priceInBase = convertCurrency(priceAmount, priceCurrency, baseCurrency, ratesData)
const vcpu = Number(item.vcpu) || 0
const ramGb = Number(item.ramGb) || 0
const diskGb = Number(item.diskGb) || 0
const pricePerVcpu = vcpu > 0 ? priceInBase / vcpu : null
const pricePerRam = ramGb > 0 ? priceInBase / ramGb : null
const pricePerDisk = diskGb > 0 ? priceInBase / diskGb : null
return (
<tr key={item.id}>
<td>
<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="text-secondary small">
{pricePerVcpu != null
? formatCurrency(pricePerVcpu, baseCurrency)
: '—'}
</span>
</td>
<td>
<span className="text-secondary small">
{pricePerRam != null
? formatCurrency(pricePerRam, baseCurrency)
: '—'}
</span>
</td>
<td>
<span className="text-secondary small">
{pricePerDisk != null
? formatCurrency(pricePerDisk, baseCurrency)
: '—'}
</span>
</td>
<td>
<span
className={`badge ${
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={17}
/>
) : null}
</tbody>
</table>
</div>
</div>
</div>
</div>
</>
)
}