diff --git a/backend/server.js b/backend/server.js index 9861921..b42aef3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -295,6 +295,75 @@ app.post('/api/servers', async (req, res) => { } }); +// --- Billing Routes --- + +// Get billing data from S3 +app.get('/api/billing', async (req, res) => { + const params = { + Bucket: BUCKET_NAME, + Key: 'servers-billing.json', + }; + + try { + const data = await s3.getObject(params).promise(); + const fileContent = data.Body.toString('utf-8'); + let billingData = []; + + try { + billingData = JSON.parse(fileContent); + // Ensure it's an array + if (!Array.isArray(billingData)) { + billingData = []; + } + } catch (parseError) { + console.error('Error parsing servers-billing.json:', parseError); + billingData = []; + } + + res.json(billingData); + } catch (error) { + if (error.code === 'NoSuchKey') { + res.json([]); // Return empty array if file does not exist + } else { + console.error(error); + res.status(500).send('Error reading from S3'); + } + } +}); + +// Update billing data in S3 +app.post('/api/billing', async (req, res) => { + const { domains: billingData } = req.body; // Keep name 'domains' for consistency + + // Validate billing data structure + if (!Array.isArray(billingData)) { + return res.status(400).send('Billing data must be an array'); + } + + // 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`); + } + } + + const params = { + Bucket: BUCKET_NAME, + Key: 'servers-billing.json', + Body: JSON.stringify(billingData, null, 2), // Pretty print JSON + ContentType: 'application/json', + }; + + try { + await s3.putObject(params).promise(); + res.send('File updated successfully'); + } catch (error) { + console.error(error); + res.status(500).send('Error writing to S3'); + } +}); + // --- Filters Routes (JSON format) --- // Get filters from S3 diff --git a/example-servers-billing.json b/example-servers-billing.json new file mode 100644 index 0000000..fa84e66 --- /dev/null +++ b/example-servers-billing.json @@ -0,0 +1,72 @@ +[ + { + "id": "1", + "hostName": "VDSINA", + "nodeName": "VDSINA", + "country": "RU", + "provider": "VDSINA", + "loginUrl": "cp.vdsina.com", + "monthlyCost": 12.00, + "nextPaymentDate": "2026-04-25", + "lastPaymentDate": "2025-05-05", + "lastPaymentAmount": 12.00, + "status": "active", + "notes": "" + }, + { + "id": "2", + "hostName": "Macloud", + "nodeName": "Macloud", + "country": "RU", + "provider": "Macloud", + "loginUrl": "cp.macloud.ru", + "monthlyCost": 7.00, + "nextPaymentDate": "2025-09-02", + "lastPaymentDate": "2025-02-03", + "lastPaymentAmount": 7.00, + "status": "active", + "notes": "" + }, + { + "id": "3", + "hostName": "Hosting VDS", + "nodeName": "Steal", + "country": "RU", + "provider": "Hosting VDS", + "loginUrl": "my.hosting-vds.com/", + "monthlyCost": 11.95, + "nextPaymentDate": "2025-12-21", + "lastPaymentDate": "2025-06-13", + "lastPaymentAmount": 10.00, + "status": "active", + "notes": "" + }, + { + "id": "4", + "hostName": "Waicore", + "nodeName": "Waicore", + "country": "DE", + "provider": "Waicore", + "loginUrl": "my.waicore.com/billmgr", + "monthlyCost": 9.60, + "nextPaymentDate": "2026-04-25", + "lastPaymentDate": "2025-04-25", + "lastPaymentAmount": 9.60, + "status": "active", + "notes": "" + }, + { + "id": "5", + "hostName": "IHOR", + "nodeName": "Ihore", + "country": "RU", + "provider": "IHOR", + "loginUrl": "billing.ihor-hosting.ru/billmgr", + "monthlyCost": 10.00, + "nextPaymentDate": "2025-09-10", + "lastPaymentDate": "2025-06-25", + "lastPaymentAmount": 10.00, + "status": "active", + "notes": "" + } +] \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d523323..8051d89 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -27,6 +27,7 @@ import DomainsNewManager from './DomainsNewManager'; import IPRangesManager from './IPRangesManager'; import ASNsNewManager from './ASNsNewManager'; import AutoUrlManager from './AutoUrlManager'; +import BillingManager from './BillingManager'; import './App.css'; import axios from 'axios'; import { @@ -60,6 +61,7 @@ function MainLayout() { { id: 'asns', title: 'AS', icon: IconNetwork, path: '/asns' }, { id: 'auto-urls', title: 'Авто URL', icon: IconDownload, path: '/auto-urls' }, { id: 'servers', title: 'Серверы', icon: IconServer, path: '/servers' }, + { id: 'billing', title: 'Биллинг', icon: IconCreditCard, path: '/billing' }, { id: 'filters', title: 'Фильтры', icon: IconFilter, path: '/filters' }, { id: 'files', title: 'Файлы', icon: IconFileText, path: '/files' }, { id: 'cloud', title: 'Облако', icon: IconCloud, path: '/cloud' }, @@ -108,6 +110,7 @@ function MainLayout() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/BillingManager.jsx b/frontend/src/BillingManager.jsx new file mode 100644 index 0000000..f14b34f --- /dev/null +++ b/frontend/src/BillingManager.jsx @@ -0,0 +1,1025 @@ +import { useState, useEffect, useRef } from 'react'; +import axios from 'axios'; +import { + IconPlus, + IconSearch, + IconEdit, + IconTrash, + IconCheck, + IconX, + IconDatabase, + IconRefresh, + IconDownload, + IconUpload, + IconAlertTriangle, + IconServer, + IconChevronDown, + IconLink, + IconCalendar, + IconCreditCard, + IconDollarSign, + IconBuilding, + IconFlag, + IconExternalLink, + IconEye, + IconSettings +} from '@tabler/icons-react'; + +const API_URL = '/api'; + +function BillingManager() { + const [billingData, setBillingData] = 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 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 [newItem, setNewItem] = useState({ + hostName: '', + nodeName: '', + country: '', + provider: '', + loginUrl: '', + monthlyCost: 0, + nextPaymentDate: '', + lastPaymentDate: '', + lastPaymentAmount: 0, + status: 'active', + notes: '' + }); + + useEffect(() => { + fetchBillingData(); + }, []); + + const fetchBillingData = async () => { + setLoading(true); + try { + const response = await axios.get(`${API_URL}/billing`); + setBillingData(response.data); + setError(''); + } catch (err) { + console.error('Ошибка при загрузке данных биллинга:', err); + setError('Ошибка при загрузке данных'); + // Загружаем тестовые данные если API недоступен + setBillingData([ + { + "id": "1", + "hostName": "VDSINA", + "nodeName": "VDSINA", + "country": "RU", + "provider": "VDSINA", + "loginUrl": "cp.vdsina.com", + "monthlyCost": 12.00, + "nextPaymentDate": "2026-04-25", + "lastPaymentDate": "2025-05-05", + "lastPaymentAmount": 12.00, + "status": "active", + "notes": "" + }, + { + "id": "2", + "hostName": "Macloud", + "nodeName": "Macloud", + "country": "RU", + "provider": "Macloud", + "loginUrl": "cp.macloud.ru", + "monthlyCost": 7.00, + "nextPaymentDate": "2025-09-02", + "lastPaymentDate": "2025-02-03", + "lastPaymentAmount": 7.00, + "status": "active", + "notes": "" + }, + { + "id": "3", + "hostName": "Hosting VDS", + "nodeName": "Steal", + "country": "RU", + "provider": "Hosting VDS", + "loginUrl": "my.hosting-vds.com/", + "monthlyCost": 11.95, + "nextPaymentDate": "2025-12-21", + "lastPaymentDate": "2025-06-13", + "lastPaymentAmount": 10.00, + "status": "active", + "notes": "" + }, + { + "id": "4", + "hostName": "Waicore", + "nodeName": "Waicore", + "country": "DE", + "provider": "Waicore", + "loginUrl": "my.waicore.com/billmgr", + "monthlyCost": 9.60, + "nextPaymentDate": "2026-04-25", + "lastPaymentDate": "2025-04-25", + "lastPaymentAmount": 9.60, + "status": "active", + "notes": "" + }, + { + "id": "5", + "hostName": "IHOR", + "nodeName": "Ihore", + "country": "RU", + "provider": "IHOR", + "loginUrl": "billing.ihor-hosting.ru/billmgr", + "monthlyCost": 10.00, + "nextPaymentDate": "2025-09-10", + "lastPaymentDate": "2025-06-25", + "lastPaymentAmount": 10.00, + "status": "active", + "notes": "" + } + ]); + } finally { + setLoading(false); + } + }; + + const handleSaveChanges = async () => { + setLoading(true); + try { + await axios.post(`${API_URL}/billing`, { domains: billingData }); + setSuccess('Данные успешно сохранены'); + setTimeout(() => setSuccess(''), 3000); + } catch (err) { + setError('Ошибка при сохранении данных'); + setTimeout(() => setError(''), 3000); + } finally { + setLoading(false); + } + }; + + const handleAddItem = () => { + setNewItem({ + hostName: '', + nodeName: '', + country: '', + provider: '', + loginUrl: '', + monthlyCost: 0, + nextPaymentDate: '', + lastPaymentDate: '', + lastPaymentAmount: 0, + status: 'active', + notes: '' + }); + 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 = () => { + const newId = Math.max(...billingData.map(item => parseInt(item.id)), 0) + 1; + const itemToAdd = { ...newItem, id: newId.toString() }; + setBillingData(prev => [...prev, itemToAdd]); + setShowAddModal(false); + }; + + const handleEditSubmit = () => { + setBillingData(prev => prev.map(item => + item.id === editingItem.id ? editingItem : item + )); + setShowEditModal(false); + }; + + const handleSort = (field) => { + if (sortField === field) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortOrder('asc'); + } + }; + + 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()); + + 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 totalMonthlyCost = billingData.reduce((sum, item) => sum + item.monthlyCost, 0); + const totalLastPayments = billingData.reduce((sum, item) => sum + item.lastPaymentAmount, 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))]; + + function countryToFlag(isoCode) { + const codePoints = isoCode + .toUpperCase() + .split('') + .map(char => 127397 + char.charCodeAt()); + return String.fromCodePoint(...codePoints); + } + + function formatDate(dateString) { + if (!dateString) return '—'; + const date = new Date(dateString); + return date.toLocaleDateString('ru-RU', { + day: 'numeric', + month: 'long', + year: 'numeric' + }); + } + + function formatCurrency(amount) { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(amount); + } + + return ( +
+ {/* Заголовок страницы */} +
+
+
+

Инфра-биллинг

+
Панель управления / Ноды / Инфра-биллинг
+
+
+
+ + +
+
+
+
+ + {/* Статистические карточки */} +
+
+
+
+ + + +
+
+ {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} +
+
нод, ожидающих оплаты
+
+
+
+
+
+
+
+ + + +
+
+ {formatCurrency(totalLastPayments)} +
+
всего платежей
+
+
+
+
+
+
+
+ + + +
+
+ {formatCurrency(totalMonthlyCost)} +
+
суммарных трат
+
+
+
+
+
+ + {/* Уведомления */} + {error && ( +
+ + {error} + +
+ )} + {success && ( +
+ + {success} + +
+ )} + +
+ {/* Левая панель - Оплачиваемые ноды */} +
+
+
+

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

+
+ +
+
+
+
+ + + + + + + + + {billingData.map(item => ( + + + + + ))} + +
Имя хостераНода
+
+ + {countryToFlag(item.country)} + + {item.hostName} +
+
+
+ + {countryToFlag(item.country)} + + {item.nodeName} +
+
+
+
+
+ Показано {billingData.length} из {billingData.length} +
+
+
+
+ + {/* Провайдеры */} +
+
+

Провайдеры

+
+
+
+ + + + + + + + + + + {providers.map(provider => { + const providerItems = billingData.filter(item => item.provider === provider); + const totalCost = providerItems.reduce((sum, item) => sum + item.monthlyCost, 0); + const servers = providerItems.map(item => item.nodeName).join(', '); + + return ( + + + + + + + ); + })} + +
Имя хостераСсылка для входаВсего, $Сервера
{provider} + + {providerItems[0]?.loginUrl} + + + {formatCurrency(totalCost)} +
+ + {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)} +
+ + +
+
+
+
+
+ + {/* Следующий платеж */} +
+
+

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

+
+
+
+ + + + + + + + + + {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)} + /> + + {/* Модальное окно удаления */} + setShowDeleteModal(false)} + /> +
+ ); +} + +// Модальное окно добавления +function AddBillingModal({ show, item, onItemChange, onSubmit, onClose }) { + if (!show) return null; + + const handleChange = (field, value) => { + onItemChange({ ...item, [field]: value }); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + onSubmit(); + }; + + return ( +
+
+
+
+
Добавить ноду
+ +
+
+
+
+
+ + handleChange('hostName', e.target.value)} + required + /> +
+
+ + handleChange('nodeName', 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)} + /> +
+
+ + +
+
+ +