Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m54s
1544 lines
62 KiB
React
1544 lines
62 KiB
React
import { useState, useEffect, useRef } from 'react';
|
|
import api from './lib/api.js';
|
|
import {
|
|
IconPlus,
|
|
IconEdit,
|
|
IconTrash,
|
|
IconCheck,
|
|
IconDatabase,
|
|
IconRefresh,
|
|
IconAlertTriangle,
|
|
IconCalendar,
|
|
IconCreditCard,
|
|
IconCurrencyDollar,
|
|
IconExternalLink,
|
|
IconSearch,
|
|
IconFilter,
|
|
IconDownload,
|
|
IconUpload,
|
|
IconEye,
|
|
IconEyeOff,
|
|
IconHistory
|
|
} from '@tabler/icons-react';
|
|
|
|
const API_URL = '/api';
|
|
|
|
function BillingManager() {
|
|
const [billingData, setBillingData] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [success, setSuccess] = useState('');
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [sortField, setSortField] = useState('hostName');
|
|
const [sortOrder, setSortOrder] = useState('asc');
|
|
const [filterProvider, setFilterProvider] = useState('');
|
|
const [filterCountry, setFilterCountry] = useState('');
|
|
const [filterStatus, setFilterStatus] = useState('');
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const pageSize = 10;
|
|
|
|
// Модальные окна
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
const [selectedItem, setSelectedItem] = useState(null);
|
|
const [editingItem, setEditingItem] = useState({});
|
|
|
|
// Модалка добавления платежа, привязанного к серверу
|
|
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
|
const [paymentDraft, setPaymentDraft] = useState({
|
|
serverId: '',
|
|
date: '',
|
|
amount: 0,
|
|
currency: 'USD',
|
|
note: ''
|
|
});
|
|
const [editingPayment, setEditingPayment] = useState(null); // { serverId, index }
|
|
const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false);
|
|
const [paymentToDelete, setPaymentToDelete] = useState(null); // { serverId, index, data }
|
|
|
|
// Состояние для курсов валют
|
|
const [exchangeRates, setExchangeRates] = useState({
|
|
USD: 1,
|
|
EUR: 1,
|
|
RUB: 1
|
|
});
|
|
const [ratesLoading, setRatesLoading] = useState(false);
|
|
|
|
// Новый элемент для добавления
|
|
const [newItem, setNewItem] = useState({
|
|
hostName: '',
|
|
purpose: '',
|
|
country: '',
|
|
provider: '',
|
|
loginUrl: '',
|
|
monthlyCost: 0,
|
|
monthlyCostCurrency: 'USD',
|
|
nextPaymentDate: '',
|
|
lastPaymentDate: '',
|
|
lastPaymentAmount: 0,
|
|
lastPaymentCurrency: 'USD',
|
|
status: 'active',
|
|
notes: ''
|
|
});
|
|
|
|
useEffect(() => {
|
|
fetchBillingData();
|
|
fetchExchangeRates();
|
|
}, []);
|
|
|
|
const fetchExchangeRates = async () => {
|
|
setRatesLoading(true);
|
|
try {
|
|
const response = await axios.get('https://api.exchangerate-api.com/v4/latest/USD');
|
|
const rates = response.data.rates;
|
|
|
|
setExchangeRates({
|
|
USD: 1,
|
|
EUR: rates.EUR || 1,
|
|
RUB: rates.RUB || 1
|
|
});
|
|
} catch (error) {
|
|
console.error('Ошибка при загрузке курсов валют:', error);
|
|
setExchangeRates({
|
|
USD: 1,
|
|
EUR: 0.85,
|
|
RUB: 95
|
|
});
|
|
} finally {
|
|
setRatesLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchBillingData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await api.get(`/billing`);
|
|
const normalized = (response.data || []).map((item, idx) => ({
|
|
id: item.id ?? String(idx + 1),
|
|
payments: Array.isArray(item.payments)
|
|
? item.payments
|
|
: (item.lastPaymentDate && item.lastPaymentAmount
|
|
? [{ date: item.lastPaymentDate, amount: item.lastPaymentAmount, currency: item.lastPaymentCurrency || 'USD', note: 'Импортировано' }]
|
|
: []
|
|
),
|
|
...item,
|
|
}));
|
|
setBillingData(normalized);
|
|
setError('');
|
|
} catch (err) {
|
|
console.error('Ошибка при загрузке данных биллинга:', err);
|
|
setError('Ошибка при загрузке данных');
|
|
setBillingData([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveChanges = async () => {
|
|
setLoading(true);
|
|
try {
|
|
await api.post(`/billing`, { domains: billingData });
|
|
setSuccess('Данные успешно сохранены');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
} catch (err) {
|
|
console.error('Ошибка при сохранении:', err);
|
|
setError(`Ошибка при сохранении данных: ${err.response?.data || err.message}`);
|
|
setTimeout(() => setError(''), 5000);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAddItem = () => {
|
|
setNewItem({
|
|
hostName: '',
|
|
purpose: '',
|
|
country: '',
|
|
provider: '',
|
|
loginUrl: '',
|
|
monthlyCost: 0,
|
|
monthlyCostCurrency: 'USD',
|
|
nextPaymentDate: '',
|
|
lastPaymentDate: '',
|
|
lastPaymentAmount: 0,
|
|
lastPaymentCurrency: 'USD',
|
|
status: 'active',
|
|
notes: ''
|
|
});
|
|
setShowAddModal(true);
|
|
};
|
|
|
|
const handleEditItem = (item) => {
|
|
setEditingItem({ ...item });
|
|
setShowEditModal(true);
|
|
};
|
|
|
|
const handleDeleteItem = (item) => {
|
|
setSelectedItem(item);
|
|
setShowDeleteModal(true);
|
|
};
|
|
|
|
const confirmDelete = () => {
|
|
if (selectedItem) {
|
|
setBillingData(prev => prev.filter(item => item.id !== selectedItem.id));
|
|
setShowDeleteModal(false);
|
|
setSelectedItem(null);
|
|
}
|
|
};
|
|
|
|
const handleAddSubmit = () => {
|
|
if (!newItem.hostName || !newItem.country || !newItem.provider) {
|
|
setError('Пожалуйста, заполните обязательные поля: Имя хоста, Страна, Провайдер');
|
|
return;
|
|
}
|
|
|
|
const newId = Math.max(...billingData.map(item => parseInt(item.id) || 0), 0) + 1;
|
|
const itemToAdd = { ...newItem, id: newId.toString() };
|
|
setBillingData(prev => [...prev, itemToAdd]);
|
|
setShowAddModal(false);
|
|
setSuccess('Элемент успешно добавлен');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
};
|
|
|
|
const handleEditSubmit = () => {
|
|
if (!editingItem.hostName || !editingItem.country || !editingItem.provider) {
|
|
setError('Пожалуйста, заполните обязательные поля: Имя хоста, Страна, Провайдер');
|
|
return;
|
|
}
|
|
|
|
setBillingData(prev => prev.map(item =>
|
|
item.id === editingItem.id ? editingItem : item
|
|
));
|
|
setShowEditModal(false);
|
|
setSuccess('Элемент успешно обновлен');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
};
|
|
|
|
const handleSort = (field) => {
|
|
if (sortField === field) {
|
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
|
} else {
|
|
setSortField(field);
|
|
setSortOrder('asc');
|
|
}
|
|
};
|
|
|
|
// Вычисляем общую сумму последних платежей
|
|
const totalLastPayments = billingData.reduce((sum, item) => {
|
|
return sum + (item.lastPaymentAmount || 0);
|
|
}, 0);
|
|
|
|
// Вычисляем общую сумму месячных затрат
|
|
const totalMonthlyCosts = billingData.reduce((sum, item) => {
|
|
return sum + (item.monthlyCost || 0);
|
|
}, 0);
|
|
|
|
// Фильтрация данных
|
|
const filteredData = billingData
|
|
.filter(item => {
|
|
const matchesSearch =
|
|
item.hostName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
item.purpose?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
item.provider?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
item.country?.toLowerCase().includes(searchTerm.toLowerCase());
|
|
|
|
const matchesProvider = !filterProvider || item.provider === filterProvider;
|
|
const matchesCountry = !filterCountry || item.country === filterCountry;
|
|
const matchesStatus = !filterStatus || item.status === filterStatus;
|
|
|
|
return matchesSearch && matchesProvider && matchesCountry && matchesStatus;
|
|
})
|
|
.sort((a, b) => {
|
|
const aValue = a[sortField] || '';
|
|
const bValue = b[sortField] || '';
|
|
|
|
if (sortOrder === 'asc') {
|
|
return aValue > bValue ? 1 : -1;
|
|
} else {
|
|
return aValue < bValue ? 1 : -1;
|
|
}
|
|
});
|
|
|
|
const paginatedData = filteredData.slice(
|
|
(currentPage - 1) * pageSize,
|
|
currentPage * pageSize
|
|
);
|
|
|
|
const totalPages = Math.ceil(filteredData.length / pageSize);
|
|
|
|
// Получаем уникальные значения для фильтров
|
|
const uniqueProviders = [...new Set(billingData.map(item => item.provider).filter(Boolean))];
|
|
const uniqueCountries = [...new Set(billingData.map(item => item.country).filter(Boolean))];
|
|
const uniqueStatuses = [...new Set(billingData.map(item => item.status).filter(Boolean))];
|
|
|
|
function formatDate(dateString) {
|
|
if (!dateString) return 'Не указано';
|
|
try {
|
|
return new Date(dateString).toLocaleDateString('ru-RU');
|
|
} catch {
|
|
return dateString;
|
|
}
|
|
}
|
|
|
|
function formatCurrency(amount, currency = 'USD') {
|
|
if (!amount) return '0.00';
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: currency
|
|
}).format(amount);
|
|
}
|
|
|
|
function convertToRUB(amount, currency) {
|
|
if (!amount) return 0;
|
|
// Курсы получены с базой USD (1 USD = rates[CCY])
|
|
const rubPerUsd = Number(exchangeRates.RUB) || 1;
|
|
if (!currency || currency === 'RUB') return amount; // уже в рублях
|
|
if (currency === 'USD') return amount * rubPerUsd;
|
|
const ccyPerUsd = Number(exchangeRates[currency]);
|
|
if (!ccyPerUsd || ccyPerUsd === 0) {
|
|
// нет курса — возвращаем как есть
|
|
return amount;
|
|
}
|
|
// amount в валюте CCY -> USD -> RUB
|
|
const amountUsd = amount / ccyPerUsd;
|
|
return amountUsd * rubPerUsd;
|
|
}
|
|
|
|
function formatCurrencyInRUB(amount, currency) {
|
|
const rubAmount = convertToRUB(amount, currency);
|
|
return new Intl.NumberFormat('ru-RU', {
|
|
style: 'currency',
|
|
currency: 'RUB'
|
|
}).format(rubAmount);
|
|
}
|
|
|
|
function formatPurpose(purpose) {
|
|
const purposeMap = {
|
|
'relay': 'Relay VPS',
|
|
'bgp': 'BGP сервер',
|
|
'monitoring': 'Мониторинг',
|
|
'dns': 'DNS сервер',
|
|
'proxy': 'Прокси сервер',
|
|
'backup': 'Backup сервер',
|
|
'other': 'Другое'
|
|
};
|
|
return purposeMap[purpose] || purpose || 'Не указано';
|
|
}
|
|
|
|
// Флаги стран (эмодзи) по коду/названию
|
|
function countryCodeToFlag(isoCode) {
|
|
if (!isoCode || isoCode.length !== 2) return '';
|
|
const codePoints = isoCode
|
|
.toUpperCase()
|
|
.split('')
|
|
.map(char => 127397 + char.charCodeAt(0));
|
|
return String.fromCodePoint(...codePoints);
|
|
}
|
|
|
|
// Favicon для провайдера по ссылке входа
|
|
function normalizeToUrl(value) {
|
|
if (!value) return '';
|
|
try {
|
|
const hasProtocol = /^https?:\/\//i.test(value);
|
|
const url = new URL(hasProtocol ? value : `https://${value}`);
|
|
return url.toString();
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function getFaviconUrl(loginUrl) {
|
|
const normalized = normalizeToUrl(loginUrl);
|
|
if (!normalized) return '';
|
|
try {
|
|
const { hostname } = new URL(normalized);
|
|
// DuckDuckGo Icons API: быстрая выдача favicon по домену
|
|
return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// Удаление платежа из истории
|
|
const handleDeletePayment = () => {
|
|
if (!paymentToDelete || !paymentToDelete.serverId || paymentToDelete.index == null) {
|
|
setShowDeletePaymentModal(false);
|
|
return;
|
|
}
|
|
const { serverId, index } = paymentToDelete;
|
|
setBillingData(prev => prev.map(s => {
|
|
if (s.id !== serverId) return s;
|
|
const payments = Array.isArray(s.payments) ? [...s.payments] : [];
|
|
if (index >= 0 && index < payments.length) {
|
|
payments.splice(index, 1);
|
|
}
|
|
const last = payments
|
|
.slice()
|
|
.sort((a,b) => new Date(b.date) - new Date(a.date))[0];
|
|
return {
|
|
...s,
|
|
payments,
|
|
lastPaymentDate: last?.date || '',
|
|
lastPaymentAmount: last?.amount || 0,
|
|
lastPaymentCurrency: last?.currency || s.monthlyCostCurrency || 'USD'
|
|
};
|
|
}));
|
|
setShowDeletePaymentModal(false);
|
|
setPaymentToDelete(null);
|
|
setSuccess('Платеж удалён');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
};
|
|
|
|
function getCountryFlag(countryRaw) {
|
|
if (!countryRaw) return '';
|
|
const value = String(countryRaw).trim();
|
|
if (!value) return '';
|
|
const upper = value.toUpperCase();
|
|
// Если уже ISO-2
|
|
if (/^[A-Z]{2}$/.test(upper)) {
|
|
return countryCodeToFlag(upper);
|
|
}
|
|
// Маппинг распространённых названий -> ISO-2
|
|
const mapNameToCode = {
|
|
// RU
|
|
'RUSSIA': 'RU', 'RUSSIAN FEDERATION': 'RU', 'РОССИЯ': 'RU',
|
|
// US
|
|
'USA': 'US', 'UNITED STATES': 'US', 'США': 'US', 'СОЕДИНЕННЫЕ ШТАТЫ': 'US',
|
|
// DE
|
|
'GERMANY': 'DE', 'DEUTSCHLAND': 'DE', 'ГЕРМАНИЯ': 'DE',
|
|
// NL
|
|
'NETHERLANDS': 'NL', 'NEDERLAND': 'NL', 'НИДЕРЛАНДЫ': 'NL',
|
|
// FR
|
|
'FRANCE': 'FR', 'ФРАНЦИЯ': 'FR',
|
|
// GB
|
|
'UNITED KINGDOM': 'GB', 'UK': 'GB', 'ВЕЛИКОБРИТАНИЯ': 'GB',
|
|
// SG
|
|
'SINGAPORE': 'SG', 'СИНГАПУР': 'SG',
|
|
// PL
|
|
'POLAND': 'PL', 'ПОЛЬША': 'PL',
|
|
// ES
|
|
'SPAIN': 'ES', 'ИСПАНИЯ': 'ES',
|
|
// IT
|
|
'ITALY': 'IT', 'ИТАЛИЯ': 'IT',
|
|
// TR
|
|
'TURKEY': 'TR', 'ТУРЦИЯ': 'TR',
|
|
// KZ
|
|
'KAZAKHSTAN': 'KZ', 'КАЗАХСТАН': 'KZ',
|
|
// LT, LV, EE, CZ, UA, BY, SE, FI, CA
|
|
'LITHUANIA': 'LT', 'ЛИТВА': 'LT',
|
|
'LATVIA': 'LV', 'ЛАТВИЯ': 'LV',
|
|
'ESTONIA': 'EE', 'ЭСТОНИЯ': 'EE',
|
|
'CZECHIA': 'CZ', 'CZECH REPUBLIC': 'CZ', 'ЧЕХИЯ': 'CZ',
|
|
'UKRAINE': 'UA', 'УКРАИНА': 'UA',
|
|
'BELARUS': 'BY', 'БЕЛАРУСЬ': 'BY',
|
|
'SWEDEN': 'SE', 'ШВЕЦИЯ': 'SE',
|
|
'FINLAND': 'FI', 'ФИНЛЯНДИЯ': 'FI',
|
|
'CANADA': 'CA', 'КАНАДА': 'CA',
|
|
};
|
|
const code = mapNameToCode[upper];
|
|
return code ? countryCodeToFlag(code) : '';
|
|
}
|
|
|
|
// Экспорт данных
|
|
const exportData = () => {
|
|
const csvContent = [
|
|
['Host Name', 'Purpose', 'Country', 'Provider', 'Monthly Cost', 'Next Payment', 'Status'],
|
|
...filteredData.map(item => [
|
|
item.hostName,
|
|
item.purpose,
|
|
item.country,
|
|
item.provider,
|
|
item.monthlyCost,
|
|
item.nextPaymentDate,
|
|
item.status
|
|
])
|
|
].map(row => row.join(',')).join('\n');
|
|
|
|
const blob = new Blob([csvContent], { type: 'text/csv' });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `billing-data-${new Date().toISOString().split('T')[0]}.csv`;
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* Заголовок страницы */}
|
|
<div className="page-header d-print-none mb-4">
|
|
<div className="row align-items-center">
|
|
<div className="col">
|
|
<h2 className="page-title">Инфра-биллинг</h2>
|
|
<div className="page-pretitle">Панель управления / Ноды / Инфра-биллинг</div>
|
|
</div>
|
|
<div className="col-auto ms-auto d-print-none">
|
|
<div className="btn-list">
|
|
<button
|
|
className="btn btn-outline-secondary"
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
>
|
|
<IconFilter size={16} />
|
|
Фильтры
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
onClick={exportData}
|
|
disabled={loading}
|
|
>
|
|
<IconDownload size={16} />
|
|
Экспорт
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
onClick={fetchBillingData}
|
|
disabled={loading}
|
|
>
|
|
<IconRefresh size={16} />
|
|
Обновить
|
|
</button>
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={handleAddItem}
|
|
>
|
|
<IconPlus size={16} />
|
|
Добавить
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Уведомления */}
|
|
{error && (
|
|
<div className="alert alert-danger alert-dismissible" role="alert">
|
|
<IconAlertTriangle className="me-2" />
|
|
{error}
|
|
<button type="button" className="btn-close" onClick={() => setError('')}></button>
|
|
</div>
|
|
)}
|
|
{success && (
|
|
<div className="alert alert-success alert-dismissible" role="alert">
|
|
<IconCheck className="me-2" />
|
|
{success}
|
|
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Фильтры */}
|
|
{showFilters && (
|
|
<div className="card mb-4">
|
|
<div className="card-header">
|
|
<h3 className="card-title">Фильтры</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="row g-3">
|
|
<div className="col-md-3">
|
|
<label className="form-label">Поиск</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
placeholder="Поиск по названию, назначению, провайдеру..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<label className="form-label">Провайдер</label>
|
|
<select
|
|
className="form-select"
|
|
value={filterProvider}
|
|
onChange={(e) => setFilterProvider(e.target.value)}
|
|
>
|
|
<option value="">Все провайдеры</option>
|
|
{uniqueProviders.map(provider => (
|
|
<option key={provider} value={provider}>{provider}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<label className="form-label">Страна</label>
|
|
<select
|
|
className="form-select"
|
|
value={filterCountry}
|
|
onChange={(e) => setFilterCountry(e.target.value)}
|
|
>
|
|
<option value="">Все страны</option>
|
|
{uniqueCountries.map(country => (
|
|
<option key={country} value={country}>{country}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<label className="form-label">Статус</label>
|
|
<select
|
|
className="form-select"
|
|
value={filterStatus}
|
|
onChange={(e) => setFilterStatus(e.target.value)}
|
|
>
|
|
<option value="">Все статусы</option>
|
|
{uniqueStatuses.map(status => (
|
|
<option key={status} value={status}>{status}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Статистические карточки */}
|
|
<div className="row g-3 mb-4">
|
|
<div className="col-md-3">
|
|
<div className="card h-100">
|
|
<div className="card-body d-flex align-items-center">
|
|
<span className="avatar avatar-lg me-3 bg-blue-lt text-blue border-0">
|
|
<IconCalendar size={32} />
|
|
</span>
|
|
<div>
|
|
<div className="h3 mb-0 fw-bold">
|
|
{new Date().toLocaleDateString('ru-RU', {
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric'
|
|
})}
|
|
</div>
|
|
<div className="text-muted lh-1">Текущая дата</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div className="card h-100">
|
|
<div className="card-body d-flex align-items-center">
|
|
<span className="avatar avatar-lg me-3 bg-orange-lt text-orange border-0">
|
|
<IconAlertTriangle size={32} />
|
|
</span>
|
|
<div>
|
|
<div className="h3 mb-0 fw-bold">
|
|
{billingData.filter(item => {
|
|
const nextPayment = new Date(item.nextPaymentDate);
|
|
const now = new Date();
|
|
const diffTime = nextPayment - now;
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
return diffDays <= 30 && diffDays > 0;
|
|
}).length}
|
|
</div>
|
|
<div className="text-muted lh-1">нод, ожидающих оплаты</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div className="card h-100">
|
|
<div className="card-body d-flex align-items-center">
|
|
<span className="avatar avatar-lg me-3 bg-green-lt text-green border-0">
|
|
<IconCreditCard size={32} />
|
|
</span>
|
|
<div>
|
|
<div className="h3 mb-0 fw-bold">
|
|
{formatCurrencyInRUB(totalLastPayments, 'USD')}
|
|
</div>
|
|
<div className="text-muted lh-1">всего платежей</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div className="card h-100">
|
|
<div className="card-body d-flex align-items-center">
|
|
<span className="avatar avatar-lg me-3 bg-purple-lt text-purple border-0">
|
|
<IconCurrencyDollar size={32} />
|
|
</span>
|
|
<div>
|
|
<div className="h3 mb-0 fw-bold">
|
|
{formatCurrencyInRUB(totalMonthlyCosts, 'USD')}
|
|
</div>
|
|
<div className="text-muted lh-1">месячные затраты</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Основная таблица */}
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<h3 className="card-title">Оплачиваемые ноды</h3>
|
|
<div className="card-actions">
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={handleSaveChanges}
|
|
disabled={loading}
|
|
>
|
|
<IconDatabase size={16} />
|
|
Сохранить
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="table-responsive">
|
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th className="cursor-pointer" onClick={() => handleSort('hostName')}>
|
|
Имя хостера
|
|
{sortField === 'hostName' && (
|
|
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
|
)}
|
|
</th>
|
|
<th className="cursor-pointer" onClick={() => handleSort('purpose')}>
|
|
Назначение
|
|
{sortField === 'purpose' && (
|
|
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
|
)}
|
|
</th>
|
|
<th className="cursor-pointer" onClick={() => handleSort('country')}>
|
|
Страна
|
|
{sortField === 'country' && (
|
|
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
|
)}
|
|
</th>
|
|
<th className="cursor-pointer" onClick={() => handleSort('provider')}>
|
|
Провайдер
|
|
{sortField === 'provider' && (
|
|
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
|
)}
|
|
</th>
|
|
<th className="cursor-pointer" onClick={() => handleSort('monthlyCost')}>
|
|
Месячная стоимость
|
|
{sortField === 'monthlyCost' && (
|
|
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
|
)}
|
|
</th>
|
|
<th className="cursor-pointer" onClick={() => handleSort('nextPaymentDate')}>
|
|
Следующий платеж
|
|
{sortField === 'nextPaymentDate' && (
|
|
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
|
)}
|
|
</th>
|
|
<th className="cursor-pointer" onClick={() => handleSort('status')}>
|
|
Статус
|
|
{sortField === 'status' && (
|
|
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
|
)}
|
|
</th>
|
|
<th>Действия</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{paginatedData.length === 0 ? (
|
|
<tr>
|
|
<td colSpan="8" className="text-center text-muted py-4">Ничего не найдено. Измените фильтры или параметры поиска.</td>
|
|
</tr>
|
|
) : paginatedData.map(item => (
|
|
<tr key={item.id}>
|
|
<td>
|
|
<div className="d-flex align-items-center">
|
|
<div>
|
|
<div className="fw-bold">{item.hostName}</div>
|
|
{item.loginUrl && (
|
|
<div className="text-muted small">
|
|
<a href={`https://${item.loginUrl}`} target="_blank" rel="noopener noreferrer">
|
|
<IconExternalLink size={12} className="me-1" />
|
|
{item.loginUrl}
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span className="badge bg-blue-lt text-blue">
|
|
{formatPurpose(item.purpose)}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<div className="d-flex align-items-center gap-2">
|
|
<span style={{ fontSize: '18px' }}>{getCountryFlag(item.country)}</span>
|
|
<span>{item.country}</span>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div className="d-flex align-items-center gap-2">
|
|
{item.loginUrl && (
|
|
<img
|
|
src={getFaviconUrl(item.loginUrl)}
|
|
alt=""
|
|
width="16"
|
|
height="16"
|
|
style={{ borderRadius: 3 }}
|
|
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
|
/>
|
|
)}
|
|
<span className="badge bg-green-lt text-green">{item.provider}</span>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div className="fw-bold">
|
|
{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
|
|
</div>
|
|
<div className="text-muted small">
|
|
{formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div className="fw-bold">
|
|
{formatDate(item.nextPaymentDate)}
|
|
</div>
|
|
{item.nextPaymentDate && (
|
|
<div className="text-muted small">
|
|
{(() => {
|
|
const nextPayment = new Date(item.nextPaymentDate);
|
|
const now = new Date();
|
|
const diffTime = nextPayment - now;
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
if (diffDays < 0) {
|
|
return <span className="text-danger">Просрочено на {Math.abs(diffDays)} дн.</span>;
|
|
} else if (diffDays <= 30) {
|
|
return <span className="text-warning">Через {diffDays} дн.</span>;
|
|
} else {
|
|
return <span className="text-success">Через {diffDays} дн.</span>;
|
|
}
|
|
})()}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td>
|
|
<span className={`badge ${item.status === 'active' ? 'bg-success-lt text-success' : 'bg-danger-lt text-danger'}`}>
|
|
{item.status === 'active' ? 'Активен' : 'Неактивен'}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<div className="btn-list">
|
|
<button
|
|
className="btn btn-outline-primary btn-icon btn-sm"
|
|
onClick={() => handleEditItem(item)}
|
|
title="Редактировать"
|
|
>
|
|
<IconEdit size={16} />
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-success btn-icon btn-sm"
|
|
onClick={() => {
|
|
setPaymentDraft({
|
|
serverId: item.id,
|
|
date: new Date().toISOString().slice(0,10),
|
|
amount: item.monthlyCost || 0,
|
|
currency: item.monthlyCostCurrency || 'USD',
|
|
note: ''
|
|
});
|
|
setShowPaymentModal(true);
|
|
}}
|
|
title="Добавить платеж"
|
|
>
|
|
<IconPlus size={16} />
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-danger btn-icon btn-sm"
|
|
onClick={() => handleDeleteItem(item)}
|
|
title="Удалить"
|
|
>
|
|
<IconTrash size={16} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Пагинация */}
|
|
{totalPages > 1 && (
|
|
<div className="d-flex align-items-center justify-content-between mt-3">
|
|
<div className="text-muted">
|
|
Показано {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, filteredData.length)} из {filteredData.length}
|
|
</div>
|
|
<ul className="pagination m-0">
|
|
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>Первая</button>
|
|
</li>
|
|
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>Назад</button>
|
|
</li>
|
|
{(() => {
|
|
const pages = [];
|
|
let start = Math.max(1, currentPage - 2);
|
|
let end = Math.min(totalPages, currentPage + 2);
|
|
if (currentPage <= 3) end = Math.min(totalPages, 5);
|
|
if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
|
|
if (start > 1) pages.push('start-ellipsis');
|
|
for (let p = start; p <= end; p++) pages.push(p);
|
|
if (end < totalPages) pages.push('end-ellipsis');
|
|
return pages.map((p) => (
|
|
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
|
|
<li key={p} className="page-item disabled"><span className="page-link">…</span></li>
|
|
) : (
|
|
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(p)}>{p}</button>
|
|
</li>
|
|
)
|
|
));
|
|
})()}
|
|
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages}>Вперед</button>
|
|
</li>
|
|
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages}>Последняя</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* История платежей */}
|
|
<div className="card mt-4">
|
|
<div className="card-header">
|
|
<h3 className="card-title">
|
|
<IconHistory size={20} className="me-2" />
|
|
История платежей
|
|
</h3>
|
|
<div className="card-actions">
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
onClick={() => { setPaymentDraft({ serverId: '', date: '', amount: 0, currency: 'USD', note: '' }); setShowPaymentModal(true); }}
|
|
>
|
|
<IconPlus size={16} />
|
|
Добавить платеж
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="table-responsive">
|
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Имя хостера</th>
|
|
<th>Дата</th>
|
|
<th>Сумма</th>
|
|
<th>Валюта</th>
|
|
<th>Комментарий</th>
|
|
<th>Действия</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(() => {
|
|
const rows = billingData.flatMap(server => (server.payments || []).map((p, idx) => ({ server, ...p, _key: `${server.id}-${idx}`, _index: idx }))).sort((a,b) => new Date(b.date) - new Date(a.date));
|
|
if (rows.length === 0) {
|
|
return null;
|
|
}
|
|
return rows.slice(0, 20).map(r => (
|
|
<tr key={r._key}>
|
|
<td>
|
|
<div className="fw-bold">{r.server.hostName}</div>
|
|
<div className="text-muted small d-flex align-items-center gap-2">
|
|
{r.server.loginUrl && (
|
|
<img
|
|
src={getFaviconUrl(r.server.loginUrl)}
|
|
alt=""
|
|
width="14"
|
|
height="14"
|
|
style={{ borderRadius: 3 }}
|
|
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
|
/>
|
|
)}
|
|
<span>{r.server.provider}</span>
|
|
</div>
|
|
</td>
|
|
<td>{formatDate(r.date)}</td>
|
|
<td>
|
|
<div className="fw-bold">{formatCurrency(r.amount, r.currency)}</div>
|
|
<div className="text-muted small">{formatCurrencyInRUB(r.amount, r.currency)}</div>
|
|
</td>
|
|
<td><span className="badge bg-blue-lt text-blue">{r.currency || 'USD'}</span></td>
|
|
<td className="text-muted small">{r.note || ''}</td>
|
|
<td>
|
|
<div className="btn-list">
|
|
<button
|
|
className="btn btn-outline-primary btn-icon"
|
|
title="Редактировать платеж"
|
|
onClick={() => {
|
|
setPaymentDraft({
|
|
serverId: r.server.id,
|
|
date: r.date,
|
|
amount: r.amount,
|
|
currency: r.currency || 'USD',
|
|
note: r.note || ''
|
|
});
|
|
setEditingPayment({ serverId: r.server.id, index: r._index });
|
|
setShowPaymentModal(true);
|
|
}}
|
|
>
|
|
<IconEdit size={18} />
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-danger btn-icon"
|
|
title="Удалить платеж"
|
|
onClick={() => {
|
|
setPaymentToDelete({ serverId: r.server.id, index: r._index, data: r });
|
|
setShowDeletePaymentModal(true);
|
|
}}
|
|
>
|
|
<IconTrash size={18} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
));
|
|
})()}
|
|
{billingData.every(i => !i.payments || i.payments.length === 0) && (
|
|
<tr>
|
|
<td colSpan="6" className="text-center text-muted py-4">
|
|
<div className="py-3">
|
|
<IconHistory size={48} className="text-muted mb-3" />
|
|
<div>История платежей пуста</div>
|
|
<div className="small text-muted mt-2">
|
|
Добавьте первый платеж, чтобы начать отслеживание
|
|
</div>
|
|
<button
|
|
className="btn btn-primary btn-sm mt-3"
|
|
onClick={() => {
|
|
setNewItem({
|
|
hostName: '',
|
|
purpose: '',
|
|
country: '',
|
|
provider: '',
|
|
loginUrl: '',
|
|
monthlyCost: 0,
|
|
monthlyCostCurrency: 'USD',
|
|
nextPaymentDate: '',
|
|
lastPaymentDate: '',
|
|
lastPaymentAmount: 0,
|
|
lastPaymentCurrency: 'USD',
|
|
status: 'active',
|
|
notes: ''
|
|
});
|
|
setShowAddModal(true);
|
|
}}
|
|
>
|
|
<IconPlus size={16} />
|
|
Добавить первый платеж
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Модальные окна */}
|
|
<AddBillingModal
|
|
show={showAddModal}
|
|
item={newItem}
|
|
onItemChange={setNewItem}
|
|
onSubmit={handleAddSubmit}
|
|
onClose={() => setShowAddModal(false)}
|
|
/>
|
|
|
|
<EditBillingModal
|
|
show={showEditModal}
|
|
item={editingItem}
|
|
onItemChange={setEditingItem}
|
|
onSubmit={handleEditSubmit}
|
|
onClose={() => setShowEditModal(false)}
|
|
/>
|
|
|
|
<DeleteBillingModal
|
|
show={showDeleteModal}
|
|
item={selectedItem}
|
|
onDelete={confirmDelete}
|
|
onClose={() => setShowDeleteModal(false)}
|
|
/>
|
|
{/* Модалка добавления платежа */}
|
|
{showPaymentModal && (
|
|
<AddPaymentModal
|
|
show={showPaymentModal}
|
|
servers={billingData}
|
|
payment={paymentDraft}
|
|
onChange={setPaymentDraft}
|
|
onClose={() => setShowPaymentModal(false)}
|
|
onSubmit={() => {
|
|
if (!paymentDraft.serverId || !paymentDraft.date || !paymentDraft.amount) {
|
|
setError('Заполните сервер, дату и сумму.');
|
|
setTimeout(() => setError(''), 3000);
|
|
return;
|
|
}
|
|
// Режим редактирования платежа
|
|
if (editingPayment && editingPayment.serverId) {
|
|
setBillingData(prev => prev.map(s => {
|
|
if (s.id !== editingPayment.serverId) return s;
|
|
const payments = Array.isArray(s.payments) ? [...s.payments] : [];
|
|
if (editingPayment.index != null && payments[editingPayment.index]) {
|
|
payments[editingPayment.index] = {
|
|
date: paymentDraft.date,
|
|
amount: paymentDraft.amount,
|
|
currency: paymentDraft.currency || 'USD',
|
|
note: paymentDraft.note || ''
|
|
};
|
|
}
|
|
// обновляем lastPayment* по последней дате
|
|
const last = payments
|
|
.slice()
|
|
.sort((a,b) => new Date(b.date) - new Date(a.date))[0];
|
|
return {
|
|
...s,
|
|
payments,
|
|
lastPaymentDate: last?.date || '',
|
|
lastPaymentAmount: last?.amount || 0,
|
|
lastPaymentCurrency: last?.currency || s.monthlyCostCurrency || 'USD'
|
|
};
|
|
}));
|
|
setEditingPayment(null);
|
|
setShowPaymentModal(false);
|
|
setSuccess('Платеж обновлен');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
return;
|
|
}
|
|
// Режим добавления платежа
|
|
setBillingData(prev => prev.map(s => {
|
|
if (s.id !== paymentDraft.serverId) return s;
|
|
const payments = Array.isArray(s.payments) ? [...s.payments] : [];
|
|
payments.push({ date: paymentDraft.date, amount: paymentDraft.amount, currency: paymentDraft.currency || 'USD', note: paymentDraft.note || '' });
|
|
const last = payments
|
|
.slice()
|
|
.sort((a,b) => new Date(b.date) - new Date(a.date))[0];
|
|
return { ...s, payments, lastPaymentDate: last?.date || '', lastPaymentAmount: last?.amount || 0, lastPaymentCurrency: last?.currency || s.monthlyCostCurrency || 'USD' };
|
|
}));
|
|
setShowPaymentModal(false);
|
|
setSuccess('Платеж добавлен');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Модалка удаления платежа */}
|
|
{showDeletePaymentModal && (
|
|
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
|
<div className="modal-dialog">
|
|
<div className="modal-content">
|
|
<div className="modal-header">
|
|
<h5 className="modal-title">Удалить платеж</h5>
|
|
<button type="button" className="btn-close" onClick={() => setShowDeletePaymentModal(false)}></button>
|
|
</div>
|
|
<div className="modal-body">
|
|
<p>Вы уверены, что хотите удалить выбранный платеж?</p>
|
|
{paymentToDelete && (
|
|
<div className="alert alert-warning">
|
|
<div><strong>Сервер:</strong> {paymentToDelete.data?.server?.hostName}</div>
|
|
<div><strong>Дата:</strong> {formatDate(paymentToDelete.data?.date)}</div>
|
|
<div><strong>Сумма:</strong> {formatCurrency(paymentToDelete.data?.amount, paymentToDelete.data?.currency)}</div>
|
|
{paymentToDelete.data?.note && (
|
|
<div><strong>Комментарий:</strong> {paymentToDelete.data?.note}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="modal-footer">
|
|
<button type="button" className="btn btn-secondary" onClick={() => setShowDeletePaymentModal(false)}>
|
|
Отмена
|
|
</button>
|
|
<button type="button" className="btn btn-danger" onClick={handleDeletePayment}>
|
|
Удалить
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Компоненты модальных окон
|
|
function AddBillingModal({ show, item, onItemChange, onSubmit, onClose }) {
|
|
if (!show) return null;
|
|
|
|
const modalTitle = 'Добавить новый элемент';
|
|
|
|
return (
|
|
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
|
<div className="modal-dialog modal-lg">
|
|
<div className="modal-content">
|
|
<div className="modal-header">
|
|
<h5 className="modal-title">{modalTitle}</h5>
|
|
<button type="button" className="btn-close" onClick={onClose}></button>
|
|
</div>
|
|
<div className="modal-body">
|
|
<div className="row g-3">
|
|
{/* Основная информация */}
|
|
<div className="col-12">
|
|
<h6 className="text-muted mb-3">Основная информация</h6>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Имя хоста *</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={item.hostName}
|
|
onChange={(e) => onItemChange({ ...item, hostName: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Назначение</label>
|
|
<select
|
|
className="form-select"
|
|
value={item.purpose}
|
|
onChange={(e) => onItemChange({ ...item, purpose: e.target.value })}
|
|
>
|
|
<option value="">Выберите назначение</option>
|
|
<option value="relay">Relay VPS</option>
|
|
<option value="bgp">BGP сервер</option>
|
|
<option value="monitoring">Мониторинг</option>
|
|
<option value="dns">DNS сервер</option>
|
|
<option value="proxy">Прокси сервер</option>
|
|
<option value="backup">Backup сервер</option>
|
|
<option value="other">Другое</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Страна *</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={item.country}
|
|
onChange={(e) => onItemChange({ ...item, country: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Провайдер *</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={item.provider}
|
|
onChange={(e) => onItemChange({ ...item, provider: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Ссылка для входа</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={item.loginUrl}
|
|
onChange={(e) => onItemChange({ ...item, loginUrl: e.target.value })}
|
|
placeholder="example.com"
|
|
/>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Статус</label>
|
|
<select
|
|
className="form-select"
|
|
value={item.status}
|
|
onChange={(e) => onItemChange({ ...item, status: e.target.value })}
|
|
>
|
|
<option value="active">Активен</option>
|
|
<option value="inactive">Неактивен</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Текущие платежи */}
|
|
<div className="col-12">
|
|
<h6 className="text-muted mb-3 mt-4">Текущие платежи</h6>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Месячная стоимость</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
className="form-control"
|
|
value={item.monthlyCost}
|
|
onChange={(e) => onItemChange({ ...item, monthlyCost: parseFloat(e.target.value) || 0 })}
|
|
/>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Валюта месячной стоимости</label>
|
|
<select
|
|
className="form-select"
|
|
value={item.monthlyCostCurrency}
|
|
onChange={(e) => onItemChange({ ...item, monthlyCostCurrency: e.target.value })}
|
|
>
|
|
<option value="USD">USD</option>
|
|
<option value="EUR">EUR</option>
|
|
<option value="RUB">RUB</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Дата следующего платежа</label>
|
|
<input
|
|
type="date"
|
|
className="form-control"
|
|
value={item.nextPaymentDate}
|
|
onChange={(e) => onItemChange({ ...item, nextPaymentDate: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
{/* Дополнительно */}
|
|
<div className="col-md-12">
|
|
<label className="form-label">Заметки</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={item.notes}
|
|
onChange={(e) => onItemChange({ ...item, notes: e.target.value })}
|
|
placeholder="Дополнительная информация"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="modal-footer">
|
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
|
Отмена
|
|
</button>
|
|
<button type="button" className="btn btn-primary" onClick={onSubmit}>
|
|
Добавить
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EditBillingModal({ show, item, onItemChange, onSubmit, onClose }) {
|
|
if (!show) return null;
|
|
|
|
return (
|
|
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
|
<div className="modal-dialog modal-lg">
|
|
<div className="modal-content">
|
|
<div className="modal-header">
|
|
<h5 className="modal-title">Редактировать элемент</h5>
|
|
<button type="button" className="btn-close" onClick={onClose}></button>
|
|
</div>
|
|
<div className="modal-body">
|
|
<div className="row g-3">
|
|
{/* Основная информация */}
|
|
<div className="col-12">
|
|
<h6 className="text-muted mb-3">Основная информация</h6>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Имя хоста *</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={item.hostName || ''}
|
|
onChange={(e) => onItemChange({ ...item, hostName: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Назначение</label>
|
|
<select
|
|
className="form-select"
|
|
value={item.purpose || ''}
|
|
onChange={(e) => onItemChange({ ...item, purpose: e.target.value })}
|
|
>
|
|
<option value="">Выберите назначение</option>
|
|
<option value="relay">Relay VPS</option>
|
|
<option value="bgp">BGP сервер</option>
|
|
<option value="monitoring">Мониторинг</option>
|
|
<option value="dns">DNS сервер</option>
|
|
<option value="proxy">Прокси сервер</option>
|
|
<option value="backup">Backup сервер</option>
|
|
<option value="other">Другое</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Страна *</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={item.country || ''}
|
|
onChange={(e) => onItemChange({ ...item, country: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Провайдер *</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={item.provider || ''}
|
|
onChange={(e) => onItemChange({ ...item, provider: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Ссылка для входа</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={item.loginUrl || ''}
|
|
onChange={(e) => onItemChange({ ...item, loginUrl: e.target.value })}
|
|
placeholder="example.com"
|
|
/>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Статус</label>
|
|
<select
|
|
className="form-select"
|
|
value={item.status || 'active'}
|
|
onChange={(e) => onItemChange({ ...item, status: e.target.value })}
|
|
>
|
|
<option value="active">Активен</option>
|
|
<option value="inactive">Неактивен</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Текущие платежи */}
|
|
<div className="col-12">
|
|
<h6 className="text-muted mb-3 mt-4">Текущие платежи</h6>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Месячная стоимость</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
className="form-control"
|
|
value={item.monthlyCost || 0}
|
|
onChange={(e) => onItemChange({ ...item, monthlyCost: parseFloat(e.target.value) || 0 })}
|
|
/>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Валюта месячной стоимости</label>
|
|
<select
|
|
className="form-select"
|
|
value={item.monthlyCostCurrency || 'USD'}
|
|
onChange={(e) => onItemChange({ ...item, monthlyCostCurrency: e.target.value })}
|
|
>
|
|
<option value="USD">USD</option>
|
|
<option value="EUR">EUR</option>
|
|
<option value="RUB">RUB</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Дата следующего платежа</label>
|
|
<input
|
|
type="date"
|
|
className="form-control"
|
|
value={item.nextPaymentDate || ''}
|
|
onChange={(e) => onItemChange({ ...item, nextPaymentDate: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
{/* Дополнительно */}
|
|
<div className="col-md-12">
|
|
<label className="form-label">Заметки</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={item.notes || ''}
|
|
onChange={(e) => onItemChange({ ...item, notes: e.target.value })}
|
|
placeholder="Дополнительная информация"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="modal-footer">
|
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
|
Отмена
|
|
</button>
|
|
<button type="button" className="btn btn-primary" onClick={onSubmit}>
|
|
Сохранить
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DeleteBillingModal({ show, item, onDelete, onClose }) {
|
|
if (!show) return null;
|
|
|
|
return (
|
|
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
|
<div className="modal-dialog">
|
|
<div className="modal-content">
|
|
<div className="modal-header">
|
|
<h5 className="modal-title">Подтверждение удаления</h5>
|
|
<button type="button" className="btn-close" onClick={onClose}></button>
|
|
</div>
|
|
<div className="modal-body">
|
|
<p>Вы уверены, что хотите удалить элемент <strong>{item?.hostName}</strong>?</p>
|
|
<p className="text-muted">Это действие нельзя отменить.</p>
|
|
</div>
|
|
<div className="modal-footer">
|
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
|
Отмена
|
|
</button>
|
|
<button type="button" className="btn btn-danger" onClick={onDelete}>
|
|
Удалить
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default BillingManager;
|
|
|
|
// Модалка добавления платежа
|
|
function AddPaymentModal({ show, servers, payment, onChange, onSubmit, onClose }) {
|
|
if (!show) return null;
|
|
const serverOptions = servers.map(s => ({ id: s.id, label: `${s.hostName} (${s.provider})` }));
|
|
return (
|
|
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
|
<div className="modal-dialog">
|
|
<div className="modal-content">
|
|
<div className="modal-header">
|
|
<h5 className="modal-title">Добавить платеж</h5>
|
|
<button type="button" className="btn-close" onClick={onClose}></button>
|
|
</div>
|
|
<div className="modal-body">
|
|
<div className="mb-3">
|
|
<label className="form-label">Сервер *</label>
|
|
<select className="form-select" value={payment.serverId} onChange={e => onChange({ ...payment, serverId: e.target.value })}>
|
|
<option value="">Выберите сервер</option>
|
|
{serverOptions.map(o => (
|
|
<option key={o.id} value={o.id}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">Дата *</label>
|
|
<input type="date" className="form-control" value={payment.date} onChange={e => onChange({ ...payment, date: e.target.value })} />
|
|
</div>
|
|
<div className="row g-2">
|
|
<div className="col-8">
|
|
<label className="form-label">Сумма *</label>
|
|
<input type="number" step="0.01" className="form-control" value={payment.amount} onChange={e => onChange({ ...payment, amount: parseFloat(e.target.value) || 0 })} />
|
|
</div>
|
|
<div className="col-4">
|
|
<label className="form-label">Валюта</label>
|
|
<select className="form-select" value={payment.currency} onChange={e => onChange({ ...payment, currency: e.target.value })}>
|
|
<option value="USD">USD</option>
|
|
<option value="EUR">EUR</option>
|
|
<option value="RUB">RUB</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3">
|
|
<label className="form-label">Комментарий</label>
|
|
<input type="text" className="form-control" value={payment.note} onChange={e => onChange({ ...payment, note: e.target.value })} placeholder="Необязательно" />
|
|
</div>
|
|
</div>
|
|
<div className="modal-footer">
|
|
<button className="btn btn-secondary" onClick={onClose}>Отмена</button>
|
|
<button className="btn btn-primary" onClick={onSubmit}>Добавить</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |