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 (
+
+
+
+
+
Добавить ноду
+
+
+
+
+
+
+ );
+}
+
+// Модальное окно редактирования
+function EditBillingModal({ show, item, onItemChange, onSubmit, onClose }) {
+ if (!show) return null;
+
+ const handleChange = (field, value) => {
+ onItemChange({ ...item, [field]: value });
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ onSubmit();
+ };
+
+ return (
+
+
+
+
+
Редактировать ноду
+
+
+
+
+
+
+ );
+}
+
+// Модальное окно удаления
+function DeleteBillingModal({ show, item, onDelete, onClose }) {
+ if (!show) return null;
+
+ return (
+
+
+
+
+
Подтверждение удаления
+
+
+
+
Вы уверены, что хотите удалить ноду {item?.hostName}?
+
Это действие нельзя отменить.
+
+
+
+
+
+
+
+
+ );
+}
+
+export default BillingManager;
\ No newline at end of file