From d60eb96d0008058d7cbc88ef0e5b8f1cd57419db Mon Sep 17 00:00:00 2001 From: shats Date: Sat, 6 Dec 2025 02:48:18 +0700 Subject: [PATCH] feat: Revamp BillingManager with enhanced filtering, sorting, and payment management features, including urgency indicators and improved UI components for better user experience. --- frontend/src/BillingManager.jsx | 2025 ++++++++++--------------------- 1 file changed, 614 insertions(+), 1411 deletions(-) diff --git a/frontend/src/BillingManager.jsx b/frontend/src/BillingManager.jsx index 278515e..3062103 100644 --- a/frontend/src/BillingManager.jsx +++ b/frontend/src/BillingManager.jsx @@ -32,7 +32,9 @@ import { IconChevronUp, IconChevronDown, IconX, - IconChecks + IconChecks, + IconServer, + IconClock } from '@tabler/icons-react'; import BulkActionsBar from './components/BulkActionsBar.jsx'; @@ -44,13 +46,11 @@ function BillingManager() { const [success, setSuccess] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [currentPage, setCurrentPage] = useState(1); - const [sortField, setSortField] = useState('hostName'); + const [sortField, setSortField] = useState('nextPaymentDate'); 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 [filterUrgency, setFilterUrgency] = useState(''); + const pageSize = 15; // Модальные окна const [showAddModal, setShowAddModal] = useState(false); @@ -59,7 +59,7 @@ function BillingManager() { const [selectedItem, setSelectedItem] = useState(null); const [editingItem, setEditingItem] = useState({}); - // Модалка добавления платежа, привязанного к серверу + // Модалка добавления платежа const [showPaymentModal, setShowPaymentModal] = useState(false); const [paymentDraft, setPaymentDraft] = useState({ serverId: '', @@ -68,26 +68,20 @@ function BillingManager() { currency: 'USD', note: '' }); - const [editingPayment, setEditingPayment] = useState(null); // { serverId, index } + const [editingPayment, setEditingPayment] = useState(null); const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false); - const [paymentToDelete, setPaymentToDelete] = useState(null); // { serverId, index, data } - const [futureDateConfirm, setFutureDateConfirm] = useState(false); // Подтверждение будущей даты + const [paymentToDelete, setPaymentToDelete] = useState(null); + 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 paymentPageSize = 10; - // Состояние для курсов валют + // Курсы валют const [exchangeRates, setExchangeRates] = useState({ USD: 1, EUR: 1, @@ -95,7 +89,7 @@ function BillingManager() { }); const [ratesLoading, setRatesLoading] = useState(false); - // Новый элемент для добавления + // Новый элемент const [newItem, setNewItem] = useState({ hostName: '', purpose: '', @@ -124,7 +118,6 @@ function BillingManager() { 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, @@ -132,11 +125,7 @@ function BillingManager() { }); } catch (error) { console.error('Ошибка при загрузке курсов валют:', error); - setExchangeRates({ - USD: 1, - EUR: 0.85, - RUB: 95 - }); + setExchangeRates({ USD: 1, EUR: 0.85, RUB: 95 }); } finally { setRatesLoading(false); } @@ -147,8 +136,7 @@ function BillingManager() { const res = await api.get(`/servers`); setServers(Array.isArray(res.data) ? res.data : []); } catch (error) { - console.error('Ошибка при загрузке серверов для привязки биллинга:', error); - // связь необязательная, поэтому не показываем отдельную ошибку пользователю + console.error('Ошибка при загрузке серверов:', error); } }; @@ -192,6 +180,135 @@ function BillingManager() { } }; + // Карта серверов для быстрой привязки + const serversById = new Map( + (servers || []).filter(s => s && s.id).map(s => [s.id, s]) + ); + + // Конвертация в рубли + const convertToRUB = (amount, currency) => { + if (!amount) return 0; + 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; + return (amount / ccyPerUsd) * rubPerUsd; + }; + + // Статистика + const totalMonthlyCosts = billingData.reduce((sum, item) => { + return sum + convertToRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD'); + }, 0); + + const urgentPayments = billingData.filter(item => { + const nextPayment = new Date(item.nextPaymentDate); + const now = new Date(); + const diffDays = Math.ceil((nextPayment - now) / (1000 * 60 * 60 * 24)); + return diffDays <= 7 && diffDays >= 0; + }); + + const overduePayments = billingData.filter(item => { + const nextPayment = new Date(item.nextPaymentDate); + const now = new Date(); + return nextPayment < now; + }); + + // Расчет дней до платежа + const getDaysUntilPayment = (dateString) => { + if (!dateString) return null; + const nextPayment = new Date(dateString); + const now = new Date(); + return Math.ceil((nextPayment - now) / (1000 * 60 * 60 * 24)); + }; + + // Форматирование + function formatDate(dateString) { + if (!dateString) return '—'; + try { + return new Date(dateString).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }); + } catch { + return dateString; + } + } + + function formatCurrency(amount, currency = 'USD') { + if (!amount) return '—'; + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: currency, + maximumFractionDigits: 0 + }).format(amount); + } + + function formatCurrencyInRUB(amount, currency) { + const rubAmount = convertToRUB(amount, currency); + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB', + maximumFractionDigits: 0 + }).format(rubAmount); + } + + // Фильтрация и сортировка + const filteredData = billingData + .filter(item => { + const matchesSearch = + item.hostName?.toLowerCase().includes(searchTerm.toLowerCase()) || + item.provider?.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesStatus = !filterStatus || item.status === filterStatus; + + let matchesUrgency = true; + if (filterUrgency === 'overdue') { + const days = getDaysUntilPayment(item.nextPaymentDate); + matchesUrgency = days !== null && days < 0; + } else if (filterUrgency === 'urgent') { + const days = getDaysUntilPayment(item.nextPaymentDate); + matchesUrgency = days !== null && days >= 0 && days <= 7; + } else if (filterUrgency === 'soon') { + const days = getDaysUntilPayment(item.nextPaymentDate); + matchesUrgency = days !== null && days > 7 && days <= 30; + } + + return matchesSearch && matchesStatus && matchesUrgency; + }) + .sort((a, b) => { + let aValue = a[sortField]; + let bValue = b[sortField]; + + if (sortField === 'nextPaymentDate') { + aValue = new Date(aValue || '9999-12-31'); + bValue = new Date(bValue || '9999-12-31'); + } else if (sortField === 'monthlyCost') { + aValue = convertToRUB(a.monthlyCost, a.monthlyCostCurrency); + bValue = convertToRUB(b.monthlyCost, b.monthlyCostCurrency); + } + + 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 handleSort = (field) => { + if (sortField === field) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortOrder('asc'); + } + }; + + // CRUD операции const handleAddItem = () => { setNewItem({ hostName: '', @@ -231,22 +348,21 @@ function BillingManager() { }; const handleAddSubmit = () => { - if (!newItem.hostName || !newItem.country || !newItem.provider) { - setError('Пожалуйста, заполните обязательные поля: Имя хоста, Страна, Провайдер'); + if (!newItem.hostName || !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]); + setBillingData(prev => [...prev, { ...newItem, id: newId.toString() }]); setShowAddModal(false); - setSuccess('Элемент успешно добавлен'); + setSuccess('Элемент добавлен'); setTimeout(() => setSuccess(''), 3000); }; const handleEditSubmit = () => { - if (!editingItem.hostName || !editingItem.country || !editingItem.provider) { - setError('Пожалуйста, заполните обязательные поля: Имя хоста, Страна, Провайдер'); + if (!editingItem.hostName || !editingItem.provider) { + setError('Заполните обязательные поля'); return; } @@ -254,191 +370,25 @@ function BillingManager() { item.id === editingItem.id ? editingItem : item )); setShowEditModal(false); - setSuccess('Элемент успешно обновлен'); + 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 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; - }; - - // Вычисляем общую сумму последних платежей в рублях - const totalLastPayments = billingData.reduce((sum, item) => { - const amount = item.lastPaymentAmount || 0; - const currency = item.lastPaymentCurrency || 'USD'; - return sum + convertToRUB(amount, currency); - }, 0); - - // Вычисляем общую сумму месячных затрат в рублях - const totalMonthlyCosts = billingData.reduce((sum, item) => { - const amount = item.monthlyCost || 0; - const currency = item.monthlyCostCurrency || 'USD'; - return sum + convertToRUB(amount, currency); - }, 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); - } - - // convertToRUB уже определена выше, используем её - - 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; - } + if (!paymentToDelete) 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]; + const payments = [...(s.payments || [])]; + 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' + lastPaymentCurrency: last?.currency || 'USD' }; })); setShowDeletePaymentModal(false); @@ -447,66 +397,15 @@ function BillingManager() { 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'], + ['Host', 'Provider', 'Monthly Cost', 'Currency', 'Next Payment', 'Status'], ...filteredData.map(item => [ item.hostName, - item.purpose, - item.country, item.provider, item.monthlyCost, + item.monthlyCostCurrency, item.nextPaymentDate, item.status ]) @@ -516,17 +415,50 @@ function BillingManager() { 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.download = `billing-${new Date().toISOString().split('T')[0]}.csv`; a.click(); window.URL.revokeObjectURL(url); }; + // Компонент бейджа срочности + const UrgencyBadge = ({ days }) => { + if (days === null) return ; + + if (days < 0) { + return ( + + Просрочено {Math.abs(days)} дн. + + ); + } else if (days === 0) { + return Сегодня!; + } else if (days <= 7) { + return ( + + {days} дн. + + ); + } else if (days <= 30) { + return ( + + {days} дн. + + ); + } else { + return ( + + {days} дн. + + ); + } + }; + return (
- {/* Заголовок страницы */} + {/* Заголовок */} - + {success}
)} - {/* Статистические карточки */} + {/* Компактная статистика */}
-
-
-
- - - -
-
- {new Date().toLocaleDateString('ru-RU', { - day: 'numeric', - month: 'long', - year: 'numeric' - })} +
+
+
+
+ + + +
+
+ {formatCurrencyInRUB(totalMonthlyCosts, 'RUB')} +
+
в месяц
-
Текущая дата
-
-
-
- - - -
-
- {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} +
+
+
+
+ 0 ? 'bg-danger-lt text-danger' : 'bg-success-lt text-success'} border-0`}> + + +
+
+ {overduePayments.length} +
+
просрочено
-
нод, ожидающих оплаты
-
-
-
- - - -
-
- {new Intl.NumberFormat('ru-RU', { - style: 'currency', - currency: 'RUB' - }).format(totalLastPayments)} +
+
+
+
+ 0 ? 'bg-warning-lt text-warning' : 'bg-blue-lt text-blue'} border-0`}> + + +
+
+ {urgentPayments.length} +
+
на этой неделе
-
всего платежей
-
-
-
-
-
-
-
- - - -
-
- {new Intl.NumberFormat('ru-RU', { - style: 'currency', - currency: 'RUB' - }).format(totalMonthlyCosts)} -
-
месячные затраты
@@ -640,260 +547,161 @@ function BillingManager() { {/* Основная таблица */}
-

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

-
-
+
+

+ Подписки + {filteredData.length} +

+ + {/* Поиск */} +
+ + + + { setSearchTerm(e.target.value); setCurrentPage(1); }} + /> +
+ + {/* Быстрые фильтры */} +
+ +
+ +
-
- {/* Фильтры */} - {showFilters && ( -
-
-
-
- - setSearchTerm(e.target.value)} - /> -
-
- - -
-
- - -
-
- - -
-
-
-
- )} -
- - + +
+
+ + + + + + + + + + + + {paginatedData.length === 0 ? ( - - - - - - - - - + - - - {paginatedData.length === 0 ? ( - - - - ) : paginatedData.map(item => ( + ) : paginatedData.map(item => { + const days = getDaysUntilPayment(item.nextPaymentDate); + const linkedServer = item.serverId ? serversById.get(item.serverId) : null; + + return ( - - - - - - - + + - - ))} - -
handleSort('hostName')} + style={{ minWidth: '200px' }} + > + Сервис + {sortField === 'hostName' && ( + {sortOrder === 'asc' ? '↑' : '↓'} + )} + handleSort('monthlyCost')} + style={{ width: '140px' }} + > + Стоимость + {sortField === 'monthlyCost' && ( + {sortOrder === 'asc' ? '↑' : '↓'} + )} + handleSort('nextPaymentDate')} + style={{ width: '120px' }} + > + Платёж + {sortField === 'nextPaymentDate' && ( + {sortOrder === 'asc' ? '↑' : '↓'} + )} + СрокСтатус
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' ? '↑' : '↓'} - )} - Действия + {searchTerm || filterUrgency ? 'Ничего не найдено' : 'Нет данных'} +
Ничего не найдено. Измените фильтры или параметры поиска.
- - {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 && ( + {item.monthlyCostCurrency !== 'RUB' && (
- {(() => { - 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} дн.; - } - })()} + ≈ {formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
)}
- - {item.status === 'active' ? 'Активен' : 'Неактивен'} + + {formatDate(item.nextPaymentDate)} + + + + + {item.status === 'active' ? '●' : '○'} -
+
- +
-
+ + ); + })} + +
- + + {totalPages > 1 && ( + + )}
- {/* История платежей */} + {/* История платежей — компактная версия */}
-

- +

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

-
- - -
+

-
- {/* Поиск и фильтры */} -
-
- - - - { 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, @@ -1228,187 +798,70 @@ function BillingManager() { 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; - }); + // Сортировка по дате (новые сверху) + allRows.sort((a, b) => new Date(b.date) - new Date(a.date)); // Пагинация - const totalPaymentPages = Math.max(1, Math.ceil(allRows.length / paymentPageSize)); - const paginatedRows = allRows.slice( - (paymentPage - 1) * paymentPageSize, - paymentPage * paymentPageSize - ); + const paginatedRows = allRows.slice(0, paymentPageSize); if (paginatedRows.length === 0) { return ( - ); } - return ( - <> - {paginatedRows.map(r => ( - - - - - - - - - - ))} - {totalPaymentPages > 1 && ( - - - - )} - - ); + return paginatedRows.map(r => ( + + + + + + + + )); })()}
- 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 || ''} -
- - -
-
- -
+ {r.server.hostName} + {r.server.provider} + {formatDate(r.date)} + {formatCurrency(r.amount, r.currency)} + {r.note || '—'} +
+ + +
+
@@ -1419,17 +872,14 @@ function BillingManager() { {/* Модальные окна */} { - e.preventDefault(); - handleAddSubmit(); - }} + title="Добавить подписку" + onSubmit={(e) => { e.preventDefault(); handleAddSubmit(); }} onClose={() => setShowAddModal(false)} submitLabel="Добавить" submitIcon={IconPlus} - size="lg" + size="md" > - { - e.preventDefault(); - handleEditSubmit(); - }} + title="Редактировать подписку" + onSubmit={(e) => { e.preventDefault(); handleEditSubmit(); }} onClose={() => setShowEditModal(false)} submitLabel="Сохранить" submitIcon={IconCheck} - size="lg" + size="md" > - -

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

-

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

+ Удалить {selectedItem?.hostName}? +
Это действие нельзя отменить
} onConfirm={confirmDelete} @@ -1469,116 +916,71 @@ function BillingManager() { confirmLabel="Удалить" variant="danger" /> - {/* Модалка добавления платежа */} + + {/* Модалка платежа */} { e.preventDefault(); if (!paymentDraft.serverId || !paymentDraft.date || !paymentDraft.amount) { - setError('Заполните сервер, дату и сумму.'); + 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) { + if (editingPayment) { 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' + const payments = [...(s.payments || [])]; + payments[editingPayment.index] = { + 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 || 'USD' }; })); setEditingPayment(null); - setShowPaymentModal(false); - setSuccess('Платеж обновлен'); - setTimeout(() => setSuccess(''), 3000); - return; + } else { + setBillingData(prev => prev.map(s => { + if (s.id !== paymentDraft.serverId) return s; + const payments = [...(s.payments || []), { + 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 || 'USD' }; + })); } - // Режим добавления платежа - 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('Платеж добавлен'); + setSuccess(editingPayment ? 'Платеж обновлен' : 'Платеж записан'); setTimeout(() => setSuccess(''), 3000); }} - onClose={() => { - setShowPaymentModal(false); - setEditingPayment(null); - setFutureDateConfirm(false); - }} - submitLabel={editingPayment ? "Сохранить" : "Добавить"} + onClose={() => { setShowPaymentModal(false); setEditingPayment(null); }} + 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}
- )} -
- )} - + paymentToDelete && ( + <> + Удалить платеж от {formatDate(paymentToDelete.data?.date)} на сумму{' '} + {formatCurrency(paymentToDelete.data?.amount, paymentToDelete.data?.currency)}? + + ) } onConfirm={handleDeletePayment} onClose={() => setShowDeletePaymentModal(false)} @@ -1589,88 +991,93 @@ function BillingManager() { ); } -// Компоненты форм для модальных окон -function AddBillingForm({ item, onItemChange, servers }) { +// Компактная форма для добавления/редактирования подписки +function BillingForm({ item, onItemChange, servers }) { 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 })} + placeholder="Например: Hetzner VPS #1" required />
-
- - onItemChange({ ...item, serverId: value || '' })} - servers={servers} - placeholder="Поиск по IP, DNS, провайдеру..." - /> -
- Используется для связи с разделом «Серверы». Можно оставить пустым. -
-
-
- - onItemChange({ ...item, purpose: value })} - /> -
-
- onItemChange({ ...item, country: value })} - required - /> -
+
onItemChange({ ...item, provider: value })} + placeholder="Hetzner, AWS, etc." required />
+ +
+ + onItemChange({ ...item, serverId: value || '' })} + servers={servers} + placeholder="Опционально" + /> +
+ +
+ onItemChange({ ...item, monthlyCost: parseFloat(value) || 0 })} + step="0.01" + /> +
+ +
+ onItemChange({ ...item, monthlyCostCurrency: value })} + options={currencyOptions} + /> +
+ +
+ onItemChange({ ...item, nextPaymentDate: value })} + /> +
+
onItemChange({ ...item, loginUrl: value })} - placeholder="example.com" + placeholder="panel.hetzner.com" />
-
-
- -
-
+ +
+
Статус
+
- {/* Текущие платежи */}
-
Текущие платежи
-
-
- 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 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 })} - /> -
-
- onItemChange({ ...item, country: value })} - required - /> -
-
- onItemChange({ ...item, provider: value })} - required - /> -
-
- onItemChange({ ...item, loginUrl: value })} - placeholder="example.com" - /> -
-
-
- -
-
- - -
-
- - {/* Текущие платежи */} -
-
Текущие платежи
-
-
- onItemChange({ ...item, monthlyCost: parseFloat(value) || 0 })} - step="0.01" - /> -
-
- onItemChange({ ...item, monthlyCostCurrency: value })} - options={currencyOptions} - /> -
-
- onItemChange({ ...item, nextPaymentDate: value })} - /> -
- - {/* Дополнительно */} -
- onItemChange({ ...item, notes: value })} - placeholder="Дополнительная информация" + placeholder="Опционально" />
@@ -1883,9 +1109,9 @@ function EditBillingForm({ item, onItemChange, servers }) { } // Форма добавления платежа -function AddPaymentForm({ servers, payment, onChange, onDateChange }) { +function PaymentForm({ servers, payment, onChange }) { const serverOptions = [ - { value: '', label: 'Выберите сервер' }, + { value: '', label: 'Выберите сервис' }, ...servers.map(s => ({ value: s.id, label: `${s.hostName} (${s.provider})` })) ]; @@ -1895,102 +1121,79 @@ function AddPaymentForm({ servers, payment, onChange, onDateChange }) { { 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 + amount: server.monthlyCost || 0, + currency: server.monthlyCostCurrency || 'USD' }); } 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="Необязательно" - /> - + +
+ onChange({ ...payment, date: value })} + required + /> +
+ +
+ onChange({ ...payment, amount: parseFloat(value) || 0 })} + step="0.01" + required + /> +
+ +
+ onChange({ ...payment, currency: value })} + options={currencyOptions} + /> +
+ +
+ onChange({ ...payment, note: value })} + placeholder="Опционально" + /> +
+
); } -export default BillingManager; \ No newline at end of file +export default BillingManager;