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 (
{/* Заголовок страницы */} } /> {/* Уведомления */} {error && ( setError('')} onRetry={fetchBillingData} className="mb-4" /> )} {success && (
{success}
)} {/* Статистические карточки */}
{new Date().toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })}
Текущая дата
{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}
нод, ожидающих оплаты
{formatCurrencyInRUB(totalLastPayments, 'USD')}
всего платежей
{formatCurrencyInRUB(totalMonthlyCosts, 'USD')}
месячные затраты
{/* Основная таблица */}

Оплачиваемые ноды

{/* Фильтры */} {showFilters && (
setSearchTerm(e.target.value)} />
)}
{paginatedData.length === 0 ? ( ) : paginatedData.map(item => ( ))}
handleSort('hostName')}> Имя хостера {sortField === 'hostName' && ( {sortOrder === 'asc' ? '↑' : '↓'} )} handleSort('purpose')}> Назначение {sortField === 'purpose' && ( {sortOrder === 'asc' ? '↑' : '↓'} )} handleSort('country')}> Страна {sortField === 'country' && ( {sortOrder === 'asc' ? '↑' : '↓'} )} Связанный сервер handleSort('provider')}> Провайдер {sortField === 'provider' && ( {sortOrder === 'asc' ? '↑' : '↓'} )} handleSort('monthlyCost')}> Месячная стоимость {sortField === 'monthlyCost' && ( {sortOrder === 'asc' ? '↑' : '↓'} )} handleSort('nextPaymentDate')}> Следующий платеж {sortField === 'nextPaymentDate' && ( {sortOrder === 'asc' ? '↑' : '↓'} )} handleSort('status')}> Статус {sortField === 'status' && ( {sortOrder === 'asc' ? '↑' : '↓'} )} Действия
Ничего не найдено. Измените фильтры или параметры поиска.
{item.hostName}
{item.loginUrl && ( )}
{formatPurpose(item.purpose)}
{getCountryFlag(item.country)} {item.country}
{item.serverId ? ( (() => { const srv = serversById.get(item.serverId); if (!srv) { return Сервер не найден; } return (
{srv.ip}
{srv.dns || srv.provider || 'Без DNS'}
Открыть в разделе «Серверы»
); })() ) : ( Не привязан )}
{item.loginUrl && ( { e.currentTarget.style.display = 'none'; }} /> )} {item.provider}
{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
{formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
{formatDate(item.nextPaymentDate)}
{item.nextPaymentDate && (
{(() => { 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 Просрочено на {Math.abs(diffDays)} дн.; } else if (diffDays <= 30) { return Через {diffDays} дн.; } else { return Через {diffDays} дн.; } })()}
)}
{item.status === 'active' ? 'Активен' : 'Неактивен'}
{/* История платежей */}

История платежей

{/* Поиск и фильтры */}
{ setPaymentSearchTerm(e.target.value); setPaymentPage(1); }} aria-label="Поиск платежей" style={{ paddingRight: paymentSearchTerm ? '2.5rem' : undefined }} /> {paymentSearchTerm && ( )}
{/* Панель фильтров */} {showPaymentFilters && (
{ setPaymentFilterDateFrom(e.target.value); setPaymentPage(1); }} />
{ setPaymentFilterDateTo(e.target.value); setPaymentPage(1); }} />
{ setPaymentFilterAmountFrom(e.target.value); setPaymentPage(1); }} />
{ setPaymentFilterAmountTo(e.target.value); setPaymentPage(1); }} />
)} {/* Bulk Actions Bar */} { 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); }} /> {/* Таблица платежей */}
{(() => { // Получаем все платежи 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 ( ); } return ( <> {paginatedRows.map(r => ( ))} {totalPaymentPages > 1 && ( )} ); })()}
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()); } }} /> { if (paymentSortField === 'hostName') { setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc'); } else { setPaymentSortField('hostName'); setPaymentSortOrder('asc'); } }} >
Имя хостера {paymentSortField === 'hostName' && ( paymentSortOrder === 'asc' ? : )}
{ if (paymentSortField === 'date') { setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc'); } else { setPaymentSortField('date'); setPaymentSortOrder('desc'); } }} >
Дата {paymentSortField === 'date' && ( paymentSortOrder === 'asc' ? : )}
{ if (paymentSortField === 'amount') { setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc'); } else { setPaymentSortField('amount'); setPaymentSortOrder('desc'); } }} >
Сумма {paymentSortField === 'amount' && ( paymentSortOrder === 'asc' ? : )}
Валюта Комментарий Действия
Платежи не найдены
{allRows.length === 0 && billingData.every(i => !i.payments || i.payments.length === 0) && ( <>
Добавьте первый платеж, чтобы начать отслеживание
)}
{ const newSet = new Set(selectedPayments); if (e.target.checked) { newSet.add(r._key); } else { newSet.delete(r._key); } setSelectedPayments(newSet); }} />
{r.server.hostName}
{r.server.loginUrl && ( { e.currentTarget.style.display = 'none'; }} /> )} {r.server.provider}
{formatDate(r.date)}
{formatCurrency(r.amount, r.currency)}
{formatCurrencyInRUB(r.amount, r.currency)}
{r.currency || 'USD'} {r.note || ''}
{/* Модальные окна */} { e.preventDefault(); handleAddSubmit(); }} onClose={() => setShowAddModal(false)} submitLabel="Добавить" submitIcon={IconPlus} size="lg" > { e.preventDefault(); handleEditSubmit(); }} onClose={() => setShowEditModal(false)} submitLabel="Сохранить" submitIcon={IconCheck} size="lg" >

Вы уверены, что хотите удалить элемент {selectedItem?.hostName}?

Это действие нельзя отменить.

} onConfirm={confirmDelete} onClose={() => setShowDeleteModal(false)} confirmLabel="Удалить" variant="danger" /> {/* Модалка добавления платежа */} { 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} > { if (requiresConfirm) { setFutureDateConfirm(false); } }} /> {/* Модалка удаления платежа */}

Вы уверены, что хотите удалить выбранный платеж?

{paymentToDelete && (
Сервер: {paymentToDelete.data?.server?.hostName}
Дата: {formatDate(paymentToDelete.data?.date)}
Сумма: {formatCurrency(paymentToDelete.data?.amount, paymentToDelete.data?.currency)}
{paymentToDelete.data?.note && (
Комментарий: {paymentToDelete.data?.note}
)}
)} } onConfirm={handleDeletePayment} onClose={() => setShowDeletePaymentModal(false)} confirmLabel="Удалить" variant="danger" />
); } // Компоненты форм для модальных окон 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 (
{/* Основная информация */}
Основная информация
onItemChange({ ...item, hostName: value })} required />
onItemChange({ ...item, serverId: value || '' })} servers={servers} placeholder="Поиск по IP, DNS, провайдеру..." />
Используется для связи с разделом «Серверы». Можно оставить пустым.
onItemChange({ ...item, purpose: value })} options={purposeOptions} />
onItemChange({ ...item, country: value })} required />
onItemChange({ ...item, provider: value })} required />
onItemChange({ ...item, loginUrl: value })} placeholder="example.com" />
onItemChange({ ...item, status: value })} options={statusOptions} />
{/* Текущие платежи */}
Текущие платежи
onItemChange({ ...item, monthlyCost: parseFloat(value) || 0 })} step="0.01" />
onItemChange({ ...item, monthlyCostCurrency: value })} options={currencyOptions} />
onItemChange({ ...item, nextPaymentDate: value })} />
{/* Дополнительно */}
onItemChange({ ...item, notes: value })} placeholder="Дополнительная информация" />
); } 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 (
{/* Основная информация */}
Основная информация
onItemChange({ ...item, hostName: value })} required />
onItemChange({ ...item, serverId: value || '' })} servers={servers} placeholder="Поиск по IP, DNS, провайдеру..." />
Используется для связи с разделом «Серверы». Можно оставить пустым.
onItemChange({ ...item, purpose: value })} options={purposeOptions} />
onItemChange({ ...item, country: value })} required />
onItemChange({ ...item, provider: value })} required />
onItemChange({ ...item, loginUrl: value })} placeholder="example.com" />
onItemChange({ ...item, status: value })} options={statusOptions} />
{/* Текущие платежи */}
Текущие платежи
onItemChange({ ...item, monthlyCost: parseFloat(value) || 0 })} step="0.01" />
onItemChange({ ...item, monthlyCostCurrency: value })} options={currencyOptions} />
onItemChange({ ...item, nextPaymentDate: value })} />
{/* Дополнительно */}
onItemChange({ ...item, notes: value })} placeholder="Дополнительная информация" />
); } // Форма добавления платежа 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 ( <>
onChange({ ...payment, amount: parseFloat(value) || 0 })} step="0.01" required />
onChange({ ...payment, currency: value })} options={currencyOptions} />
onChange({ ...payment, note: value })} placeholder="Необязательно" /> ); } export default BillingManager;