Files
router-lists-ui/frontend/src/BillingManager.jsx
T

1998 lines
77 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react';
import api from './lib/api.js';
import axios from 'axios';
import FormModal from './components/FormModal.jsx';
import ConfirmModal from './components/ConfirmModal.jsx';
import Pagination from './components/Pagination.jsx';
import PageHeader from './components/PageHeader.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import FormField from './components/FormField.jsx';
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
import ErrorAlert from './components/ErrorAlert.jsx';
import {
IconPlus,
IconEdit,
IconTrash,
IconCheck,
IconDatabase,
IconRefresh,
IconAlertTriangle,
IconCalendar,
IconCreditCard,
IconCurrencyDollar,
IconExternalLink,
IconSearch,
IconFilter,
IconDownload,
IconUpload,
IconEye,
IconEyeOff,
IconHistory,
IconChevronUp,
IconChevronDown,
IconX,
IconChecks
} from '@tabler/icons-react';
import BulkActionsBar from './components/BulkActionsBar.jsx';
function BillingManager() {
const [billingData, setBillingData] = useState([]);
const [servers, setServers] = 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 [futureDateConfirm, setFutureDateConfirm] = useState(false); // Подтверждение будущей даты
// Состояния для истории платежей: поиск, сортировка, фильтры
const [paymentSearchTerm, setPaymentSearchTerm] = useState('');
const [paymentSortField, setPaymentSortField] = useState('date');
const [paymentSortOrder, setPaymentSortOrder] = useState('desc');
const [paymentFilterServer, setPaymentFilterServer] = useState('');
const [paymentFilterDateFrom, setPaymentFilterDateFrom] = useState('');
const [paymentFilterDateTo, setPaymentFilterDateTo] = useState('');
const [paymentFilterAmountFrom, setPaymentFilterAmountFrom] = useState('');
const [paymentFilterAmountTo, setPaymentFilterAmountTo] = useState('');
const [showPaymentFilters, setShowPaymentFilters] = useState(false);
const [selectedPayments, setSelectedPayments] = useState(new Set());
const [paymentPage, setPaymentPage] = useState(1);
const paymentPageSize = 20;
// Состояние для курсов валют
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: '',
serverId: '',
});
useEffect(() => {
fetchBillingData();
fetchExchangeRates();
fetchServers();
}, []);
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 fetchServers = async () => {
try {
const res = await api.get(`/servers`);
setServers(Array.isArray(res.data) ? res.data : []);
} catch (error) {
console.error('Ошибка при загрузке серверов для привязки биллинга:', error);
// связь необязательная, поэтому не показываем отдельную ошибку пользователю
}
};
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: '',
serverId: '',
});
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');
}
};
// Карта серверов по id для быстрой привязки
const serversById = new Map(
(servers || [])
.filter(s => s && s.id)
.map(s => [s.id, s])
);
// Вычисляем общую сумму последних платежей
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>
{/* Заголовок страницы */}
<PageHeader
title="Инфра-биллинг"
pretitle="Панель управления / Ноды / Инфра-биллинг"
actions={
<PageHeaderActions
loading={loading}
onRefresh={fetchBillingData}
disableRefresh={loading}
onExport={exportData}
disableExport={loading}
onSave={handleSaveChanges}
disableSave={loading}
/>
}
/>
{/* Уведомления */}
{error && (
<ErrorAlert
message={error}
onClose={() => setError('')}
onRetry={fetchBillingData}
className="mb-4"
/>
)}
{success && (
<div className="alert alert-success alert-dismissible mb-4" role="alert">
<IconCheck className="me-2" />
{success}
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</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">
<div className="btn-list">
<button
className="btn btn-outline-secondary"
onClick={() => setShowFilters(!showFilters)}
>
<IconFilter size={16} className="me-1" />
Фильтры
</button>
<button
className="btn btn-primary"
onClick={handleAddItem}
>
<IconPlus size={16} className="me-1" />
Добавить
</button>
<button
className="btn btn-primary"
onClick={handleSaveChanges}
disabled={loading}
>
<IconDatabase size={16} className="me-1" />
Сохранить
</button>
</div>
</div>
</div>
<div className="card-body">
{/* Фильтры */}
{showFilters && (
<div className="card mb-3">
<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="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>Связанный сервер</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>
{item.serverId ? (
(() => {
const srv = serversById.get(item.serverId);
if (!srv) {
return <span className="text-warning small">Сервер не найден</span>;
}
return (
<div className="small">
<div className="fw-bold">{srv.ip}</div>
<div className="text-muted">
{srv.dns || srv.provider || 'Без DNS'}
</div>
<a href="/servers" className="small text-blue text-decoration-underline">
Открыть в разделе «Серверы»
</a>
</div>
);
})()
) : (
<span className="text-muted small">Не привязан</span>
)}
</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>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={filteredData.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
</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">
<div className="btn-list">
<button
className="btn btn-outline-secondary"
onClick={() => setShowPaymentFilters(!showPaymentFilters)}
>
<IconFilter size={16} className="me-1" />
Фильтры
</button>
<button
className="btn btn-primary"
onClick={() => {
setPaymentDraft({ serverId: '', date: '', amount: 0, currency: 'USD', note: '' });
setShowPaymentModal(true);
}}
>
<IconPlus size={16} className="me-1" />
Добавить платеж
</button>
</div>
</div>
</div>
<div className="card-body">
{/* Поиск и фильтры */}
<div className="mb-3">
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск по серверу, дате, сумме, комментарию..."
value={paymentSearchTerm}
onChange={(e) => { setPaymentSearchTerm(e.target.value); setPaymentPage(1); }}
aria-label="Поиск платежей"
style={{ paddingRight: paymentSearchTerm ? '2.5rem' : undefined }}
/>
{paymentSearchTerm && (
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm"
style={{
position: 'absolute',
right: '0.25rem',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 2
}}
onClick={() => { setPaymentSearchTerm(''); setPaymentPage(1); }}
aria-label="Очистить поиск"
>
<IconX size={16} />
</button>
)}
</div>
</div>
{/* Панель фильтров */}
{showPaymentFilters && (
<div className="card mb-3">
<div className="card-body">
<div className="row g-3">
<div className="col-md-3">
<label className="form-label">Сервер</label>
<select
className="form-select"
value={paymentFilterServer}
onChange={(e) => { setPaymentFilterServer(e.target.value); setPaymentPage(1); }}
>
<option value="">Все серверы</option>
{billingData.map(server => (
<option key={server.id} value={server.id}>
{server.hostName} ({server.provider})
</option>
))}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Дата от</label>
<input
type="date"
className="form-control"
value={paymentFilterDateFrom}
onChange={(e) => { setPaymentFilterDateFrom(e.target.value); setPaymentPage(1); }}
/>
</div>
<div className="col-md-3">
<label className="form-label">Дата до</label>
<input
type="date"
className="form-control"
value={paymentFilterDateTo}
onChange={(e) => { setPaymentFilterDateTo(e.target.value); setPaymentPage(1); }}
/>
</div>
<div className="col-md-3">
<label className="form-label">Сумма от</label>
<input
type="number"
className="form-control"
placeholder="0"
step="0.01"
value={paymentFilterAmountFrom}
onChange={(e) => { setPaymentFilterAmountFrom(e.target.value); setPaymentPage(1); }}
/>
</div>
<div className="col-md-3">
<label className="form-label">Сумма до</label>
<input
type="number"
className="form-control"
placeholder="0"
step="0.01"
value={paymentFilterAmountTo}
onChange={(e) => { setPaymentFilterAmountTo(e.target.value); setPaymentPage(1); }}
/>
</div>
<div className="col-md-3 d-flex align-items-end">
<button
className="btn btn-ghost-secondary"
onClick={() => {
setPaymentFilterServer('');
setPaymentFilterDateFrom('');
setPaymentFilterDateTo('');
setPaymentFilterAmountFrom('');
setPaymentFilterAmountTo('');
setPaymentPage(1);
}}
>
<IconX size={16} className="me-1" />
Сбросить
</button>
</div>
</div>
</div>
</div>
)}
{/* Bulk Actions Bar */}
<BulkActionsBar
selectedCount={selectedPayments.size}
totalCount={(() => {
const allRows = billingData.flatMap(server =>
(server.payments || []).map((p, idx) => ({ server, ...p, _key: `${server.id}-${idx}`, _index: idx }))
);
return allRows.length;
})()}
onSelectAll={() => {
const allRows = billingData.flatMap(server =>
(server.payments || []).map((p, idx) => ({ server, ...p, _key: `${server.id}-${idx}`, _index: idx }))
);
setSelectedPayments(new Set(allRows.map(r => r._key)));
}}
onDeselectAll={() => setSelectedPayments(new Set())}
onDelete={() => {
if (selectedPayments.size === 0) return;
const keys = Array.from(selectedPayments);
setBillingData(prev => prev.map(server => {
const payments = Array.isArray(server.payments) ? [...server.payments] : [];
const filtered = payments.filter((p, idx) => {
const key = `${server.id}-${idx}`;
return !keys.includes(key);
});
const last = filtered
.slice()
.sort((a,b) => new Date(b.date) - new Date(a.date))[0];
return {
...server,
payments: filtered,
lastPaymentDate: last?.date || '',
lastPaymentAmount: last?.amount || 0,
lastPaymentCurrency: last?.currency || server.monthlyCostCurrency || 'USD'
};
}));
setSelectedPayments(new Set());
setSuccess(`Удалено платежей: ${selectedPayments.size}`);
setTimeout(() => setSuccess(''), 3000);
}}
onEdit={() => {
// Массовое редактирование - можно добавить позже
setError('Массовое редактирование пока не реализовано');
setTimeout(() => setError(''), 3000);
}}
/>
{/* Таблица платежей */}
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40px' }}>
<input
type="checkbox"
className="form-check-input"
checked={selectedPayments.size > 0 && (() => {
const allRows = billingData.flatMap(server =>
(server.payments || []).map((p, idx) => ({ server, ...p, _key: `${server.id}-${idx}`, _index: idx }))
);
return selectedPayments.size === allRows.length;
})()}
onChange={(e) => {
if (e.target.checked) {
const allRows = billingData.flatMap(server =>
(server.payments || []).map((p, idx) => ({ server, ...p, _key: `${server.id}-${idx}`, _index: idx }))
);
setSelectedPayments(new Set(allRows.map(r => r._key)));
} else {
setSelectedPayments(new Set());
}
}}
/>
</th>
<th
style={{ cursor: 'pointer' }}
onClick={() => {
if (paymentSortField === 'hostName') {
setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc');
} else {
setPaymentSortField('hostName');
setPaymentSortOrder('asc');
}
}}
>
<div className="d-flex align-items-center gap-2">
Имя хостера
{paymentSortField === 'hostName' && (
paymentSortOrder === 'asc' ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />
)}
</div>
</th>
<th
style={{ cursor: 'pointer' }}
onClick={() => {
if (paymentSortField === 'date') {
setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc');
} else {
setPaymentSortField('date');
setPaymentSortOrder('desc');
}
}}
>
<div className="d-flex align-items-center gap-2">
Дата
{paymentSortField === 'date' && (
paymentSortOrder === 'asc' ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />
)}
</div>
</th>
<th
style={{ cursor: 'pointer' }}
onClick={() => {
if (paymentSortField === 'amount') {
setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc');
} else {
setPaymentSortField('amount');
setPaymentSortOrder('desc');
}
}}
>
<div className="d-flex align-items-center gap-2">
Сумма
{paymentSortField === 'amount' && (
paymentSortOrder === 'asc' ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />
)}
</div>
</th>
<th>Валюта</th>
<th>Комментарий</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{(() => {
// Получаем все платежи
let allRows = billingData.flatMap(server =>
(server.payments || []).map((p, idx) => ({
server,
...p,
_key: `${server.id}-${idx}`,
_index: idx
}))
);
// Поиск
if (paymentSearchTerm) {
const term = paymentSearchTerm.toLowerCase();
allRows = allRows.filter(r =>
r.server.hostName?.toLowerCase().includes(term) ||
r.server.provider?.toLowerCase().includes(term) ||
formatDate(r.date).toLowerCase().includes(term) ||
String(r.amount).includes(term) ||
r.currency?.toLowerCase().includes(term) ||
r.note?.toLowerCase().includes(term)
);
}
// Фильтр по серверу
if (paymentFilterServer) {
allRows = allRows.filter(r => r.server.id === paymentFilterServer);
}
// Фильтр по дате
if (paymentFilterDateFrom) {
allRows = allRows.filter(r => new Date(r.date) >= new Date(paymentFilterDateFrom));
}
if (paymentFilterDateTo) {
allRows = allRows.filter(r => new Date(r.date) <= new Date(paymentFilterDateTo));
}
// Фильтр по сумме
if (paymentFilterAmountFrom) {
const from = parseFloat(paymentFilterAmountFrom);
allRows = allRows.filter(r => r.amount >= from);
}
if (paymentFilterAmountTo) {
const to = parseFloat(paymentFilterAmountTo);
allRows = allRows.filter(r => r.amount <= to);
}
// Сортировка
allRows.sort((a, b) => {
let valA, valB;
if (paymentSortField === 'date') {
valA = new Date(a.date);
valB = new Date(b.date);
} else if (paymentSortField === 'amount') {
valA = a.amount || 0;
valB = b.amount || 0;
} else if (paymentSortField === 'hostName') {
valA = (a.server.hostName || '').toLowerCase();
valB = (b.server.hostName || '').toLowerCase();
} else {
valA = a[paymentSortField] || '';
valB = b[paymentSortField] || '';
}
if (valA < valB) return paymentSortOrder === 'asc' ? -1 : 1;
if (valA > valB) return paymentSortOrder === 'asc' ? 1 : -1;
return 0;
});
// Пагинация
const totalPaymentPages = Math.max(1, Math.ceil(allRows.length / paymentPageSize));
const paginatedRows = allRows.slice(
(paymentPage - 1) * paymentPageSize,
paymentPage * paymentPageSize
);
if (paginatedRows.length === 0) {
return (
<tr>
<td colSpan="7" className="text-center text-muted py-4">
<div className="py-3">
<IconHistory size={48} className="text-muted mb-3" />
<div>Платежи не найдены</div>
{allRows.length === 0 && billingData.every(i => !i.payments || i.payments.length === 0) && (
<>
<div className="small text-muted mt-2">
Добавьте первый платеж, чтобы начать отслеживание
</div>
<button
className="btn btn-primary btn-sm mt-3"
onClick={() => {
setPaymentDraft({ serverId: '', date: '', amount: 0, currency: 'USD', note: '' });
setShowPaymentModal(true);
}}
>
<IconPlus size={16} className="me-1" />
Добавить первый платеж
</button>
</>
)}
</div>
</td>
</tr>
);
}
return (
<>
{paginatedRows.map(r => (
<tr key={r._key}>
<td>
<input
type="checkbox"
className="form-check-input"
checked={selectedPayments.has(r._key)}
onChange={(e) => {
const newSet = new Set(selectedPayments);
if (e.target.checked) {
newSet.add(r._key);
} else {
newSet.delete(r._key);
}
setSelectedPayments(newSet);
}}
/>
</td>
<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>
))}
{totalPaymentPages > 1 && (
<tr>
<td colSpan="7" className="p-0">
<Pagination
currentPage={paymentPage}
totalPages={totalPaymentPages}
totalItems={allRows.length}
pageSize={paymentPageSize}
onPageChange={setPaymentPage}
/>
</td>
</tr>
)}
</>
);
})()}
</tbody>
</table>
</div>
</div>
</div>
{/* Модальные окна */}
<FormModal
show={showAddModal}
title="Добавить новый элемент"
onSubmit={(e) => {
e.preventDefault();
handleAddSubmit();
}}
onClose={() => setShowAddModal(false)}
submitLabel="Добавить"
submitIcon={IconPlus}
size="lg"
>
<AddBillingForm
item={newItem}
onItemChange={setNewItem}
servers={servers}
/>
</FormModal>
<FormModal
show={showEditModal}
title="Редактировать элемент"
onSubmit={(e) => {
e.preventDefault();
handleEditSubmit();
}}
onClose={() => setShowEditModal(false)}
submitLabel="Сохранить"
submitIcon={IconCheck}
size="lg"
>
<EditBillingForm
item={editingItem}
onItemChange={setEditingItem}
servers={servers}
/>
</FormModal>
<ConfirmModal
show={showDeleteModal}
title="Подтверждение удаления"
message={
<>
<p>Вы уверены, что хотите удалить элемент <strong>{selectedItem?.hostName}</strong>?</p>
<p className="text-muted">Это действие нельзя отменить.</p>
</>
}
onConfirm={confirmDelete}
onClose={() => setShowDeleteModal(false)}
confirmLabel="Удалить"
variant="danger"
/>
{/* Модалка добавления платежа */}
<FormModal
show={showPaymentModal}
title={editingPayment ? "Редактировать платеж" : "Добавить платеж"}
onSubmit={(e) => {
e.preventDefault();
if (!paymentDraft.serverId || !paymentDraft.date || !paymentDraft.amount) {
setError('Заполните сервер, дату и сумму.');
setTimeout(() => setError(''), 3000);
return;
}
// Валидация даты (нельзя добавить платеж в будущем без подтверждения)
const selectedDate = new Date(paymentDraft.date);
const today = new Date();
today.setHours(0, 0, 0, 0);
selectedDate.setHours(0, 0, 0, 0);
if (selectedDate > today && !futureDateConfirm) {
if (!window.confirm('Внимание: Выбранная дата в будущем. Вы уверены, что хотите добавить платеж с будущей датой?')) {
return;
}
setFutureDateConfirm(true);
// Продолжаем выполнение после подтверждения
}
// Режим редактирования платежа
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);
setFutureDateConfirm(false);
setSuccess('Платеж добавлен');
setTimeout(() => setSuccess(''), 3000);
}}
onClose={() => {
setShowPaymentModal(false);
setEditingPayment(null);
setFutureDateConfirm(false);
}}
submitLabel={editingPayment ? "Сохранить" : "Добавить"}
submitIcon={editingPayment ? IconCheck : IconPlus}
>
<AddPaymentForm
servers={billingData}
payment={paymentDraft}
onChange={setPaymentDraft}
onDateChange={(date, requiresConfirm) => {
if (requiresConfirm) {
setFutureDateConfirm(false);
}
}}
/>
</FormModal>
{/* Модалка удаления платежа */}
<ConfirmModal
show={showDeletePaymentModal}
title="Удалить платеж"
message={
<>
<p>Вы уверены, что хотите удалить выбранный платеж?</p>
{paymentToDelete && (
<div className="alert alert-warning mt-3">
<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>
)}
</>
}
onConfirm={handleDeletePayment}
onClose={() => setShowDeletePaymentModal(false)}
confirmLabel="Удалить"
variant="danger"
/>
</div>
);
}
// Компоненты форм для модальных окон
function AddBillingForm({ item, onItemChange, servers }) {
const purposeOptions = [
{ value: '', label: 'Выберите назначение' },
{ value: 'relay', label: 'Relay VPS' },
{ value: 'bgp', label: 'BGP сервер' },
{ value: 'monitoring', label: 'Мониторинг' },
{ value: 'dns', label: 'DNS сервер' },
{ value: 'proxy', label: 'Прокси сервер' },
{ value: 'backup', label: 'Backup сервер' },
{ value: 'other', label: 'Другое' }
];
const statusOptions = [
{ value: 'active', label: 'Активен' },
{ value: 'inactive', label: 'Неактивен' }
];
const currencyOptions = [
{ value: 'USD', label: 'USD' },
{ value: 'EUR', label: 'EUR' },
{ value: 'RUB', label: 'RUB' }
];
const serverOptions = [
{ value: '', label: 'Без привязки' },
...(Array.isArray(servers) ? servers : []).map((s) => ({
value: s.id || s.ip,
label: `${s.ip}${s.dns ? ` (${s.dns})` : ''}${s.provider ? ` ${s.provider}` : ''}`,
})),
];
return (
<div className="row g-3">
{/* Основная информация */}
<div className="col-12">
<h6 className="text-muted mb-3">Основная информация</h6>
</div>
<div className="col-md-6">
<FormField
label="Имя хоста"
name="hostName"
value={item.hostName}
onChange={(value) => onItemChange({ ...item, hostName: value })}
required
/>
</div>
<div className="col-md-6">
<label className="form-label">Связанный сервер (опционально)</label>
<ServerAutocompleteInput
value={item.serverId || ''}
onChange={(value) => onItemChange({ ...item, serverId: value || '' })}
servers={servers}
placeholder="Поиск по IP, DNS, провайдеру..."
/>
<div className="form-hint mt-1">
Используется для связи с разделом «Серверы». Можно оставить пустым.
</div>
</div>
<div className="col-md-6">
<FormField
label="Назначение"
name="purpose"
type="select"
value={item.purpose}
onChange={(value) => onItemChange({ ...item, purpose: value })}
options={purposeOptions}
/>
</div>
<div className="col-md-6">
<FormField
label="Страна"
name="country"
value={item.country}
onChange={(value) => onItemChange({ ...item, country: value })}
required
/>
</div>
<div className="col-md-6">
<FormField
label="Провайдер"
name="provider"
value={item.provider}
onChange={(value) => onItemChange({ ...item, provider: value })}
required
/>
</div>
<div className="col-md-6">
<FormField
label="Ссылка для входа"
name="loginUrl"
value={item.loginUrl}
onChange={(value) => onItemChange({ ...item, loginUrl: value })}
placeholder="example.com"
/>
</div>
<div className="col-md-6">
<FormField
label="Статус"
name="status"
type="select"
value={item.status}
onChange={(value) => onItemChange({ ...item, status: value })}
options={statusOptions}
/>
</div>
{/* Текущие платежи */}
<div className="col-12">
<h6 className="text-muted mb-3 mt-4">Текущие платежи</h6>
</div>
<div className="col-md-6">
<FormField
label="Месячная стоимость"
name="monthlyCost"
type="number"
value={item.monthlyCost}
onChange={(value) => onItemChange({ ...item, monthlyCost: parseFloat(value) || 0 })}
step="0.01"
/>
</div>
<div className="col-md-6">
<FormField
label="Валюта месячной стоимости"
name="monthlyCostCurrency"
type="select"
value={item.monthlyCostCurrency}
onChange={(value) => onItemChange({ ...item, monthlyCostCurrency: value })}
options={currencyOptions}
/>
</div>
<div className="col-md-6">
<FormField
label="Дата следующего платежа"
name="nextPaymentDate"
type="date"
value={item.nextPaymentDate}
onChange={(value) => onItemChange({ ...item, nextPaymentDate: value })}
/>
</div>
{/* Дополнительно */}
<div className="col-md-12">
<FormField
label="Заметки"
name="notes"
value={item.notes}
onChange={(value) => onItemChange({ ...item, notes: value })}
placeholder="Дополнительная информация"
/>
</div>
</div>
);
}
function EditBillingForm({ item, onItemChange, servers }) {
const purposeOptions = [
{ value: '', label: 'Выберите назначение' },
{ value: 'relay', label: 'Relay VPS' },
{ value: 'bgp', label: 'BGP сервер' },
{ value: 'monitoring', label: 'Мониторинг' },
{ value: 'dns', label: 'DNS сервер' },
{ value: 'proxy', label: 'Прокси сервер' },
{ value: 'backup', label: 'Backup сервер' },
{ value: 'other', label: 'Другое' }
];
const statusOptions = [
{ value: 'active', label: 'Активен' },
{ value: 'inactive', label: 'Неактивен' }
];
const currencyOptions = [
{ value: 'USD', label: 'USD' },
{ value: 'EUR', label: 'EUR' },
{ value: 'RUB', label: 'RUB' }
];
const serverOptions = [
{ value: '', label: 'Без привязки' },
...(Array.isArray(servers) ? servers : []).map((s) => ({
value: s.id || s.ip,
label: `${s.ip}${s.dns ? ` (${s.dns})` : ''}${s.provider ? ` ${s.provider}` : ''}`,
})),
];
return (
<div className="row g-3">
{/* Основная информация */}
<div className="col-12">
<h6 className="text-muted mb-3">Основная информация</h6>
</div>
<div className="col-md-6">
<FormField
label="Имя хоста"
name="hostName"
value={item.hostName || ''}
onChange={(value) => onItemChange({ ...item, hostName: value })}
required
/>
</div>
<div className="col-md-6">
<label className="form-label">Связанный сервер (опционально)</label>
<ServerAutocompleteInput
value={item.serverId || ''}
onChange={(value) => onItemChange({ ...item, serverId: value || '' })}
servers={servers}
placeholder="Поиск по IP, DNS, провайдеру..."
/>
<div className="form-hint mt-1">
Используется для связи с разделом «Серверы». Можно оставить пустым.
</div>
</div>
<div className="col-md-6">
<FormField
label="Назначение"
name="purpose"
type="select"
value={item.purpose || ''}
onChange={(value) => onItemChange({ ...item, purpose: value })}
options={purposeOptions}
/>
</div>
<div className="col-md-6">
<FormField
label="Страна"
name="country"
value={item.country || ''}
onChange={(value) => onItemChange({ ...item, country: value })}
required
/>
</div>
<div className="col-md-6">
<FormField
label="Провайдер"
name="provider"
value={item.provider || ''}
onChange={(value) => onItemChange({ ...item, provider: value })}
required
/>
</div>
<div className="col-md-6">
<FormField
label="Ссылка для входа"
name="loginUrl"
value={item.loginUrl || ''}
onChange={(value) => onItemChange({ ...item, loginUrl: value })}
placeholder="example.com"
/>
</div>
<div className="col-md-6">
<FormField
label="Статус"
name="status"
type="select"
value={item.status || 'active'}
onChange={(value) => onItemChange({ ...item, status: value })}
options={statusOptions}
/>
</div>
{/* Текущие платежи */}
<div className="col-12">
<h6 className="text-muted mb-3 mt-4">Текущие платежи</h6>
</div>
<div className="col-md-6">
<FormField
label="Месячная стоимость"
name="monthlyCost"
type="number"
value={item.monthlyCost || 0}
onChange={(value) => onItemChange({ ...item, monthlyCost: parseFloat(value) || 0 })}
step="0.01"
/>
</div>
<div className="col-md-6">
<FormField
label="Валюта месячной стоимости"
name="monthlyCostCurrency"
type="select"
value={item.monthlyCostCurrency || 'USD'}
onChange={(value) => onItemChange({ ...item, monthlyCostCurrency: value })}
options={currencyOptions}
/>
</div>
<div className="col-md-6">
<FormField
label="Дата следующего платежа"
name="nextPaymentDate"
type="date"
value={item.nextPaymentDate || ''}
onChange={(value) => onItemChange({ ...item, nextPaymentDate: value })}
/>
</div>
{/* Дополнительно */}
<div className="col-md-12">
<FormField
label="Заметки"
name="notes"
value={item.notes || ''}
onChange={(value) => onItemChange({ ...item, notes: value })}
placeholder="Дополнительная информация"
/>
</div>
</div>
);
}
// Форма добавления платежа
function AddPaymentForm({ servers, payment, onChange, onDateChange }) {
const serverOptions = [
{ value: '', label: 'Выберите сервер' },
...servers.map(s => ({ value: s.id, label: `${s.hostName} (${s.provider})` }))
];
const currencyOptions = [
{ value: 'USD', label: 'USD' },
{ value: 'EUR', label: 'EUR' },
{ value: 'RUB', label: 'RUB' }
];
// Автозаполнение при выборе сервера
const handleServerChange = (serverId) => {
const server = servers.find(s => s.id === serverId);
if (server) {
// Используем последний платеж или месячную стоимость
const lastAmount = server.lastPaymentAmount || server.monthlyCost || 0;
const lastCurrency = server.lastPaymentCurrency || server.monthlyCostCurrency || 'USD';
onChange({
...payment,
serverId,
amount: lastAmount,
currency: lastCurrency
});
} else {
onChange({ ...payment, serverId });
}
};
// Валидация даты (нельзя добавить платеж в будущем без подтверждения)
const handleDateChange = (date) => {
if (date) {
const selectedDate = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
selectedDate.setHours(0, 0, 0, 0);
if (selectedDate > today) {
if (onDateChange) {
onDateChange(date, true); // true = требуется подтверждение
} else {
// Если нет обработчика, просто предупреждаем
alert('Внимание: Выбранная дата в будущем. Пожалуйста, подтвердите корректность даты.');
}
} else {
if (onDateChange) {
onDateChange(date, false);
}
}
}
onChange({ ...payment, date });
};
return (
<>
<FormField
label="Сервер"
name="serverId"
type="select"
value={payment.serverId}
onChange={handleServerChange}
options={serverOptions}
required
helpText={payment.serverId ? "Сумма и валюта заполнены автоматически из последнего платежа" : ""}
/>
<FormField
label="Дата"
name="date"
type="date"
value={payment.date}
onChange={handleDateChange}
required
helpText="Нельзя добавить платеж в будущем без подтверждения"
/>
<div className="row g-2">
<div className="col-8">
<FormField
label="Сумма"
name="amount"
type="number"
value={payment.amount}
onChange={(value) => onChange({ ...payment, amount: parseFloat(value) || 0 })}
step="0.01"
required
/>
</div>
<div className="col-4">
<FormField
label="Валюта"
name="currency"
type="select"
value={payment.currency}
onChange={(value) => onChange({ ...payment, currency: value })}
options={currencyOptions}
/>
</div>
</div>
<FormField
label="Комментарий"
name="note"
value={payment.note}
onChange={(value) => onChange({ ...payment, note: value })}
placeholder="Необязательно"
/>
</>
);
}
export default BillingManager;