Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
1998 lines
77 KiB
React
1998 lines
77 KiB
React
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 (
|
||
<div>
|
||
{/* Заголовок страницы */}
|
||
<PageHeader
|
||
title="Инфра-биллинг"
|
||
pretitle="Панель управления / Ноды / Инфра-биллинг"
|
||
actions={
|
||
<PageHeaderActions
|
||
loading={loading}
|
||
onRefresh={fetchBillingData}
|
||
disableRefresh={loading}
|
||
onExport={exportData}
|
||
disableExport={loading}
|
||
onSave={handleSaveChanges}
|
||
disableSave={loading}
|
||
/>
|
||
}
|
||
/>
|
||
|
||
{/* Уведомления */}
|
||
{error && (
|
||
<ErrorAlert
|
||
message={error}
|
||
onClose={() => setError('')}
|
||
onRetry={fetchBillingData}
|
||
className="mb-4"
|
||
/>
|
||
)}
|
||
{success && (
|
||
<div className="alert alert-success alert-dismissible mb-4" role="alert">
|
||
<IconCheck className="me-2" />
|
||
{success}
|
||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Статистические карточки */}
|
||
<div className="row g-3 mb-4">
|
||
<div className="col-md-3">
|
||
<div className="card h-100">
|
||
<div className="card-body d-flex align-items-center">
|
||
<span className="avatar avatar-lg me-3 bg-blue-lt text-blue border-0">
|
||
<IconCalendar size={32} />
|
||
</span>
|
||
<div>
|
||
<div className="h3 mb-0 fw-bold">
|
||
{new Date().toLocaleDateString('ru-RU', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: 'numeric'
|
||
})}
|
||
</div>
|
||
<div className="text-muted lh-1">Текущая дата</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<div className="card h-100">
|
||
<div className="card-body d-flex align-items-center">
|
||
<span className="avatar avatar-lg me-3 bg-orange-lt text-orange border-0">
|
||
<IconAlertTriangle size={32} />
|
||
</span>
|
||
<div>
|
||
<div className="h3 mb-0 fw-bold">
|
||
{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}
|
||
</div>
|
||
<div className="text-muted lh-1">нод, ожидающих оплаты</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<div className="card h-100">
|
||
<div className="card-body d-flex align-items-center">
|
||
<span className="avatar avatar-lg me-3 bg-green-lt text-green border-0">
|
||
<IconCreditCard size={32} />
|
||
</span>
|
||
<div>
|
||
<div className="h3 mb-0 fw-bold">
|
||
{formatCurrencyInRUB(totalLastPayments, 'USD')}
|
||
</div>
|
||
<div className="text-muted lh-1">всего платежей</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<div className="card h-100">
|
||
<div className="card-body d-flex align-items-center">
|
||
<span className="avatar avatar-lg me-3 bg-purple-lt text-purple border-0">
|
||
<IconCurrencyDollar size={32} />
|
||
</span>
|
||
<div>
|
||
<div className="h3 mb-0 fw-bold">
|
||
{formatCurrencyInRUB(totalMonthlyCosts, 'USD')}
|
||
</div>
|
||
<div className="text-muted lh-1">месячные затраты</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Основная таблица */}
|
||
<div className="card">
|
||
<div className="card-header">
|
||
<h3 className="card-title">Оплачиваемые ноды</h3>
|
||
<div className="card-actions">
|
||
<div className="btn-list">
|
||
<button
|
||
className="btn btn-outline-secondary"
|
||
onClick={() => setShowFilters(!showFilters)}
|
||
>
|
||
<IconFilter size={16} className="me-1" />
|
||
Фильтры
|
||
</button>
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={handleAddItem}
|
||
>
|
||
<IconPlus size={16} className="me-1" />
|
||
Добавить
|
||
</button>
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={handleSaveChanges}
|
||
disabled={loading}
|
||
>
|
||
<IconDatabase size={16} className="me-1" />
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="card-body">
|
||
{/* Фильтры */}
|
||
{showFilters && (
|
||
<div className="card mb-3">
|
||
<div className="card-body">
|
||
<div className="row g-3">
|
||
<div className="col-md-3">
|
||
<label className="form-label">Поиск</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
placeholder="Поиск по названию, назначению, провайдеру..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<label className="form-label">Провайдер</label>
|
||
<select
|
||
className="form-select"
|
||
value={filterProvider}
|
||
onChange={(e) => setFilterProvider(e.target.value)}
|
||
>
|
||
<option value="">Все провайдеры</option>
|
||
{uniqueProviders.map(provider => (
|
||
<option key={provider} value={provider}>{provider}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<label className="form-label">Страна</label>
|
||
<select
|
||
className="form-select"
|
||
value={filterCountry}
|
||
onChange={(e) => setFilterCountry(e.target.value)}
|
||
>
|
||
<option value="">Все страны</option>
|
||
{uniqueCountries.map(country => (
|
||
<option key={country} value={country}>{country}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<label className="form-label">Статус</label>
|
||
<select
|
||
className="form-select"
|
||
value={filterStatus}
|
||
onChange={(e) => setFilterStatus(e.target.value)}
|
||
>
|
||
<option value="">Все статусы</option>
|
||
{uniqueStatuses.map(status => (
|
||
<option key={status} value={status}>{status}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="table-responsive">
|
||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th className="cursor-pointer" onClick={() => handleSort('hostName')}>
|
||
Имя хостера
|
||
{sortField === 'hostName' && (
|
||
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
||
)}
|
||
</th>
|
||
<th className="cursor-pointer" onClick={() => handleSort('purpose')}>
|
||
Назначение
|
||
{sortField === 'purpose' && (
|
||
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
||
)}
|
||
</th>
|
||
<th className="cursor-pointer" onClick={() => handleSort('country')}>
|
||
Страна
|
||
{sortField === 'country' && (
|
||
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
||
)}
|
||
</th>
|
||
<th>Связанный сервер</th>
|
||
<th className="cursor-pointer" onClick={() => handleSort('provider')}>
|
||
Провайдер
|
||
{sortField === 'provider' && (
|
||
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
||
)}
|
||
</th>
|
||
<th className="cursor-pointer" onClick={() => handleSort('monthlyCost')}>
|
||
Месячная стоимость
|
||
{sortField === 'monthlyCost' && (
|
||
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
||
)}
|
||
</th>
|
||
<th className="cursor-pointer" onClick={() => handleSort('nextPaymentDate')}>
|
||
Следующий платеж
|
||
{sortField === 'nextPaymentDate' && (
|
||
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
||
)}
|
||
</th>
|
||
<th className="cursor-pointer" onClick={() => handleSort('status')}>
|
||
Статус
|
||
{sortField === 'status' && (
|
||
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
||
)}
|
||
</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{paginatedData.length === 0 ? (
|
||
<tr>
|
||
<td colSpan="8" className="text-center text-muted py-4">Ничего не найдено. Измените фильтры или параметры поиска.</td>
|
||
</tr>
|
||
) : paginatedData.map(item => (
|
||
<tr key={item.id}>
|
||
<td>
|
||
<div className="d-flex align-items-center">
|
||
<div>
|
||
<div className="fw-bold">{item.hostName}</div>
|
||
{item.loginUrl && (
|
||
<div className="text-muted small">
|
||
<a href={`https://${item.loginUrl}`} target="_blank" rel="noopener noreferrer">
|
||
<IconExternalLink size={12} className="me-1" />
|
||
{item.loginUrl}
|
||
</a>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span className="badge bg-blue-lt text-blue">
|
||
{formatPurpose(item.purpose)}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<div className="d-flex align-items-center gap-2">
|
||
<span style={{ fontSize: '18px' }}>{getCountryFlag(item.country)}</span>
|
||
<span>{item.country}</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
{item.serverId ? (
|
||
(() => {
|
||
const srv = serversById.get(item.serverId);
|
||
if (!srv) {
|
||
return <span className="text-warning small">Сервер не найден</span>;
|
||
}
|
||
return (
|
||
<div className="small">
|
||
<div className="fw-bold">{srv.ip}</div>
|
||
<div className="text-muted">
|
||
{srv.dns || srv.provider || 'Без DNS'}
|
||
</div>
|
||
<a href="/servers" className="small text-blue text-decoration-underline">
|
||
Открыть в разделе «Серверы»
|
||
</a>
|
||
</div>
|
||
);
|
||
})()
|
||
) : (
|
||
<span className="text-muted small">Не привязан</span>
|
||
)}
|
||
</td>
|
||
<td>
|
||
<div className="d-flex align-items-center gap-2">
|
||
{item.loginUrl && (
|
||
<img
|
||
src={getFaviconUrl(item.loginUrl)}
|
||
alt=""
|
||
width="16"
|
||
height="16"
|
||
style={{ borderRadius: 3 }}
|
||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||
/>
|
||
)}
|
||
<span className="badge bg-green-lt text-green">{item.provider}</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div className="fw-bold">
|
||
{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
|
||
</div>
|
||
<div className="text-muted small">
|
||
{formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div className="fw-bold">
|
||
{formatDate(item.nextPaymentDate)}
|
||
</div>
|
||
{item.nextPaymentDate && (
|
||
<div className="text-muted small">
|
||
{(() => {
|
||
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 <span className="text-danger">Просрочено на {Math.abs(diffDays)} дн.</span>;
|
||
} else if (diffDays <= 30) {
|
||
return <span className="text-warning">Через {diffDays} дн.</span>;
|
||
} else {
|
||
return <span className="text-success">Через {diffDays} дн.</span>;
|
||
}
|
||
})()}
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td>
|
||
<span className={`badge ${item.status === 'active' ? 'bg-success-lt text-success' : 'bg-danger-lt text-danger'}`}>
|
||
{item.status === 'active' ? 'Активен' : 'Неактивен'}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<div className="btn-list">
|
||
<button
|
||
className="btn btn-outline-primary btn-icon btn-sm"
|
||
onClick={() => handleEditItem(item)}
|
||
title="Редактировать"
|
||
>
|
||
<IconEdit size={16} />
|
||
</button>
|
||
<button
|
||
className="btn btn-outline-success btn-icon btn-sm"
|
||
onClick={() => {
|
||
setPaymentDraft({
|
||
serverId: item.id,
|
||
date: new Date().toISOString().slice(0,10),
|
||
amount: item.monthlyCost || 0,
|
||
currency: item.monthlyCostCurrency || 'USD',
|
||
note: ''
|
||
});
|
||
setShowPaymentModal(true);
|
||
}}
|
||
title="Добавить платеж"
|
||
>
|
||
<IconPlus size={16} />
|
||
</button>
|
||
<button
|
||
className="btn btn-outline-danger btn-icon btn-sm"
|
||
onClick={() => handleDeleteItem(item)}
|
||
title="Удалить"
|
||
>
|
||
<IconTrash size={16} />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<Pagination
|
||
currentPage={currentPage}
|
||
totalPages={totalPages}
|
||
totalItems={filteredData.length}
|
||
pageSize={pageSize}
|
||
onPageChange={setCurrentPage}
|
||
/>
|
||
</div>
|
||
|
||
{/* История платежей */}
|
||
<div className="card mt-4">
|
||
<div className="card-header">
|
||
<h3 className="card-title">
|
||
<IconHistory size={20} className="me-2" />
|
||
История платежей
|
||
</h3>
|
||
<div className="card-actions">
|
||
<div className="btn-list">
|
||
<button
|
||
className="btn btn-outline-secondary"
|
||
onClick={() => setShowPaymentFilters(!showPaymentFilters)}
|
||
>
|
||
<IconFilter size={16} className="me-1" />
|
||
Фильтры
|
||
</button>
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={() => {
|
||
setPaymentDraft({ serverId: '', date: '', amount: 0, currency: 'USD', note: '' });
|
||
setShowPaymentModal(true);
|
||
}}
|
||
>
|
||
<IconPlus size={16} className="me-1" />
|
||
Добавить платеж
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="card-body">
|
||
{/* Поиск и фильтры */}
|
||
<div className="mb-3">
|
||
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
|
||
<span className="input-icon-addon">
|
||
<IconSearch size={18} />
|
||
</span>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
placeholder="Поиск по серверу, дате, сумме, комментарию..."
|
||
value={paymentSearchTerm}
|
||
onChange={(e) => { setPaymentSearchTerm(e.target.value); setPaymentPage(1); }}
|
||
aria-label="Поиск платежей"
|
||
style={{ paddingRight: paymentSearchTerm ? '2.5rem' : undefined }}
|
||
/>
|
||
{paymentSearchTerm && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost-secondary btn-icon btn-sm"
|
||
style={{
|
||
position: 'absolute',
|
||
right: '0.25rem',
|
||
top: '50%',
|
||
transform: 'translateY(-50%)',
|
||
zIndex: 2
|
||
}}
|
||
onClick={() => { setPaymentSearchTerm(''); setPaymentPage(1); }}
|
||
aria-label="Очистить поиск"
|
||
>
|
||
<IconX size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Панель фильтров */}
|
||
{showPaymentFilters && (
|
||
<div className="card mb-3">
|
||
<div className="card-body">
|
||
<div className="row g-3">
|
||
<div className="col-md-3">
|
||
<label className="form-label">Сервер</label>
|
||
<select
|
||
className="form-select"
|
||
value={paymentFilterServer}
|
||
onChange={(e) => { setPaymentFilterServer(e.target.value); setPaymentPage(1); }}
|
||
>
|
||
<option value="">Все серверы</option>
|
||
{billingData.map(server => (
|
||
<option key={server.id} value={server.id}>
|
||
{server.hostName} ({server.provider})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<label className="form-label">Дата от</label>
|
||
<input
|
||
type="date"
|
||
className="form-control"
|
||
value={paymentFilterDateFrom}
|
||
onChange={(e) => { setPaymentFilterDateFrom(e.target.value); setPaymentPage(1); }}
|
||
/>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<label className="form-label">Дата до</label>
|
||
<input
|
||
type="date"
|
||
className="form-control"
|
||
value={paymentFilterDateTo}
|
||
onChange={(e) => { setPaymentFilterDateTo(e.target.value); setPaymentPage(1); }}
|
||
/>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<label className="form-label">Сумма от</label>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
placeholder="0"
|
||
step="0.01"
|
||
value={paymentFilterAmountFrom}
|
||
onChange={(e) => { setPaymentFilterAmountFrom(e.target.value); setPaymentPage(1); }}
|
||
/>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<label className="form-label">Сумма до</label>
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
placeholder="0"
|
||
step="0.01"
|
||
value={paymentFilterAmountTo}
|
||
onChange={(e) => { setPaymentFilterAmountTo(e.target.value); setPaymentPage(1); }}
|
||
/>
|
||
</div>
|
||
<div className="col-md-3 d-flex align-items-end">
|
||
<button
|
||
className="btn btn-ghost-secondary"
|
||
onClick={() => {
|
||
setPaymentFilterServer('');
|
||
setPaymentFilterDateFrom('');
|
||
setPaymentFilterDateTo('');
|
||
setPaymentFilterAmountFrom('');
|
||
setPaymentFilterAmountTo('');
|
||
setPaymentPage(1);
|
||
}}
|
||
>
|
||
<IconX size={16} className="me-1" />
|
||
Сбросить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Bulk Actions Bar */}
|
||
<BulkActionsBar
|
||
selectedCount={selectedPayments.size}
|
||
totalCount={(() => {
|
||
const allRows = billingData.flatMap(server =>
|
||
(server.payments || []).map((p, idx) => ({ server, ...p, _key: `${server.id}-${idx}`, _index: idx }))
|
||
);
|
||
return allRows.length;
|
||
})()}
|
||
onSelectAll={() => {
|
||
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)));
|
||
}}
|
||
onDeselectAll={() => setSelectedPayments(new Set())}
|
||
onDelete={() => {
|
||
if (selectedPayments.size === 0) return;
|
||
const keys = Array.from(selectedPayments);
|
||
setBillingData(prev => prev.map(server => {
|
||
const payments = Array.isArray(server.payments) ? [...server.payments] : [];
|
||
const filtered = payments.filter((p, idx) => {
|
||
const key = `${server.id}-${idx}`;
|
||
return !keys.includes(key);
|
||
});
|
||
const last = filtered
|
||
.slice()
|
||
.sort((a,b) => new Date(b.date) - new Date(a.date))[0];
|
||
return {
|
||
...server,
|
||
payments: filtered,
|
||
lastPaymentDate: last?.date || '',
|
||
lastPaymentAmount: last?.amount || 0,
|
||
lastPaymentCurrency: last?.currency || server.monthlyCostCurrency || 'USD'
|
||
};
|
||
}));
|
||
setSelectedPayments(new Set());
|
||
setSuccess(`Удалено платежей: ${selectedPayments.size}`);
|
||
setTimeout(() => setSuccess(''), 3000);
|
||
}}
|
||
onEdit={() => {
|
||
// Массовое редактирование - можно добавить позже
|
||
setError('Массовое редактирование пока не реализовано');
|
||
setTimeout(() => setError(''), 3000);
|
||
}}
|
||
/>
|
||
|
||
{/* Таблица платежей */}
|
||
<div className="table-responsive">
|
||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: '40px' }}>
|
||
<input
|
||
type="checkbox"
|
||
className="form-check-input"
|
||
checked={selectedPayments.size > 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());
|
||
}
|
||
}}
|
||
/>
|
||
</th>
|
||
<th
|
||
style={{ cursor: 'pointer' }}
|
||
onClick={() => {
|
||
if (paymentSortField === 'hostName') {
|
||
setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setPaymentSortField('hostName');
|
||
setPaymentSortOrder('asc');
|
||
}
|
||
}}
|
||
>
|
||
<div className="d-flex align-items-center gap-2">
|
||
Имя хостера
|
||
{paymentSortField === 'hostName' && (
|
||
paymentSortOrder === 'asc' ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />
|
||
)}
|
||
</div>
|
||
</th>
|
||
<th
|
||
style={{ cursor: 'pointer' }}
|
||
onClick={() => {
|
||
if (paymentSortField === 'date') {
|
||
setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setPaymentSortField('date');
|
||
setPaymentSortOrder('desc');
|
||
}
|
||
}}
|
||
>
|
||
<div className="d-flex align-items-center gap-2">
|
||
Дата
|
||
{paymentSortField === 'date' && (
|
||
paymentSortOrder === 'asc' ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />
|
||
)}
|
||
</div>
|
||
</th>
|
||
<th
|
||
style={{ cursor: 'pointer' }}
|
||
onClick={() => {
|
||
if (paymentSortField === 'amount') {
|
||
setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setPaymentSortField('amount');
|
||
setPaymentSortOrder('desc');
|
||
}
|
||
}}
|
||
>
|
||
<div className="d-flex align-items-center gap-2">
|
||
Сумма
|
||
{paymentSortField === 'amount' && (
|
||
paymentSortOrder === 'asc' ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />
|
||
)}
|
||
</div>
|
||
</th>
|
||
<th>Валюта</th>
|
||
<th>Комментарий</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(() => {
|
||
// Получаем все платежи
|
||
let allRows = billingData.flatMap(server =>
|
||
(server.payments || []).map((p, idx) => ({
|
||
server,
|
||
...p,
|
||
_key: `${server.id}-${idx}`,
|
||
_index: idx
|
||
}))
|
||
);
|
||
|
||
// Поиск
|
||
if (paymentSearchTerm) {
|
||
const term = paymentSearchTerm.toLowerCase();
|
||
allRows = allRows.filter(r =>
|
||
r.server.hostName?.toLowerCase().includes(term) ||
|
||
r.server.provider?.toLowerCase().includes(term) ||
|
||
formatDate(r.date).toLowerCase().includes(term) ||
|
||
String(r.amount).includes(term) ||
|
||
r.currency?.toLowerCase().includes(term) ||
|
||
r.note?.toLowerCase().includes(term)
|
||
);
|
||
}
|
||
|
||
// Фильтр по серверу
|
||
if (paymentFilterServer) {
|
||
allRows = allRows.filter(r => r.server.id === paymentFilterServer);
|
||
}
|
||
|
||
// Фильтр по дате
|
||
if (paymentFilterDateFrom) {
|
||
allRows = allRows.filter(r => new Date(r.date) >= new Date(paymentFilterDateFrom));
|
||
}
|
||
if (paymentFilterDateTo) {
|
||
allRows = allRows.filter(r => new Date(r.date) <= new Date(paymentFilterDateTo));
|
||
}
|
||
|
||
// Фильтр по сумме
|
||
if (paymentFilterAmountFrom) {
|
||
const from = parseFloat(paymentFilterAmountFrom);
|
||
allRows = allRows.filter(r => r.amount >= from);
|
||
}
|
||
if (paymentFilterAmountTo) {
|
||
const to = parseFloat(paymentFilterAmountTo);
|
||
allRows = allRows.filter(r => r.amount <= to);
|
||
}
|
||
|
||
// Сортировка
|
||
allRows.sort((a, b) => {
|
||
let valA, valB;
|
||
if (paymentSortField === 'date') {
|
||
valA = new Date(a.date);
|
||
valB = new Date(b.date);
|
||
} else if (paymentSortField === 'amount') {
|
||
valA = a.amount || 0;
|
||
valB = b.amount || 0;
|
||
} else if (paymentSortField === 'hostName') {
|
||
valA = (a.server.hostName || '').toLowerCase();
|
||
valB = (b.server.hostName || '').toLowerCase();
|
||
} else {
|
||
valA = a[paymentSortField] || '';
|
||
valB = b[paymentSortField] || '';
|
||
}
|
||
|
||
if (valA < valB) return paymentSortOrder === 'asc' ? -1 : 1;
|
||
if (valA > valB) return paymentSortOrder === 'asc' ? 1 : -1;
|
||
return 0;
|
||
});
|
||
|
||
// Пагинация
|
||
const totalPaymentPages = Math.max(1, Math.ceil(allRows.length / paymentPageSize));
|
||
const paginatedRows = allRows.slice(
|
||
(paymentPage - 1) * paymentPageSize,
|
||
paymentPage * paymentPageSize
|
||
);
|
||
|
||
if (paginatedRows.length === 0) {
|
||
return (
|
||
<tr>
|
||
<td colSpan="7" className="text-center text-muted py-4">
|
||
<div className="py-3">
|
||
<IconHistory size={48} className="text-muted mb-3" />
|
||
<div>Платежи не найдены</div>
|
||
{allRows.length === 0 && billingData.every(i => !i.payments || i.payments.length === 0) && (
|
||
<>
|
||
<div className="small text-muted mt-2">
|
||
Добавьте первый платеж, чтобы начать отслеживание
|
||
</div>
|
||
<button
|
||
className="btn btn-primary btn-sm mt-3"
|
||
onClick={() => {
|
||
setPaymentDraft({ serverId: '', date: '', amount: 0, currency: 'USD', note: '' });
|
||
setShowPaymentModal(true);
|
||
}}
|
||
>
|
||
<IconPlus size={16} className="me-1" />
|
||
Добавить первый платеж
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{paginatedRows.map(r => (
|
||
<tr key={r._key}>
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
className="form-check-input"
|
||
checked={selectedPayments.has(r._key)}
|
||
onChange={(e) => {
|
||
const newSet = new Set(selectedPayments);
|
||
if (e.target.checked) {
|
||
newSet.add(r._key);
|
||
} else {
|
||
newSet.delete(r._key);
|
||
}
|
||
setSelectedPayments(newSet);
|
||
}}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<div className="fw-bold">{r.server.hostName}</div>
|
||
<div className="text-muted small d-flex align-items-center gap-2">
|
||
{r.server.loginUrl && (
|
||
<img
|
||
src={getFaviconUrl(r.server.loginUrl)}
|
||
alt=""
|
||
width="14"
|
||
height="14"
|
||
style={{ borderRadius: 3 }}
|
||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||
/>
|
||
)}
|
||
<span>{r.server.provider}</span>
|
||
</div>
|
||
</td>
|
||
<td>{formatDate(r.date)}</td>
|
||
<td>
|
||
<div className="fw-bold">{formatCurrency(r.amount, r.currency)}</div>
|
||
<div className="text-muted small">{formatCurrencyInRUB(r.amount, r.currency)}</div>
|
||
</td>
|
||
<td><span className="badge bg-blue-lt text-blue">{r.currency || 'USD'}</span></td>
|
||
<td className="text-muted small">{r.note || ''}</td>
|
||
<td>
|
||
<div className="btn-list">
|
||
<button
|
||
className="btn btn-outline-primary btn-icon"
|
||
title="Редактировать платеж"
|
||
onClick={() => {
|
||
setPaymentDraft({
|
||
serverId: r.server.id,
|
||
date: r.date,
|
||
amount: r.amount,
|
||
currency: r.currency || 'USD',
|
||
note: r.note || ''
|
||
});
|
||
setEditingPayment({ serverId: r.server.id, index: r._index });
|
||
setShowPaymentModal(true);
|
||
}}
|
||
>
|
||
<IconEdit size={18} />
|
||
</button>
|
||
<button
|
||
className="btn btn-outline-danger btn-icon"
|
||
title="Удалить платеж"
|
||
onClick={() => {
|
||
setPaymentToDelete({ serverId: r.server.id, index: r._index, data: r });
|
||
setShowDeletePaymentModal(true);
|
||
}}
|
||
>
|
||
<IconTrash size={18} />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{totalPaymentPages > 1 && (
|
||
<tr>
|
||
<td colSpan="7" className="p-0">
|
||
<Pagination
|
||
currentPage={paymentPage}
|
||
totalPages={totalPaymentPages}
|
||
totalItems={allRows.length}
|
||
pageSize={paymentPageSize}
|
||
onPageChange={setPaymentPage}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</>
|
||
);
|
||
})()}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Модальные окна */}
|
||
<FormModal
|
||
show={showAddModal}
|
||
title="Добавить новый элемент"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
handleAddSubmit();
|
||
}}
|
||
onClose={() => setShowAddModal(false)}
|
||
submitLabel="Добавить"
|
||
submitIcon={IconPlus}
|
||
size="lg"
|
||
>
|
||
<AddBillingForm
|
||
item={newItem}
|
||
onItemChange={setNewItem}
|
||
servers={servers}
|
||
/>
|
||
</FormModal>
|
||
|
||
<FormModal
|
||
show={showEditModal}
|
||
title="Редактировать элемент"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
handleEditSubmit();
|
||
}}
|
||
onClose={() => setShowEditModal(false)}
|
||
submitLabel="Сохранить"
|
||
submitIcon={IconCheck}
|
||
size="lg"
|
||
>
|
||
<EditBillingForm
|
||
item={editingItem}
|
||
onItemChange={setEditingItem}
|
||
servers={servers}
|
||
/>
|
||
</FormModal>
|
||
|
||
<ConfirmModal
|
||
show={showDeleteModal}
|
||
title="Подтверждение удаления"
|
||
message={
|
||
<>
|
||
<p>Вы уверены, что хотите удалить элемент <strong>{selectedItem?.hostName}</strong>?</p>
|
||
<p className="text-muted">Это действие нельзя отменить.</p>
|
||
</>
|
||
}
|
||
onConfirm={confirmDelete}
|
||
onClose={() => setShowDeleteModal(false)}
|
||
confirmLabel="Удалить"
|
||
variant="danger"
|
||
/>
|
||
{/* Модалка добавления платежа */}
|
||
<FormModal
|
||
show={showPaymentModal}
|
||
title={editingPayment ? "Редактировать платеж" : "Добавить платеж"}
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
if (!paymentDraft.serverId || !paymentDraft.date || !paymentDraft.amount) {
|
||
setError('Заполните сервер, дату и сумму.');
|
||
setTimeout(() => setError(''), 3000);
|
||
return;
|
||
}
|
||
|
||
// Валидация даты (нельзя добавить платеж в будущем без подтверждения)
|
||
const selectedDate = new Date(paymentDraft.date);
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
selectedDate.setHours(0, 0, 0, 0);
|
||
|
||
if (selectedDate > today && !futureDateConfirm) {
|
||
if (!window.confirm('Внимание: Выбранная дата в будущем. Вы уверены, что хотите добавить платеж с будущей датой?')) {
|
||
return;
|
||
}
|
||
setFutureDateConfirm(true);
|
||
// Продолжаем выполнение после подтверждения
|
||
}
|
||
|
||
// Режим редактирования платежа
|
||
if (editingPayment && editingPayment.serverId) {
|
||
setBillingData(prev => prev.map(s => {
|
||
if (s.id !== editingPayment.serverId) return s;
|
||
const payments = Array.isArray(s.payments) ? [...s.payments] : [];
|
||
if (editingPayment.index != null && payments[editingPayment.index]) {
|
||
payments[editingPayment.index] = {
|
||
date: paymentDraft.date,
|
||
amount: paymentDraft.amount,
|
||
currency: paymentDraft.currency || 'USD',
|
||
note: paymentDraft.note || ''
|
||
};
|
||
}
|
||
// обновляем lastPayment* по последней дате
|
||
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'
|
||
};
|
||
}));
|
||
setEditingPayment(null);
|
||
setShowPaymentModal(false);
|
||
setSuccess('Платеж обновлен');
|
||
setTimeout(() => setSuccess(''), 3000);
|
||
return;
|
||
}
|
||
// Режим добавления платежа
|
||
setBillingData(prev => prev.map(s => {
|
||
if (s.id !== paymentDraft.serverId) return s;
|
||
const payments = Array.isArray(s.payments) ? [...s.payments] : [];
|
||
payments.push({ date: paymentDraft.date, amount: paymentDraft.amount, currency: paymentDraft.currency || 'USD', note: paymentDraft.note || '' });
|
||
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' };
|
||
}));
|
||
setShowPaymentModal(false);
|
||
setFutureDateConfirm(false);
|
||
setSuccess('Платеж добавлен');
|
||
setTimeout(() => setSuccess(''), 3000);
|
||
}}
|
||
onClose={() => {
|
||
setShowPaymentModal(false);
|
||
setEditingPayment(null);
|
||
setFutureDateConfirm(false);
|
||
}}
|
||
submitLabel={editingPayment ? "Сохранить" : "Добавить"}
|
||
submitIcon={editingPayment ? IconCheck : IconPlus}
|
||
>
|
||
<AddPaymentForm
|
||
servers={billingData}
|
||
payment={paymentDraft}
|
||
onChange={setPaymentDraft}
|
||
onDateChange={(date, requiresConfirm) => {
|
||
if (requiresConfirm) {
|
||
setFutureDateConfirm(false);
|
||
}
|
||
}}
|
||
/>
|
||
</FormModal>
|
||
|
||
{/* Модалка удаления платежа */}
|
||
<ConfirmModal
|
||
show={showDeletePaymentModal}
|
||
title="Удалить платеж"
|
||
message={
|
||
<>
|
||
<p>Вы уверены, что хотите удалить выбранный платеж?</p>
|
||
{paymentToDelete && (
|
||
<div className="alert alert-warning mt-3">
|
||
<div><strong>Сервер:</strong> {paymentToDelete.data?.server?.hostName}</div>
|
||
<div><strong>Дата:</strong> {formatDate(paymentToDelete.data?.date)}</div>
|
||
<div><strong>Сумма:</strong> {formatCurrency(paymentToDelete.data?.amount, paymentToDelete.data?.currency)}</div>
|
||
{paymentToDelete.data?.note && (
|
||
<div><strong>Комментарий:</strong> {paymentToDelete.data?.note}</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
}
|
||
onConfirm={handleDeletePayment}
|
||
onClose={() => setShowDeletePaymentModal(false)}
|
||
confirmLabel="Удалить"
|
||
variant="danger"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Компоненты форм для модальных окон
|
||
function AddBillingForm({ item, onItemChange, servers }) {
|
||
const purposeOptions = [
|
||
{ value: '', label: 'Выберите назначение' },
|
||
{ value: 'relay', label: 'Relay VPS' },
|
||
{ value: 'bgp', label: 'BGP сервер' },
|
||
{ value: 'monitoring', label: 'Мониторинг' },
|
||
{ value: 'dns', label: 'DNS сервер' },
|
||
{ value: 'proxy', label: 'Прокси сервер' },
|
||
{ value: 'backup', label: 'Backup сервер' },
|
||
{ value: 'other', label: 'Другое' }
|
||
];
|
||
|
||
const statusOptions = [
|
||
{ value: 'active', label: 'Активен' },
|
||
{ value: 'inactive', label: 'Неактивен' }
|
||
];
|
||
|
||
const currencyOptions = [
|
||
{ value: 'USD', label: 'USD' },
|
||
{ value: 'EUR', label: 'EUR' },
|
||
{ value: 'RUB', label: 'RUB' }
|
||
];
|
||
|
||
const serverOptions = [
|
||
{ value: '', label: 'Без привязки' },
|
||
...(Array.isArray(servers) ? servers : []).map((s) => ({
|
||
value: s.id || s.ip,
|
||
label: `${s.ip}${s.dns ? ` (${s.dns})` : ''}${s.provider ? ` – ${s.provider}` : ''}`,
|
||
})),
|
||
];
|
||
|
||
return (
|
||
<div className="row g-3">
|
||
{/* Основная информация */}
|
||
<div className="col-12">
|
||
<h6 className="text-muted mb-3">Основная информация</h6>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Имя хоста"
|
||
name="hostName"
|
||
value={item.hostName}
|
||
onChange={(value) => onItemChange({ ...item, hostName: value })}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<label className="form-label">Связанный сервер (опционально)</label>
|
||
<ServerAutocompleteInput
|
||
value={item.serverId || ''}
|
||
onChange={(value) => onItemChange({ ...item, serverId: value || '' })}
|
||
servers={servers}
|
||
placeholder="Поиск по IP, DNS, провайдеру..."
|
||
/>
|
||
<div className="form-hint mt-1">
|
||
Используется для связи с разделом «Серверы». Можно оставить пустым.
|
||
</div>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Назначение"
|
||
name="purpose"
|
||
type="select"
|
||
value={item.purpose}
|
||
onChange={(value) => onItemChange({ ...item, purpose: value })}
|
||
options={purposeOptions}
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Страна"
|
||
name="country"
|
||
value={item.country}
|
||
onChange={(value) => onItemChange({ ...item, country: value })}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Провайдер"
|
||
name="provider"
|
||
value={item.provider}
|
||
onChange={(value) => onItemChange({ ...item, provider: value })}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Ссылка для входа"
|
||
name="loginUrl"
|
||
value={item.loginUrl}
|
||
onChange={(value) => onItemChange({ ...item, loginUrl: value })}
|
||
placeholder="example.com"
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Статус"
|
||
name="status"
|
||
type="select"
|
||
value={item.status}
|
||
onChange={(value) => onItemChange({ ...item, status: value })}
|
||
options={statusOptions}
|
||
/>
|
||
</div>
|
||
|
||
{/* Текущие платежи */}
|
||
<div className="col-12">
|
||
<h6 className="text-muted mb-3 mt-4">Текущие платежи</h6>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Месячная стоимость"
|
||
name="monthlyCost"
|
||
type="number"
|
||
value={item.monthlyCost}
|
||
onChange={(value) => onItemChange({ ...item, monthlyCost: parseFloat(value) || 0 })}
|
||
step="0.01"
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Валюта месячной стоимости"
|
||
name="monthlyCostCurrency"
|
||
type="select"
|
||
value={item.monthlyCostCurrency}
|
||
onChange={(value) => onItemChange({ ...item, monthlyCostCurrency: value })}
|
||
options={currencyOptions}
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Дата следующего платежа"
|
||
name="nextPaymentDate"
|
||
type="date"
|
||
value={item.nextPaymentDate}
|
||
onChange={(value) => onItemChange({ ...item, nextPaymentDate: value })}
|
||
/>
|
||
</div>
|
||
|
||
{/* Дополнительно */}
|
||
<div className="col-md-12">
|
||
<FormField
|
||
label="Заметки"
|
||
name="notes"
|
||
value={item.notes}
|
||
onChange={(value) => onItemChange({ ...item, notes: value })}
|
||
placeholder="Дополнительная информация"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EditBillingForm({ item, onItemChange, servers }) {
|
||
const purposeOptions = [
|
||
{ value: '', label: 'Выберите назначение' },
|
||
{ value: 'relay', label: 'Relay VPS' },
|
||
{ value: 'bgp', label: 'BGP сервер' },
|
||
{ value: 'monitoring', label: 'Мониторинг' },
|
||
{ value: 'dns', label: 'DNS сервер' },
|
||
{ value: 'proxy', label: 'Прокси сервер' },
|
||
{ value: 'backup', label: 'Backup сервер' },
|
||
{ value: 'other', label: 'Другое' }
|
||
];
|
||
|
||
const statusOptions = [
|
||
{ value: 'active', label: 'Активен' },
|
||
{ value: 'inactive', label: 'Неактивен' }
|
||
];
|
||
|
||
const currencyOptions = [
|
||
{ value: 'USD', label: 'USD' },
|
||
{ value: 'EUR', label: 'EUR' },
|
||
{ value: 'RUB', label: 'RUB' }
|
||
];
|
||
|
||
const serverOptions = [
|
||
{ value: '', label: 'Без привязки' },
|
||
...(Array.isArray(servers) ? servers : []).map((s) => ({
|
||
value: s.id || s.ip,
|
||
label: `${s.ip}${s.dns ? ` (${s.dns})` : ''}${s.provider ? ` – ${s.provider}` : ''}`,
|
||
})),
|
||
];
|
||
|
||
return (
|
||
<div className="row g-3">
|
||
{/* Основная информация */}
|
||
<div className="col-12">
|
||
<h6 className="text-muted mb-3">Основная информация</h6>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Имя хоста"
|
||
name="hostName"
|
||
value={item.hostName || ''}
|
||
onChange={(value) => onItemChange({ ...item, hostName: value })}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<label className="form-label">Связанный сервер (опционально)</label>
|
||
<ServerAutocompleteInput
|
||
value={item.serverId || ''}
|
||
onChange={(value) => onItemChange({ ...item, serverId: value || '' })}
|
||
servers={servers}
|
||
placeholder="Поиск по IP, DNS, провайдеру..."
|
||
/>
|
||
<div className="form-hint mt-1">
|
||
Используется для связи с разделом «Серверы». Можно оставить пустым.
|
||
</div>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Назначение"
|
||
name="purpose"
|
||
type="select"
|
||
value={item.purpose || ''}
|
||
onChange={(value) => onItemChange({ ...item, purpose: value })}
|
||
options={purposeOptions}
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Страна"
|
||
name="country"
|
||
value={item.country || ''}
|
||
onChange={(value) => onItemChange({ ...item, country: value })}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Провайдер"
|
||
name="provider"
|
||
value={item.provider || ''}
|
||
onChange={(value) => onItemChange({ ...item, provider: value })}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Ссылка для входа"
|
||
name="loginUrl"
|
||
value={item.loginUrl || ''}
|
||
onChange={(value) => onItemChange({ ...item, loginUrl: value })}
|
||
placeholder="example.com"
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Статус"
|
||
name="status"
|
||
type="select"
|
||
value={item.status || 'active'}
|
||
onChange={(value) => onItemChange({ ...item, status: value })}
|
||
options={statusOptions}
|
||
/>
|
||
</div>
|
||
|
||
{/* Текущие платежи */}
|
||
<div className="col-12">
|
||
<h6 className="text-muted mb-3 mt-4">Текущие платежи</h6>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Месячная стоимость"
|
||
name="monthlyCost"
|
||
type="number"
|
||
value={item.monthlyCost || 0}
|
||
onChange={(value) => onItemChange({ ...item, monthlyCost: parseFloat(value) || 0 })}
|
||
step="0.01"
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Валюта месячной стоимости"
|
||
name="monthlyCostCurrency"
|
||
type="select"
|
||
value={item.monthlyCostCurrency || 'USD'}
|
||
onChange={(value) => onItemChange({ ...item, monthlyCostCurrency: value })}
|
||
options={currencyOptions}
|
||
/>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<FormField
|
||
label="Дата следующего платежа"
|
||
name="nextPaymentDate"
|
||
type="date"
|
||
value={item.nextPaymentDate || ''}
|
||
onChange={(value) => onItemChange({ ...item, nextPaymentDate: value })}
|
||
/>
|
||
</div>
|
||
|
||
{/* Дополнительно */}
|
||
<div className="col-md-12">
|
||
<FormField
|
||
label="Заметки"
|
||
name="notes"
|
||
value={item.notes || ''}
|
||
onChange={(value) => onItemChange({ ...item, notes: value })}
|
||
placeholder="Дополнительная информация"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Форма добавления платежа
|
||
function AddPaymentForm({ servers, payment, onChange, onDateChange }) {
|
||
const serverOptions = [
|
||
{ value: '', label: 'Выберите сервер' },
|
||
...servers.map(s => ({ value: s.id, label: `${s.hostName} (${s.provider})` }))
|
||
];
|
||
|
||
const currencyOptions = [
|
||
{ value: 'USD', label: 'USD' },
|
||
{ value: 'EUR', label: 'EUR' },
|
||
{ value: 'RUB', label: 'RUB' }
|
||
];
|
||
|
||
// Автозаполнение при выборе сервера
|
||
const handleServerChange = (serverId) => {
|
||
const server = servers.find(s => s.id === serverId);
|
||
if (server) {
|
||
// Используем последний платеж или месячную стоимость
|
||
const lastAmount = server.lastPaymentAmount || server.monthlyCost || 0;
|
||
const lastCurrency = server.lastPaymentCurrency || server.monthlyCostCurrency || 'USD';
|
||
|
||
onChange({
|
||
...payment,
|
||
serverId,
|
||
amount: lastAmount,
|
||
currency: lastCurrency
|
||
});
|
||
} else {
|
||
onChange({ ...payment, serverId });
|
||
}
|
||
};
|
||
|
||
// Валидация даты (нельзя добавить платеж в будущем без подтверждения)
|
||
const handleDateChange = (date) => {
|
||
if (date) {
|
||
const selectedDate = new Date(date);
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
selectedDate.setHours(0, 0, 0, 0);
|
||
|
||
if (selectedDate > today) {
|
||
if (onDateChange) {
|
||
onDateChange(date, true); // true = требуется подтверждение
|
||
} else {
|
||
// Если нет обработчика, просто предупреждаем
|
||
alert('Внимание: Выбранная дата в будущем. Пожалуйста, подтвердите корректность даты.');
|
||
}
|
||
} else {
|
||
if (onDateChange) {
|
||
onDateChange(date, false);
|
||
}
|
||
}
|
||
}
|
||
onChange({ ...payment, date });
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<FormField
|
||
label="Сервер"
|
||
name="serverId"
|
||
type="select"
|
||
value={payment.serverId}
|
||
onChange={handleServerChange}
|
||
options={serverOptions}
|
||
required
|
||
helpText={payment.serverId ? "Сумма и валюта заполнены автоматически из последнего платежа" : ""}
|
||
/>
|
||
<FormField
|
||
label="Дата"
|
||
name="date"
|
||
type="date"
|
||
value={payment.date}
|
||
onChange={handleDateChange}
|
||
required
|
||
helpText="Нельзя добавить платеж в будущем без подтверждения"
|
||
/>
|
||
<div className="row g-2">
|
||
<div className="col-8">
|
||
<FormField
|
||
label="Сумма"
|
||
name="amount"
|
||
type="number"
|
||
value={payment.amount}
|
||
onChange={(value) => onChange({ ...payment, amount: parseFloat(value) || 0 })}
|
||
step="0.01"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="col-4">
|
||
<FormField
|
||
label="Валюта"
|
||
name="currency"
|
||
type="select"
|
||
value={payment.currency}
|
||
onChange={(value) => onChange({ ...payment, currency: value })}
|
||
options={currencyOptions}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<FormField
|
||
label="Комментарий"
|
||
name="note"
|
||
value={payment.note}
|
||
onChange={(value) => onChange({ ...payment, note: value })}
|
||
placeholder="Необязательно"
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default BillingManager; |