init commit
This commit is contained in:
+42
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AppLayout } from './components/AppLayout'
|
||||
import { DashboardPage } from './pages/DashboardPage'
|
||||
import { VpsPage } from './pages/VpsPage'
|
||||
import { ProvidersPage } from './pages/ProvidersPage'
|
||||
import { AccountsPage } from './pages/AccountsPage'
|
||||
import { PaymentsPage } from './pages/PaymentsPage'
|
||||
import { BalancePage } from './pages/BalancePage'
|
||||
import { ReportsPage } from './pages/ReportsPage'
|
||||
import { SettingsPage } from './pages/SettingsPage'
|
||||
import { TariffsPage } from './pages/TariffsPage'
|
||||
import {
|
||||
createRecord,
|
||||
deleteRecord,
|
||||
initDataStore,
|
||||
loadDataSet,
|
||||
updateRecord,
|
||||
} from './lib/api'
|
||||
|
||||
function App() {
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [db, setDb] = useState({
|
||||
vps: [],
|
||||
providers: [],
|
||||
providerAccounts: [],
|
||||
payments: [],
|
||||
balanceLedger: [],
|
||||
settings: [],
|
||||
activeTariffs: [],
|
||||
tariffSyncOptions: [],
|
||||
})
|
||||
const [ratesData, setRatesData] = useState(null)
|
||||
const [ratesError, setRatesError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
initDataStore()
|
||||
.then(() => loadDataSet())
|
||||
.then((data) => {
|
||||
setDb(data)
|
||||
setIsReady(true)
|
||||
setLoadError('')
|
||||
})
|
||||
.catch((err) => {
|
||||
setLoadError(err.message || 'Ошибка загрузки данных')
|
||||
setIsReady(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const settings = db.settings?.[0]
|
||||
if (!settings?.ratesUrl) {
|
||||
return
|
||||
}
|
||||
fetch(settings.ratesUrl)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось получить курсы валют')
|
||||
}
|
||||
return response.json()
|
||||
})
|
||||
.then((payload) => {
|
||||
setRatesData(payload)
|
||||
setRatesError('')
|
||||
})
|
||||
.catch((error) => {
|
||||
setRatesError(error.message || 'Ошибка загрузки курсов')
|
||||
})
|
||||
}, [db.settings])
|
||||
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
create: async (collectionName, record) => {
|
||||
const nextCollection = await createRecord(collectionName, record)
|
||||
setDb((prev) => ({ ...prev, [collectionName]: nextCollection }))
|
||||
},
|
||||
update: async (collectionName, id, patch) => {
|
||||
const nextCollection = await updateRecord(collectionName, id, patch)
|
||||
setDb((prev) => ({ ...prev, [collectionName]: nextCollection }))
|
||||
},
|
||||
remove: async (collectionName, id) => {
|
||||
const nextCollection = await deleteRecord(collectionName, id)
|
||||
setDb((prev) => ({ ...prev, [collectionName]: nextCollection }))
|
||||
},
|
||||
upsertSettings: async (patch) => {
|
||||
const current = db.settings?.[0]
|
||||
if (current?.id) {
|
||||
const nextCollection = await updateRecord('settings', current.id, patch)
|
||||
setDb((prev) => ({ ...prev, settings: nextCollection }))
|
||||
return
|
||||
}
|
||||
const nextCollection = await createRecord('settings', {
|
||||
id: 'settings-main',
|
||||
baseCurrency: 'RUB',
|
||||
ratesUrl: 'https://www.cbr-xml-daily.ru/latest.js',
|
||||
autoConvert: true,
|
||||
...patch,
|
||||
})
|
||||
setDb((prev) => ({ ...prev, settings: nextCollection }))
|
||||
},
|
||||
refreshData: async () => {
|
||||
const data = await loadDataSet()
|
||||
setDb(data)
|
||||
},
|
||||
}),
|
||||
[db.settings],
|
||||
)
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className="page page-center">
|
||||
<div className="container container-tight py-4 text-center">
|
||||
<div className="spinner-border text-blue" role="status" />
|
||||
<div className="text-secondary mt-2">Загрузка данных...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="page page-center">
|
||||
<div className="container container-tight py-4 text-center">
|
||||
<div className="text-danger mb-2">{loadError}</div>
|
||||
<div className="text-secondary">Убедитесь, что сервер запущен (npm run server)</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={<DashboardPage db={db} settings={db.settings} ratesData={ratesData} />}
|
||||
/>
|
||||
<Route
|
||||
path="/vps"
|
||||
element={<VpsPage db={db} actions={actions} settings={db.settings} ratesData={ratesData} />}
|
||||
/>
|
||||
<Route
|
||||
path="/tariffs"
|
||||
element={<TariffsPage db={db} actions={actions} settings={db.settings} ratesData={ratesData} />}
|
||||
/>
|
||||
<Route
|
||||
path="/providers"
|
||||
element={<ProvidersPage db={db} actions={actions} />}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts"
|
||||
element={<AccountsPage db={db} actions={actions} settings={db.settings} ratesData={ratesData} />}
|
||||
/>
|
||||
<Route
|
||||
path="/payments"
|
||||
element={<PaymentsPage db={db} actions={actions} settings={db.settings} ratesData={ratesData} />}
|
||||
/>
|
||||
<Route
|
||||
path="/balance"
|
||||
element={<BalancePage db={db} actions={actions} settings={db.settings} ratesData={ratesData} />}
|
||||
/>
|
||||
<Route
|
||||
path="/reports"
|
||||
element={<ReportsPage db={db} settings={db.settings} ratesData={ratesData} />}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<SettingsPage
|
||||
db={db}
|
||||
actions={actions}
|
||||
ratesData={ratesData}
|
||||
ratesError={ratesError}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,84 @@
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
IconBuildingSkyscraper,
|
||||
IconChartHistogram,
|
||||
IconCoin,
|
||||
IconCreditCardPay,
|
||||
IconLayoutDashboard,
|
||||
IconSettings,
|
||||
IconServer,
|
||||
IconServer2,
|
||||
IconWallet,
|
||||
} from '@tabler/icons-react'
|
||||
|
||||
const menuItems = [
|
||||
{ to: '/dashboard', label: 'Дашборд', icon: IconLayoutDashboard },
|
||||
{ to: '/vps', label: 'VPS', icon: IconServer },
|
||||
{ to: '/tariffs', label: 'Активные тарифы', icon: IconServer2 },
|
||||
{ to: '/providers', label: 'Хостеры', icon: IconBuildingSkyscraper },
|
||||
{ to: '/accounts', label: 'Аккаунты хостеров', icon: IconWallet },
|
||||
{ to: '/payments', label: 'Платежи', icon: IconCreditCardPay },
|
||||
{ to: '/balance', label: 'Баланс и списания', icon: IconCoin },
|
||||
{ to: '/reports', label: 'Отчёты', icon: IconChartHistogram },
|
||||
{ to: '/settings', label: 'Настройки', icon: IconSettings },
|
||||
]
|
||||
|
||||
export function AppLayout({ children }) {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<aside className="navbar navbar-vertical navbar-expand-lg app-sidebar" data-bs-theme="dark">
|
||||
<div className="container-fluid">
|
||||
<h1 className="navbar-brand navbar-brand-autodark my-3 text-white">VPS Tracker</h1>
|
||||
<div className="collapse navbar-collapse show">
|
||||
<ul className="navbar-nav pt-lg-3">
|
||||
{menuItems.map((item) => (
|
||||
<li className="nav-item" key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={`nav-link ${
|
||||
location.pathname === item.to ? 'active' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="nav-link-icon d-md-none d-lg-inline-block">
|
||||
<item.icon size={18} stroke={1.75} />
|
||||
</span>
|
||||
<span className="nav-link-title">{item.label}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="page-wrapper">
|
||||
<div className="d-lg-none border-bottom bg-white">
|
||||
<div className="container-fluid py-2">
|
||||
<div className="mobile-nav-scroll">
|
||||
<div className="nav nav-pills nav-sm flex-nowrap">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={`nav-link ${location.pathname === item.to ? 'active' : ''}`}
|
||||
>
|
||||
<Icon size={16} className="me-1" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div className="container-fluid">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { convertWithProviderRate, formatCurrency } from '../lib/utils'
|
||||
|
||||
function sourceMeta(source) {
|
||||
if (source === 'provider') {
|
||||
return { label: 'Курс хостера', className: 'bg-green-lt text-green' }
|
||||
}
|
||||
if (source === 'global') {
|
||||
return { label: 'Глобальный курс', className: 'bg-blue-lt text-blue' }
|
||||
}
|
||||
return { label: 'Без конвертации', className: 'bg-secondary-lt text-secondary' }
|
||||
}
|
||||
|
||||
export function ConvertedAmount({ amount, currency, provider, settings, ratesData }) {
|
||||
const result = convertWithProviderRate(amount, currency, provider, settings, ratesData)
|
||||
const meta = sourceMeta(result.source)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<span>{formatCurrency(result.value, result.currency)}</span>
|
||||
<span className={`badge ${meta.className}`}>{meta.label}</span>
|
||||
</div>
|
||||
<div className="text-secondary small">{formatCurrency(amount, currency)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Tabler-style empty state for tables.
|
||||
* @see https://docs.tabler.io/docs/components/empty-states.html
|
||||
*/
|
||||
export function EmptyState({ message = 'Нет данных', colSpan = 10 }) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="text-secondary text-center py-4">
|
||||
{message}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from 'chart.js'
|
||||
|
||||
ChartJS.register(BarController, BarElement, CategoryScale, LinearScale, Title, Tooltip)
|
||||
|
||||
export function ExpenseChart({ data, baseCurrency, formatCurrency }) {
|
||||
const canvasRef = useRef(null)
|
||||
const chartRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const safeData = Array.isArray(data) ? data : []
|
||||
if (safeData.length === 0) return
|
||||
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
try {
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy()
|
||||
chartRef.current = null
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const chart = new ChartJS(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: safeData.map((d) => d.monthLabel || ''),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Расход',
|
||||
data: safeData.map((d) => Number(d.amount) || 0),
|
||||
backgroundColor: 'rgba(47, 179, 68, 0.6)',
|
||||
borderColor: 'rgb(47, 179, 68)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) =>
|
||||
typeof formatCurrency === 'function'
|
||||
? formatCurrency(ctx.raw, baseCurrency)
|
||||
: String(ctx.raw),
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
suggestedMax: (ctx) => {
|
||||
const max = ctx.chart?.data?.datasets?.[0]?.data
|
||||
? Math.max(...ctx.chart.data.datasets[0].data, 0)
|
||||
: 0
|
||||
return max > 0 ? undefined : 1
|
||||
},
|
||||
ticks: {
|
||||
callback: (value) =>
|
||||
typeof formatCurrency === 'function'
|
||||
? formatCurrency(value, baseCurrency)
|
||||
: String(value),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
chartRef.current = chart
|
||||
} catch (err) {
|
||||
console.warn('ExpenseChart error:', err)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy()
|
||||
chartRef.current = null
|
||||
}
|
||||
}
|
||||
}, [data, baseCurrency, formatCurrency])
|
||||
|
||||
const safeData = Array.isArray(data) ? data : []
|
||||
|
||||
return (
|
||||
<div style={{ height: 220, minHeight: 220, position: 'relative' }}>
|
||||
{safeData.length === 0 ? (
|
||||
<div className="text-secondary text-center py-5">Нет данных за период</div>
|
||||
) : (
|
||||
<canvas ref={canvasRef} style={{ display: 'block', maxHeight: 220 }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function PageHeader({ pretitle, title }) {
|
||||
return (
|
||||
<div className="page-header d-print-none mb-3">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<div className="page-pretitle">{pretitle}</div>
|
||||
<h2 className="page-title">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
ArcElement,
|
||||
DoughnutController,
|
||||
Legend,
|
||||
Tooltip,
|
||||
} from 'chart.js'
|
||||
|
||||
ChartJS.register(ArcElement, DoughnutController, Legend, Tooltip)
|
||||
|
||||
const COLORS = [
|
||||
'rgba(32, 107, 196, 0.8)',
|
||||
'rgba(47, 179, 68, 0.8)',
|
||||
'rgba(245, 159, 0, 0.8)',
|
||||
'rgba(155, 93, 229, 0.8)',
|
||||
'rgba(214, 51, 132, 0.8)',
|
||||
'rgba(13, 202, 240, 0.8)',
|
||||
]
|
||||
|
||||
export function ProviderPieChart({ data, baseCurrency, formatCurrency }) {
|
||||
const canvasRef = useRef(null)
|
||||
const chartRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const safeData = Array.isArray(data) ? data : []
|
||||
if (safeData.length === 0) return
|
||||
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
try {
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy()
|
||||
chartRef.current = null
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
chartRef.current = new ChartJS(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: safeData.map((d) => d.providerName || '-'),
|
||||
datasets: [
|
||||
{
|
||||
data: safeData.map((d) => Number(d.amount) || 0),
|
||||
backgroundColor: safeData.map((_, i) => COLORS[i % COLORS.length]),
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right' },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
const total = ctx.dataset.data.reduce((a, b) => a + b, 0)
|
||||
const pct = total > 0 ? ((ctx.raw / total) * 100).toFixed(1) : 0
|
||||
const formatted =
|
||||
typeof formatCurrency === 'function'
|
||||
? formatCurrency(ctx.raw, baseCurrency)
|
||||
: String(ctx.raw)
|
||||
return `${ctx.label}: ${formatted} (${pct}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('ProviderPieChart error:', err)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy()
|
||||
chartRef.current = null
|
||||
}
|
||||
}
|
||||
}, [data, baseCurrency, formatCurrency])
|
||||
|
||||
const safeData = Array.isArray(data) ? data : []
|
||||
|
||||
return (
|
||||
<div style={{ height: 220, minHeight: 220, position: 'relative' }}>
|
||||
{safeData.length === 0 ? (
|
||||
<div className="text-secondary text-center py-5">Нет расходов по хостеру</div>
|
||||
) : (
|
||||
<canvas ref={canvasRef} style={{ display: 'block', maxHeight: 220 }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export function UiModal({ open, title, onClose, size = 'modal-lg', footer, scrollable = false, children }) {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined
|
||||
}
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
const modalNode = (
|
||||
<div
|
||||
className="ui-modal-root"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 2000,
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className="modal-backdrop fade show"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
}}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
className="modal modal-blur fade show d-block"
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<div className={`modal-dialog ${size} modal-dialog-centered`} role="document">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{title}</h5>
|
||||
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
|
||||
</div>
|
||||
<div
|
||||
className="modal-body"
|
||||
style={scrollable ? { maxHeight: '60vh', overflowY: 'auto' } : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? <div className="modal-footer">{footer}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return createPortal(modalNode, document.body)
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: var(--tblr-bg-surface);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #18233a 0%, #111a2d 100%);
|
||||
border-right: 1px solid #23314b;
|
||||
}
|
||||
|
||||
.app-sidebar .nav-link {
|
||||
border-radius: 8px;
|
||||
margin-bottom: 4px;
|
||||
color: #aebbd2;
|
||||
}
|
||||
|
||||
.app-sidebar .nav-link:hover,
|
||||
.app-sidebar .nav-link.active {
|
||||
background: rgba(61, 119, 255, 0.2);
|
||||
color: #e8f0ff;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
background: #f5f7fb;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.page-wrapper {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-nav-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-nav-scroll .nav {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table td,
|
||||
.table th {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
|
||||
.metric-card.metric-blue {
|
||||
border-left-color: #206bc4;
|
||||
}
|
||||
|
||||
.metric-card.metric-green {
|
||||
border-left-color: #2fb344;
|
||||
}
|
||||
|
||||
.metric-card.metric-yellow {
|
||||
border-left-color: #f59f00;
|
||||
}
|
||||
|
||||
.metric-card.metric-purple {
|
||||
border-left-color: #9b5de5;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.table-actions .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vps-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* VPS modal form sections */
|
||||
.vps-modal-form .vps-form-section {
|
||||
padding-bottom: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--tblr-border-color, #e6e7e9);
|
||||
}
|
||||
|
||||
.vps-modal-form .vps-form-section:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.vps-form-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--tblr-secondary, #656d77);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Компактный отступ между карточками в одной колонке */
|
||||
.card-stack .card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.card-stack .card + .card {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.app-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-body .container-fluid {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.card .table-responsive {
|
||||
margin-left: -0.75rem;
|
||||
margin-right: -0.75rem;
|
||||
width: calc(100% + 1.5rem);
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-width: 8.5rem;
|
||||
}
|
||||
|
||||
.table-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vps-header-actions {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
padding-bottom: 0.125rem;
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { uid } from './utils'
|
||||
|
||||
const COLLECTIONS = {
|
||||
vps: 'vps',
|
||||
providers: 'providers',
|
||||
providerAccounts: 'providerAccounts',
|
||||
payments: 'payments',
|
||||
balanceLedger: 'balanceLedger',
|
||||
settings: 'settings',
|
||||
}
|
||||
|
||||
const API_PATHS = {
|
||||
[COLLECTIONS.vps]: '/api/vps',
|
||||
[COLLECTIONS.providers]: '/api/providers',
|
||||
[COLLECTIONS.providerAccounts]: '/api/provider-accounts',
|
||||
[COLLECTIONS.payments]: '/api/payments',
|
||||
[COLLECTIONS.balanceLedger]: '/api/balance-ledger',
|
||||
[COLLECTIONS.settings]: '/api/settings',
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'vps-tracker:'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
function getLocalStorageData() {
|
||||
const data = {}
|
||||
for (const name of Object.values(COLLECTIONS)) {
|
||||
const key = `${STORAGE_KEY_PREFIX}${name}`
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw) {
|
||||
try {
|
||||
data[name] = JSON.parse(raw)
|
||||
} catch {
|
||||
data[name] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
function clearLocalStorage() {
|
||||
for (const name of Object.values(COLLECTIONS)) {
|
||||
localStorage.removeItem(`${STORAGE_KEY_PREFIX}${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApi(path, options = {}) {
|
||||
const url = `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = new Error(res.statusText || 'API error')
|
||||
err.status = res.status
|
||||
err.response = res
|
||||
throw err
|
||||
}
|
||||
if (res.status === 204) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function initDataStore() {
|
||||
const localData = getLocalStorageData()
|
||||
const hasLocalData = Object.keys(localData).some((k) => {
|
||||
const arr = localData[k]
|
||||
return Array.isArray(arr) ? arr.length > 0 : arr && typeof arr === 'object'
|
||||
})
|
||||
|
||||
if (hasLocalData) {
|
||||
try {
|
||||
await fetchApi('/api/migrate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(localData),
|
||||
})
|
||||
clearLocalStorage()
|
||||
} catch (err) {
|
||||
console.warn('Migration from localStorage failed:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDataSet() {
|
||||
const data = await fetchApi('/api/data')
|
||||
return {
|
||||
[COLLECTIONS.vps]: data.vps ?? [],
|
||||
[COLLECTIONS.providers]: data.providers ?? [],
|
||||
[COLLECTIONS.providerAccounts]: data.providerAccounts ?? [],
|
||||
[COLLECTIONS.payments]: data.payments ?? [],
|
||||
[COLLECTIONS.balanceLedger]: data.balanceLedger ?? [],
|
||||
[COLLECTIONS.settings]: data.settings ?? [],
|
||||
activeTariffs: data.activeTariffs ?? [],
|
||||
tariffSyncOptions: data.tariffSyncOptions ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCollection(collectionName) {
|
||||
const path = API_PATHS[collectionName]
|
||||
if (!path) throw new Error(`Unknown collection: ${collectionName}`)
|
||||
return fetchApi(path)
|
||||
}
|
||||
|
||||
export async function createRecord(collectionName, record) {
|
||||
const path = API_PATHS[collectionName]
|
||||
if (!path) throw new Error(`Unknown collection: ${collectionName}`)
|
||||
const payload = { ...record, id: record.id || uid() }
|
||||
await fetchApi(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
return fetchCollection(collectionName)
|
||||
}
|
||||
|
||||
export async function updateRecord(collectionName, id, patch) {
|
||||
const path = API_PATHS[collectionName]
|
||||
if (!path) throw new Error(`Unknown collection: ${collectionName}`)
|
||||
await fetchApi(`${path}/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
return fetchCollection(collectionName)
|
||||
}
|
||||
|
||||
export async function deleteRecord(collectionName, id) {
|
||||
const path = API_PATHS[collectionName]
|
||||
if (!path) throw new Error(`Unknown collection: ${collectionName}`)
|
||||
await fetchApi(`${path}/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
return fetchCollection(collectionName)
|
||||
}
|
||||
|
||||
export async function syncAccount(accountId) {
|
||||
return fetchApi(`/api/sync/${encodeURIComponent(accountId)}`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function fetchAccountBalance(accountId) {
|
||||
return fetchApi(`/api/sync/${encodeURIComponent(accountId)}/balance`, { method: 'GET' })
|
||||
}
|
||||
|
||||
export async function testApiConnection(apiBaseUrl, apiCredentials) {
|
||||
return fetchApi('/api/sync/test-connection', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ apiBaseUrl, apiCredentials }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Утилиты vps-tracker: ID, URL, форматирование, валюта, лейблы, CSV
|
||||
*/
|
||||
|
||||
/**
|
||||
* Генерирует уникальный ID (UUID или fallback)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function uid() {
|
||||
if (crypto && crypto.randomUUID) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет https:// к URL при отсутствии протокола
|
||||
* @param {string} website
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeWebsiteUrl(website) {
|
||||
if (!website) {
|
||||
return ''
|
||||
}
|
||||
if (website.startsWith('http://') || website.startsWith('https://')) {
|
||||
return website
|
||||
}
|
||||
return `https://${website}`
|
||||
}
|
||||
|
||||
/**
|
||||
* URL иконки сайта через Google Favicon API
|
||||
* @param {string} website
|
||||
* @returns {string}
|
||||
*/
|
||||
export function faviconUrlFromWebsite(website) {
|
||||
const normalized = normalizeWebsiteUrl(website)
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const { hostname } = new URL(normalized)
|
||||
return `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const countryCodeByName = {
|
||||
germany: 'DE',
|
||||
netherlands: 'NL',
|
||||
russia: 'RU',
|
||||
usa: 'US',
|
||||
'united states': 'US',
|
||||
ukraine: 'UA',
|
||||
poland: 'PL',
|
||||
france: 'FR',
|
||||
spain: 'ES',
|
||||
italy: 'IT',
|
||||
estonia: 'EE',
|
||||
finland: 'FI',
|
||||
sweden: 'SE',
|
||||
norway: 'NO',
|
||||
latvia: 'LV',
|
||||
lithuania: 'LT',
|
||||
czechia: 'CZ',
|
||||
czech: 'CZ',
|
||||
singapore: 'SG',
|
||||
japan: 'JP',
|
||||
canada: 'CA',
|
||||
brazil: 'BR',
|
||||
turkey: 'TR',
|
||||
georgia: 'GE',
|
||||
kazakhstan: 'KZ',
|
||||
}
|
||||
|
||||
/**
|
||||
* Эмодзи флага страны по названию (ru-RU)
|
||||
* @param {string} country - название страны
|
||||
* @returns {string} эмодзи флага или 🌐
|
||||
*/
|
||||
export function getCountryFlagEmoji(country) {
|
||||
if (!country) {
|
||||
return '🌐'
|
||||
}
|
||||
const code = countryCodeByName[country.trim().toLowerCase()]
|
||||
if (!code) {
|
||||
return '🌐'
|
||||
}
|
||||
return code
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map((char) => String.fromCodePoint(127397 + char.charCodeAt(0)))
|
||||
.join('')
|
||||
}
|
||||
|
||||
const paymentTypeLabels = {
|
||||
direct_vps_payment: 'Прямой платеж за VPS',
|
||||
provider_balance_topup: 'Пополнение баланса хостера',
|
||||
daily_debit: 'Ежедневное списание',
|
||||
monthly_debit: 'Ежемесячное списание',
|
||||
}
|
||||
|
||||
/**
|
||||
* Человекочитаемая метка типа платежа
|
||||
* @param {string} type - direct_vps_payment | provider_balance_topup | daily_debit | monthly_debit
|
||||
* @returns {string}
|
||||
*/
|
||||
export function paymentTypeLabel(type) {
|
||||
return paymentTypeLabels[type] || type
|
||||
}
|
||||
|
||||
const vpsStatusLabels = {
|
||||
active: 'Активен',
|
||||
paused: 'Приостановлен',
|
||||
archived: 'Архив',
|
||||
}
|
||||
|
||||
/**
|
||||
* Человекочитаемая метка статуса VPS
|
||||
* @param {string} status - active | paused | archived
|
||||
* @returns {string}
|
||||
*/
|
||||
export function vpsStatusLabel(status) {
|
||||
return vpsStatusLabels[status] || status
|
||||
}
|
||||
|
||||
const billingModeLabels = {
|
||||
daily: 'Ежедневно',
|
||||
monthly: 'Ежемесячно',
|
||||
}
|
||||
|
||||
/**
|
||||
* Человекочитаемая метка режима биллинга
|
||||
* @param {string} mode - daily | monthly
|
||||
* @returns {string}
|
||||
*/
|
||||
export function billingModeLabel(mode) {
|
||||
return billingModeLabels[mode] || mode
|
||||
}
|
||||
|
||||
const tariffTypeLabels = {
|
||||
daily: 'Суточный',
|
||||
monthly: 'Месячный',
|
||||
}
|
||||
|
||||
/**
|
||||
* Человекочитаемая метка типа тарифа
|
||||
* @param {string} type - daily | monthly
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tariffTypeLabel(type) {
|
||||
return tariffTypeLabels[type] || type
|
||||
}
|
||||
|
||||
const CURRENCY_SYMBOL_MAP = {
|
||||
'€': 'EUR',
|
||||
'$': 'USD',
|
||||
'₽': 'RUB',
|
||||
'£': 'GBP',
|
||||
'¥': 'JPY',
|
||||
'₴': 'UAH',
|
||||
'₸': 'KZT',
|
||||
}
|
||||
|
||||
function toIsoCurrency(currency) {
|
||||
if (!currency || typeof currency !== 'string') return 'USD'
|
||||
const trimmed = currency.trim()
|
||||
if (CURRENCY_SYMBOL_MAP[trimmed]) return CURRENCY_SYMBOL_MAP[trimmed]
|
||||
if (trimmed.length === 3 && /^[A-Z]{3}$/i.test(trimmed)) return trimmed.toUpperCase()
|
||||
return 'USD'
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует сумму в валюте (ru-RU)
|
||||
* @param {number} amount
|
||||
* @param {string} [currency='USD']
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCurrency(amount, currency = 'USD') {
|
||||
const safeAmount = Number.isFinite(Number(amount)) ? Number(amount) : 0
|
||||
const isoCurrency = toIsoCurrency(currency)
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: isoCurrency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(safeAmount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертирует сумму из одной валюты в другую по ratesData (CBR и т.п.)
|
||||
* @param {number} amount
|
||||
* @param {string} fromCurrency
|
||||
* @param {string} toCurrency
|
||||
* @param {object} ratesData - { base, rates: { USD: 1.2, EUR: 1.1, ... } }
|
||||
* @returns {number}
|
||||
*/
|
||||
export function convertCurrency(amount, fromCurrency, toCurrency, ratesData) {
|
||||
const safeAmount = Number(amount)
|
||||
if (!Number.isFinite(safeAmount)) {
|
||||
return 0
|
||||
}
|
||||
const from = toIsoCurrency(fromCurrency)
|
||||
const to = toIsoCurrency(toCurrency)
|
||||
if (!from || !to || from === to) {
|
||||
return safeAmount
|
||||
}
|
||||
if (!ratesData || !ratesData.rates || !ratesData.base) {
|
||||
return safeAmount
|
||||
}
|
||||
const apiBase = ratesData.base.toUpperCase()
|
||||
const rates = { ...ratesData.rates, [apiBase]: 1 }
|
||||
|
||||
if (!rates[from] || !rates[to]) {
|
||||
return safeAmount
|
||||
}
|
||||
|
||||
const amountInApiBase = safeAmount / rates[from]
|
||||
return amountInApiBase * rates[to]
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует сумму в базовой валюте приложения (settings.baseCurrency)
|
||||
* @param {number} amount
|
||||
* @param {string} currency
|
||||
* @param {object[]} appSettings - settings из API
|
||||
* @param {object} ratesData
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatInBaseCurrency(amount, currency, appSettings, ratesData) {
|
||||
const settings = appSettings?.[0] || {}
|
||||
const baseCurrency = settings.baseCurrency || 'RUB'
|
||||
const autoConvert = settings.autoConvert !== false
|
||||
|
||||
if (!autoConvert) {
|
||||
return formatCurrency(amount, currency)
|
||||
}
|
||||
|
||||
const converted = convertCurrency(amount, currency, baseCurrency, ratesData)
|
||||
return formatCurrency(converted, baseCurrency)
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертирует сумму в валюту отображения (из настроек).
|
||||
* provider.baseCurrency — валюта, в которой хостер принимает платежи.
|
||||
* settings.baseCurrency — валюта отображения на дашбордах.
|
||||
* Курсы хостера (usdRate, eurRate) — курс 1 USD/EUR в валюту отображения.
|
||||
*/
|
||||
export function convertWithProviderRate(amount, currency, provider, appSettings, ratesData) {
|
||||
const safeAmount = Number(amount)
|
||||
const appBase = (appSettings?.[0]?.baseCurrency || 'RUB').toUpperCase()
|
||||
if (!Number.isFinite(safeAmount)) {
|
||||
return { value: 0, currency: appBase, source: 'global' }
|
||||
}
|
||||
|
||||
const fromCurrency = toIsoCurrency(currency || appBase)
|
||||
|
||||
if (fromCurrency === appBase) {
|
||||
return { value: safeAmount, currency: appBase, source: 'native' }
|
||||
}
|
||||
|
||||
const usdRate = Number(provider?.usdRate)
|
||||
const eurRate = Number(provider?.eurRate)
|
||||
if (fromCurrency === 'USD' && Number.isFinite(usdRate) && usdRate > 0) {
|
||||
return { value: safeAmount * usdRate, currency: appBase, source: 'provider' }
|
||||
}
|
||||
if (fromCurrency === 'EUR' && Number.isFinite(eurRate) && eurRate > 0) {
|
||||
return { value: safeAmount * eurRate, currency: appBase, source: 'provider' }
|
||||
}
|
||||
|
||||
return {
|
||||
value: convertCurrency(safeAmount, fromCurrency, appBase, ratesData),
|
||||
currency: appBase,
|
||||
source: 'global',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует сумму с учётом курсов провайдера (usdRate, eurRate)
|
||||
* @param {number} amount
|
||||
* @param {string} currency
|
||||
* @param {object} provider
|
||||
* @param {object[]} appSettings
|
||||
* @param {object} ratesData
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatInProviderCurrency(amount, currency, provider, appSettings, ratesData) {
|
||||
const converted = convertWithProviderRate(amount, currency, provider, appSettings, ratesData)
|
||||
return formatCurrency(converted.value, converted.currency)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ключ месяца для группировки: "2025-03"
|
||||
* @param {string} dateString
|
||||
* @returns {string}
|
||||
*/
|
||||
export function monthKey(dateString) {
|
||||
const date = new Date(dateString)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует массив объектов в CSV-строку
|
||||
* @param {object[]} rows
|
||||
* @returns {string}
|
||||
*/
|
||||
export function toCsv(rows) {
|
||||
if (!rows.length) {
|
||||
return ''
|
||||
}
|
||||
const headers = Object.keys(rows[0])
|
||||
const escapeValue = (value) => {
|
||||
const str = `${value ?? ''}`
|
||||
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
|
||||
return `"${str.replaceAll('"', '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
const lines = [headers.join(',')]
|
||||
for (const row of rows) {
|
||||
lines.push(headers.map((h) => escapeValue(row[h])).join(','))
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициирует скачивание текстового файла (CSV)
|
||||
* @param {string} fileName
|
||||
* @param {string} content
|
||||
*/
|
||||
export function downloadTextFile(fileName, content) {
|
||||
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
import '@tabler/core/dist/css/tabler.min.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -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