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 (
| Имя хостера | -Ссылка для входа | -Всего, $ | -Сервера | -
|---|---|---|---|
| {provider} | -
-
- {providerItems[0]?.loginUrl}
- |
- {formatCurrencyInRUB(totalCost, 'USD')} | -
-
-
- {countryToFlag(providerItems[0]?.country || 'US')}
-
- {servers}
-
- |
-
| Имя хостера | -Дата оплаты | -Оплачено, $ | -- |
|---|---|---|---|
| {item.hostName} | -{formatDate(item.lastPaymentDate)} | -
-
-
- {formatCurrency(item.lastPaymentAmount, item.lastPaymentCurrency || 'USD')}
-
- {formatCurrencyInRUB(item.lastPaymentAmount, item.lastPaymentCurrency || 'USD')}
-
- |
-
-
-
- |
-
| Имя хостера | -Следующий платеж | -- |
|---|---|---|
| {item.hostName} | -{formatDate(item.nextPaymentDate)} | -
-
-
- |
-
Вы уверены, что хотите удалить ноду {item?.hostName}?
+Вы уверены, что хотите удалить элемент {item?.hostName}?
Это действие нельзя отменить.