diff --git a/backend/server.js b/backend/server.js index 3e193e0..e10050d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -343,8 +343,8 @@ app.post('/api/billing', async (req, res) => { // Validate each billing item has required fields for (let i = 0; i < billingData.length; i++) { const item = billingData[i]; - if (!item.hostName || !item.nodeName || !item.country || !item.provider) { - return res.status(400).send(`Billing item at index ${i} is missing required fields`); + if (!item.hostName || !item.country || !item.provider) { + return res.status(400).send(`Billing item at index ${i} is missing required fields: hostName, country, provider`); } } diff --git a/frontend/src/BillingManager.jsx b/frontend/src/BillingManager.jsx index 57e8e53..6cf6718 100644 --- a/frontend/src/BillingManager.jsx +++ b/frontend/src/BillingManager.jsx @@ -11,7 +11,13 @@ import { IconCalendar, IconCreditCard, IconCurrencyDollar, - IconExternalLink + IconExternalLink, + IconSearch, + IconFilter, + IconDownload, + IconUpload, + IconEye, + IconEyeOff } from '@tabler/icons-react'; const API_URL = '/api'; @@ -28,6 +34,7 @@ function BillingManager() { const [filterProvider, setFilterProvider] = useState(''); const [filterCountry, setFilterCountry] = useState(''); const [filterStatus, setFilterStatus] = useState(''); + const [showFilters, setShowFilters] = useState(false); const pageSize = 10; // Модальные окна @@ -70,7 +77,6 @@ function BillingManager() { const fetchExchangeRates = async () => { setRatesLoading(true); try { - // Используем бесплатный API для получения курсов валют const response = await axios.get('https://api.exchangerate-api.com/v4/latest/USD'); const rates = response.data.rates; @@ -81,7 +87,6 @@ function BillingManager() { }); } catch (error) { console.error('Ошибка при загрузке курсов валют:', error); - // Используем примерные курсы если API недоступен setExchangeRates({ USD: 1, EUR: 0.85, @@ -101,89 +106,7 @@ function BillingManager() { } catch (err) { console.error('Ошибка при загрузке данных биллинга:', err); setError('Ошибка при загрузке данных'); - // Загружаем тестовые данные если API недоступен - setBillingData([ - { - "id": "1", - "hostName": "VDSINA", - "purpose": "relay", - "country": "RU", - "provider": "VDSINA", - "loginUrl": "cp.vdsina.com", - "monthlyCost": 12.00, - "monthlyCostCurrency": "USD", - "nextPaymentDate": "2026-04-25", - "lastPaymentDate": "2025-05-05", - "lastPaymentAmount": 12.00, - "lastPaymentCurrency": "USD", - "status": "active", - "notes": "" - }, - { - "id": "2", - "hostName": "Macloud", - "purpose": "bgp", - "country": "RU", - "provider": "Macloud", - "loginUrl": "cp.macloud.ru", - "monthlyCost": 7.00, - "monthlyCostCurrency": "USD", - "nextPaymentDate": "2025-09-02", - "lastPaymentDate": "2025-02-03", - "lastPaymentAmount": 7.00, - "lastPaymentCurrency": "USD", - "status": "active", - "notes": "" - }, - { - "id": "3", - "hostName": "Hosting VDS", - "purpose": "monitoring", - "country": "RU", - "provider": "Hosting VDS", - "loginUrl": "my.hosting-vds.com/", - "monthlyCost": 11.95, - "monthlyCostCurrency": "USD", - "nextPaymentDate": "2025-12-21", - "lastPaymentDate": "2025-06-13", - "lastPaymentAmount": 10.00, - "lastPaymentCurrency": "USD", - "status": "active", - "notes": "" - }, - { - "id": "4", - "hostName": "Waicore", - "purpose": "dns", - "country": "DE", - "provider": "Waicore", - "loginUrl": "my.waicore.com/billmgr", - "monthlyCost": 9.60, - "monthlyCostCurrency": "USD", - "nextPaymentDate": "2026-04-25", - "lastPaymentDate": "2025-04-25", - "lastPaymentAmount": 9.60, - "lastPaymentCurrency": "USD", - "status": "active", - "notes": "" - }, - { - "id": "5", - "hostName": "IHOR", - "purpose": "proxy", - "country": "RU", - "provider": "IHOR", - "loginUrl": "billing.ihor-hosting.ru/billmgr", - "monthlyCost": 10.00, - "monthlyCostCurrency": "USD", - "nextPaymentDate": "2025-09-10", - "lastPaymentDate": "2025-06-25", - "lastPaymentAmount": 10.00, - "lastPaymentCurrency": "USD", - "status": "active", - "notes": "" - } - ]); + setBillingData([]); } finally { setLoading(false); } @@ -196,8 +119,9 @@ function BillingManager() { setSuccess('Данные успешно сохранены'); setTimeout(() => setSuccess(''), 3000); } catch (err) { - setError('Ошибка при сохранении данных'); - setTimeout(() => setError(''), 3000); + console.error('Ошибка при сохранении:', err); + setError(`Ошибка при сохранении данных: ${err.response?.data || err.message}`); + setTimeout(() => setError(''), 5000); } finally { setLoading(false); } @@ -206,14 +130,16 @@ function BillingManager() { const handleAddItem = () => { setNewItem({ hostName: '', - nodeName: '', + purpose: '', country: '', provider: '', loginUrl: '', monthlyCost: 0, + monthlyCostCurrency: 'USD', nextPaymentDate: '', lastPaymentDate: '', lastPaymentAmount: 0, + lastPaymentCurrency: 'USD', status: 'active', notes: '' }); @@ -239,17 +165,31 @@ function BillingManager() { }; const handleAddSubmit = () => { - const newId = Math.max(...billingData.map(item => parseInt(item.id)), 0) + 1; + if (!newItem.hostName || !newItem.country || !newItem.provider) { + setError('Пожалуйста, заполните обязательные поля: Host Name, Country, Provider'); + 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('Пожалуйста, заполните обязательные поля: Host Name, Country, Provider'); + return; + } + setBillingData(prev => prev.map(item => item.id === editingItem.id ? editingItem : item )); setShowEditModal(false); + setSuccess('Элемент успешно обновлен'); + setTimeout(() => setSuccess(''), 3000); }; const handleSort = (field) => { @@ -261,12 +201,24 @@ function BillingManager() { } }; + // Вычисляем общую сумму последних платежей + 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.nodeName.toLowerCase().includes(searchTerm.toLowerCase()) || - item.provider.toLowerCase().includes(searchTerm.toLowerCase()); + 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; @@ -275,8 +227,8 @@ function BillingManager() { return matchesSearch && matchesProvider && matchesCountry && matchesStatus; }) .sort((a, b) => { - const aValue = a[sortField]; - const bValue = b[sortField]; + const aValue = a[sortField] || ''; + const bValue = b[sortField] || ''; if (sortOrder === 'asc') { return aValue > bValue ? 1 : -1; @@ -292,23 +244,13 @@ function BillingManager() { const totalPages = Math.ceil(filteredData.length / pageSize); - // Статистика - const totalMonthlyCost = billingData.reduce((sum, item) => { - const currency = item.monthlyCostCurrency || 'USD'; - return sum + convertToRUB(item.monthlyCost, currency); - }, 0); - const totalLastPayments = billingData.reduce((sum, item) => { - const currency = item.lastPaymentCurrency || 'USD'; - return sum + convertToRUB(item.lastPaymentAmount, currency); - }, 0); - const activeProviders = [...new Set(billingData.map(item => item.provider))].length; - const activeNodes = billingData.filter(item => item.status === 'active').length; - - // Получение уникальных значений для фильтров - const providers = [...new Set(billingData.map(item => item.provider))]; - const countries = [...new Set(billingData.map(item => item.country))]; + // Получаем уникальные значения для фильтров + 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 countryToFlag(isoCode) { + if (!isoCode) return '🌍'; const codePoints = isoCode .toUpperCase() .split('') @@ -317,16 +259,16 @@ function BillingManager() { } function formatDate(dateString) { - if (!dateString) return '—'; - const date = new Date(dateString); - return date.toLocaleDateString('ru-RU', { - day: 'numeric', - month: 'long', - year: 'numeric' - }); + 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 @@ -334,8 +276,9 @@ function BillingManager() { } function convertToRUB(amount, currency) { + if (!amount) return 0; const rate = exchangeRates[currency] || 1; - return amount * rate; + return amount * rate * exchangeRates.RUB; } function formatCurrencyInRUB(amount, currency) { @@ -359,6 +302,30 @@ function BillingManager() { return purposeMap[purpose] || purpose || 'Не указано'; } + // Экспорт данных + 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 (
{/* Заголовок страницы */} @@ -369,27 +336,120 @@ function BillingManager() {
Панель управления / Ноды / Инфра-биллинг
-
- - -
+
+ + + + +
+ {/* Уведомления */} + {error && ( +
+ + {error} + +
+ )} + {success && ( +
+ + {success} + +
+ )} + + {/* Фильтры */} + {showFilters && ( +
+
+

Фильтры

+
+
+
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ )} + {/* Статистические карточки */}
@@ -455,274 +515,235 @@ function BillingManager() {
- {formatCurrencyInRUB(totalMonthlyCost, 'USD')} + {formatCurrencyInRUB(totalMonthlyCosts, 'USD')}
-
суммарных трат
+
месячные затраты
- {/* Уведомления */} - {error && ( -
- - {error} - + {/* Основная таблица */} +
+
+

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

+
+ +
- )} - {success && ( -
- - {success} - -
- )} - -
- {/* Левая панель - Оплачиваемые ноды */} -
-
-
-

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

-
- + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const page = i + 1; + return ( + + ); + })} +
-
-
- - - - - - - - - {billingData.map(item => ( - - - - - ))} - -
Имя хостераНазначение
-
- - {countryToFlag(item.country)} - - {item.hostName} -
-
-
- - {formatPurpose(item.purpose)} - - {formatPurpose(item.purpose)} -
-
-
-
-
- Показано {billingData.length} из {billingData.length} -
-
-
-
- - {/* Провайдеры */} -
-
-

Провайдеры

-
-
-
- - - - - - - - - - - {providers.map(provider => { - const providerItems = billingData.filter(item => item.provider === provider); - const totalCost = providerItems.reduce((sum, item) => { - const currency = item.monthlyCostCurrency || 'USD'; - return sum + convertToRUB(item.monthlyCost, currency); - }, 0); - const servers = providerItems.map(item => formatPurpose(item.purpose)).join(', '); - - return ( - - - - - - - ); - })} - -
Имя хостераСсылка для входаВсего, $Сервера
{provider} - - {providerItems[0]?.loginUrl} - - - {formatCurrencyInRUB(totalCost, 'USD')} -
- - {countryToFlag(providerItems[0]?.country || 'US')} - - {servers} -
-
-
-
-
- Σ {providers.length} провайдер(а) -
-
- Σ {formatCurrency(totalMonthlyCost)} -
-
-
-
-
- - {/* Правая панель - История платежей */} -
-
-
-

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

-
- -
-
-
-
- - - - - - - - - - - {billingData - .filter(item => item.lastPaymentDate) - .sort((a, b) => new Date(b.lastPaymentDate) - new Date(a.lastPaymentDate)) - .map(item => ( - - - - - - - ))} - -
Имя хостераДата оплатыОплачено, $
{item.hostName}{formatDate(item.lastPaymentDate)} -
-
{formatCurrency(item.lastPaymentAmount, item.lastPaymentCurrency || 'USD')}
- - {formatCurrencyInRUB(item.lastPaymentAmount, item.lastPaymentCurrency || 'USD')} - -
-
-
- - -
-
-
-
-
- - {/* Следующий платеж */} -
-
-

Следующий платеж

-
-
-
- - - - - - - - - - {billingData - .filter(item => item.nextPaymentDate) - .sort((a, b) => new Date(a.nextPaymentDate) - new Date(b.nextPaymentDate)) - .map(item => ( - - - - - - ))} - -
Имя хостераСледующий платеж
{item.hostName}{formatDate(item.nextPaymentDate)} -
- - -
-
-
-
-
+ )}
- {/* Модальное окно добавления */} + {/* Модальные окна */} setShowAddModal(false)} /> - {/* Модальное окно редактирования */} setShowEditModal(false)} /> - {/* Модальное окно удаления */} { - onItemChange({ ...item, [field]: value }); - }; - - const handleSubmit = (e) => { - e.preventDefault(); - onSubmit(); - }; - return ( -
+
-
Добавить ноду
+
Добавить новый элемент
-
-
-
-
- - handleChange('hostName', e.target.value)} - required - /> -
-
- - -
-
- - -
-
- - handleChange('provider', e.target.value)} - required - /> -
-
- - handleChange('loginUrl', e.target.value)} - placeholder="example.com" - /> -
-
- - handleChange('monthlyCost', parseFloat(e.target.value) || 0)} - required - /> -
-
- - -
-
- - handleChange('nextPaymentDate', e.target.value)} - required - /> -
-
- - handleChange('lastPaymentDate', e.target.value)} - /> -
-
- - handleChange('lastPaymentAmount', parseFloat(e.target.value) || 0)} - /> -
-
- - -
-
- - -
-
- -