import { useState, useEffect, useRef } from 'react'; import api from './lib/api.js'; import axios from 'axios'; import FormModal from './components/FormModal.jsx'; import ConfirmModal from './components/ConfirmModal.jsx'; import Pagination from './components/Pagination.jsx'; import PageHeader from './components/PageHeader.jsx'; import PageHeaderActions from './components/PageHeaderActions.jsx'; import FormField from './components/FormField.jsx'; import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx'; import ErrorAlert from './components/ErrorAlert.jsx'; import { IconPlus, IconEdit, IconTrash, IconCheck, IconDatabase, IconRefresh, IconAlertTriangle, IconCalendar, IconCreditCard, IconCurrencyDollar, IconExternalLink, IconSearch, IconFilter, IconDownload, IconUpload, IconEye, IconEyeOff, IconHistory, IconChevronUp, IconChevronDown, IconX, IconChecks } from '@tabler/icons-react'; import BulkActionsBar from './components/BulkActionsBar.jsx'; function BillingManager() { const [billingData, setBillingData] = useState([]); const [servers, setServers] = 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 [showFilters, setShowFilters] = useState(false); 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 [showPaymentModal, setShowPaymentModal] = useState(false); const [paymentDraft, setPaymentDraft] = useState({ serverId: '', date: '', amount: 0, currency: 'USD', note: '' }); const [editingPayment, setEditingPayment] = useState(null); // { serverId, index } const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false); const [paymentToDelete, setPaymentToDelete] = useState(null); // { serverId, index, data } 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 [exchangeRates, setExchangeRates] = useState({ USD: 1, EUR: 1, RUB: 1 }); const [ratesLoading, setRatesLoading] = useState(false); // Новый элемент для добавления const [newItem, setNewItem] = useState({ hostName: '', purpose: '', country: '', provider: '', loginUrl: '', monthlyCost: 0, monthlyCostCurrency: 'USD', nextPaymentDate: '', lastPaymentDate: '', lastPaymentAmount: 0, lastPaymentCurrency: 'USD', status: 'active', notes: '', serverId: '', }); useEffect(() => { fetchBillingData(); fetchExchangeRates(); fetchServers(); }, []); const fetchExchangeRates = async () => { setRatesLoading(true); 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, RUB: rates.RUB || 1 }); } catch (error) { console.error('Ошибка при загрузке курсов валют:', error); setExchangeRates({ USD: 1, EUR: 0.85, RUB: 95 }); } finally { setRatesLoading(false); } }; const fetchServers = async () => { try { const res = await api.get(`/servers`); setServers(Array.isArray(res.data) ? res.data : []); } catch (error) { console.error('Ошибка при загрузке серверов для привязки биллинга:', error); // связь необязательная, поэтому не показываем отдельную ошибку пользователю } }; const fetchBillingData = async () => { setLoading(true); try { const response = await api.get(`/billing`); const normalized = (response.data || []).map((item, idx) => ({ id: item.id ?? String(idx + 1), payments: Array.isArray(item.payments) ? item.payments : (item.lastPaymentDate && item.lastPaymentAmount ? [{ date: item.lastPaymentDate, amount: item.lastPaymentAmount, currency: item.lastPaymentCurrency || 'USD', note: 'Импортировано' }] : [] ), ...item, })); setBillingData(normalized); setError(''); } catch (err) { console.error('Ошибка при загрузке данных биллинга:', err); setError('Ошибка при загрузке данных'); setBillingData([]); } finally { setLoading(false); } }; const handleSaveChanges = async () => { setLoading(true); try { await api.post(`/billing`, { domains: billingData }); setSuccess('Данные успешно сохранены'); setTimeout(() => setSuccess(''), 3000); } catch (err) { console.error('Ошибка при сохранении:', err); setError(`Ошибка при сохранении данных: ${err.response?.data || err.message}`); setTimeout(() => setError(''), 5000); } finally { setLoading(false); } }; const handleAddItem = () => { setNewItem({ hostName: '', purpose: '', country: '', provider: '', loginUrl: '', monthlyCost: 0, monthlyCostCurrency: 'USD', nextPaymentDate: '', lastPaymentDate: '', lastPaymentAmount: 0, lastPaymentCurrency: 'USD', status: 'active', notes: '', serverId: '', }); 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 = () => { if (!newItem.hostName || !newItem.country || !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]); setShowAddModal(false); setSuccess('Элемент успешно добавлен'); setTimeout(() => setSuccess(''), 3000); }; const handleEditSubmit = () => { if (!editingItem.hostName || !editingItem.country || !editingItem.provider) { setError('Пожалуйста, заполните обязательные поля: Имя хоста, Страна, Провайдер'); return; } setBillingData(prev => prev.map(item => item.id === editingItem.id ? editingItem : item )); setShowEditModal(false); 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 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.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); } function 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; } 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; } 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]; return { ...s, payments, lastPaymentDate: last?.date || '', lastPaymentAmount: last?.amount || 0, lastPaymentCurrency: last?.currency || s.monthlyCostCurrency || 'USD' }; })); setShowDeletePaymentModal(false); setPaymentToDelete(null); setSuccess('Платеж удалён'); 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'], ...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 (
| 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' ? '↑' : '↓'} )} | Действия |
|---|---|---|---|---|---|---|---|---|
| Ничего не найдено. Измените фильтры или параметры поиска. | ||||||||
|
{item.hostName}
{item.loginUrl && (
)}
|
{formatPurpose(item.purpose)} |
{getCountryFlag(item.country)}
{item.country}
|
{item.serverId ? ( (() => { const srv = serversById.get(item.serverId); if (!srv) { return Сервер не найден; } return ( ); })() ) : ( Не привязан )} |
{item.loginUrl && (
|
{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
{formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
|
{formatDate(item.nextPaymentDate)}
{item.nextPaymentDate && (
{(() => {
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} дн.;
}
})()}
)}
|
{item.status === 'active' ? 'Активен' : 'Неактивен'} |
|
| 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 && (
|
{formatDate(r.date)} |
{formatCurrency(r.amount, r.currency)}
{formatCurrencyInRUB(r.amount, r.currency)}
|
{r.currency || 'USD'} | {r.note || ''} |
|
|
|
||||||
Вы уверены, что хотите удалить элемент {selectedItem?.hostName}?
Это действие нельзя отменить.
> } onConfirm={confirmDelete} onClose={() => setShowDeleteModal(false)} confirmLabel="Удалить" variant="danger" /> {/* Модалка добавления платежа */}Вы уверены, что хотите удалить выбранный платеж?
{paymentToDelete && (