Files
router-lists-ui/frontend/src/BillingManager.jsx
T

1437 lines
53 KiB
React

import { useState, useEffect } 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 PurposeSelectInput, { PURPOSES as PURPOSE_OPTIONS } from './components/PurposeSelectInput.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,
IconServer,
IconClock,
IconLayoutGrid,
IconList
} from '@tabler/icons-react';
import BulkActionsBar from './components/BulkActionsBar.jsx';
import { BillingCard } from './components/billing/index.js';
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('nextPaymentDate');
const [sortOrder, setSortOrder] = useState('asc');
const [filterStatus, _setFilterStatus] = useState('');
const [filterUrgency, setFilterUrgency] = useState('');
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
const [activeTab, setActiveTab] = useState('subscriptions'); // 'subscriptions' | 'payments'
const pageSize = 15;
// Модальные окна
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);
const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false);
const [paymentToDelete, setPaymentToDelete] = useState(null);
const [_futureDateConfirm, _setFutureDateConfirm] = useState(false);
// История платежей
const [paymentSearchTerm, _setPaymentSearchTerm] = useState('');
const [_paymentSortField, _setPaymentSortField] = useState('date');
const [_paymentSortOrder, _setPaymentSortOrder] = useState('desc');
const [_selectedPayments, _setSelectedPayments] = useState(new Set());
const [_paymentPage, _setPaymentPage] = useState(1);
const paymentPageSize = 10;
// Курсы валют
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(() => {
const controller = new AbortController();
const signal = controller.signal;
fetchBillingData(signal);
fetchExchangeRates(signal);
fetchServers(signal);
return () => controller.abort();
}, []);
// Автоматическая синхронизация: добавляем серверы без записей в биллинге
useEffect(() => {
if (servers.length === 0 || loading) return;
setBillingData((prev) => {
const existingServerIds = new Set(
prev.map(b => b.serverId).filter(Boolean)
);
// Находим серверы без записей в биллинге (исключаем входные роутеры)
const serversToAdd = servers.filter(server =>
server.id &&
!existingServerIds.has(server.id) &&
server.type !== 'home' // Входные роутеры не попадают в биллинг
);
if (serversToAdd.length === 0) return prev;
// Создаём записи с флагом _external
const newItems = serversToAdd.map(server => ({
id: `billing-${server.id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
hostName: server.dns || server.ip,
purpose: '',
country: server.country || '',
provider: server.provider || '',
loginUrl: '',
monthlyCost: 0,
monthlyCostCurrency: 'USD',
nextPaymentDate: '',
lastPaymentDate: '',
lastPaymentAmount: 0,
lastPaymentCurrency: 'USD',
status: 'active',
notes: '',
serverId: server.id,
payments: [],
_external: true, // флаг "не заполнен"
}));
return [...prev, ...newItems];
});
}, [servers, loading]);
const fetchExchangeRates = async (abortSignal) => {
setRatesLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
// Основной источник: ЦБ РФ (base = RUB)
const cbr = await axios.get('https://www.cbr-xml-daily.ru/latest.js', opts);
const rubPerUsd = cbr.data && cbr.data.rates?.USD ? 1 / Number(cbr.data.rates.USD) : null;
const rubPerEur = cbr.data && cbr.data.rates?.EUR ? 1 / Number(cbr.data.rates.EUR) : null;
if (rubPerUsd) {
const eurPerUsd = rubPerEur ? rubPerUsd / rubPerEur : 1;
setExchangeRates({
USD: 1,
EUR: eurPerUsd, // сколько EUR за 1 USD
RUB: rubPerUsd // сколько RUB за 1 USD
});
return;
}
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.warn('ЦБ недоступен, пробуем резервный источник:', error?.message);
}
try {
// Резервный источник: open.er-api
const response = await axios.get('https://open.er-api.com/v6/latest/USD', opts);
const rates = response.data?.rates || {};
setExchangeRates({
USD: 1,
EUR: rates.EUR || 1,
RUB: rates.RUB || 1
});
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке курсов валют:', error);
// Фолбек на разумные значения
setExchangeRates({ USD: 1, EUR: 1.05, RUB: 95 });
} finally {
setRatesLoading(false);
}
};
const fetchServers = async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get(`/servers`, opts);
setServers(Array.isArray(res.data) ? res.data : []);
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке серверов:', error);
}
};
const fetchBillingData = async (abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/billing`, opts);
const rates = exchangeRates; // из closure, на первой загрузке может быть {1,1,1} — для USD достаточно
const toRUB = (amt, ccy) => {
if (!amt) return 0;
const rub = Number(rates.RUB) || 1;
if (!ccy || ccy === 'RUB') return amt;
if (ccy === 'USD') return amt * rub;
const c = Number(rates[ccy]);
return c ? (amt / c) * rub : amt * rub;
};
const normalized = (response.data || []).map((item, idx) => {
const payments = Array.isArray(item.payments)
? item.payments
: (item.lastPaymentDate && item.lastPaymentAmount
? [{ date: item.lastPaymentDate, amount: item.lastPaymentAmount, currency: item.lastPaymentCurrency || 'USD', note: 'Импортировано' }]
: []
);
const merged = { id: item.id ?? String(idx + 1), payments, ...item };
if (payments.length > 0) {
const last = payments.slice().sort((a, b) => new Date(b.date) - new Date(a.date))[0];
if (last?.date) {
const current = new Date(item.nextPaymentDate || 0);
const now = new Date();
if (current < now || !item.nextPaymentDate) {
const costRUB = toRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD');
const amtRUB = toRUB(last.amount, last.currency || 'USD');
const months = costRUB > 0 ? Math.max(1, Math.floor(amtRUB / costRUB)) : 1;
const d = new Date(last.date);
d.setMonth(d.getMonth() + months);
merged.nextPaymentDate = d.toISOString().slice(0, 10);
}
}
}
return merged;
});
setBillingData(normalized);
setError('');
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
console.error('Ошибка при загрузке данных биллинга:', err);
setError('Ошибка при загрузке данных');
setBillingData([]);
} finally {
setLoading(false);
}
};
const handleSaveChanges = async () => {
setLoading(true);
try {
// Убираем служебное поле _external перед сохранением
const payload = billingData.map(({ _external: _ext, ...rest }) => rest);
await api.post(`/billing`, { domains: payload });
// После сохранения снимаем флаг _external со всех записей
setBillingData(prev => prev.map(item => ({ ...item, _external: false })));
setSuccess('Данные успешно сохранены');
setTimeout(() => setSuccess(''), 3000);
} catch (err) {
console.error('Ошибка при сохранении:', err);
setError(`Ошибка при сохранении данных: ${err.response?.data || err.message}`);
setTimeout(() => setError(''), 5000);
} finally {
setLoading(false);
}
};
// Карта серверов для быстрой привязки
const serversById = new Map(
(servers || []).filter(s => s && s.id).map(s => [s.id, s])
);
// Конвертация в рубли
const convertToRUB = (amount, currency) => {
if (!amount) return 0;
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;
return (amount / ccyPerUsd) * rubPerUsd;
};
// Статистика
const totalMonthlyCosts = billingData.reduce((sum, item) => {
return sum + convertToRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD');
}, 0);
const totalPaymentsCount = billingData.reduce((sum, item) => {
return sum + ((item.payments && Array.isArray(item.payments) ? item.payments.length : 0));
}, 0);
const urgentPayments = billingData.filter(item => {
const nextPayment = new Date(item.nextPaymentDate);
const now = new Date();
const diffDays = Math.ceil((nextPayment - now) / (1000 * 60 * 60 * 24));
return diffDays <= 7 && diffDays >= 0;
});
const overduePayments = billingData.filter(item => {
const nextPayment = new Date(item.nextPaymentDate);
const now = new Date();
return nextPayment < now;
});
// Расчет дней до платежа
const getDaysUntilPayment = (dateString) => {
if (!dateString) return null;
const nextPayment = new Date(dateString);
const now = new Date();
return Math.ceil((nextPayment - now) / (1000 * 60 * 60 * 24));
};
// Вычислить nextPaymentDate по платежу: сумма / месячная стоимость = кол-во месяцев
const computeNextPaymentDate = (payment, server) => {
if (!payment?.date) return '';
const amountRUB = convertToRUB(payment.amount, payment.currency || 'USD');
const costRUB = convertToRUB(server.monthlyCost || 0, server.monthlyCostCurrency || 'USD');
const months = costRUB > 0 ? Math.max(1, Math.floor(amountRUB / costRUB)) : 1;
const d = new Date(payment.date);
d.setMonth(d.getMonth() + months);
return d.toISOString().slice(0, 10);
};
// Форматирование
function formatDate(dateString) {
if (!dateString) return '—';
try {
return new Date(dateString).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
} catch {
return dateString;
}
}
function formatCurrency(amount, currency = 'USD') {
if (amount === undefined || amount === null || amount === '') return '—';
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount);
}
function formatCurrencyInRUB(amount, currency) {
const rubAmount = convertToRUB(amount, currency);
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(rubAmount);
}
// Фильтрация и сортировка
const filteredData = billingData
.filter(item => {
const matchesSearch =
item.hostName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.provider?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = !filterStatus || item.status === filterStatus;
let matchesUrgency = true;
if (filterUrgency === 'overdue') {
const days = getDaysUntilPayment(item.nextPaymentDate);
matchesUrgency = days !== null && days < 0;
} else if (filterUrgency === 'urgent') {
const days = getDaysUntilPayment(item.nextPaymentDate);
matchesUrgency = days !== null && days >= 0 && days <= 7;
} else if (filterUrgency === 'soon') {
const days = getDaysUntilPayment(item.nextPaymentDate);
matchesUrgency = days !== null && days > 7 && days <= 30;
}
return matchesSearch && matchesStatus && matchesUrgency;
})
.sort((a, b) => {
let aValue = a[sortField];
let bValue = b[sortField];
if (sortField === 'nextPaymentDate') {
aValue = new Date(aValue || '9999-12-31');
bValue = new Date(bValue || '9999-12-31');
} else if (sortField === 'monthlyCost') {
aValue = convertToRUB(a.monthlyCost, a.monthlyCostCurrency);
bValue = convertToRUB(b.monthlyCost, b.monthlyCostCurrency);
}
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 handleSort = (field) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
// CRUD операции
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.provider) {
setError('Заполните обязательные поля: Имя хоста, Провайдер');
return;
}
const newId = Math.max(...billingData.map(item => parseInt(item.id) || 0), 0) + 1;
setBillingData(prev => [...prev, { ...newItem, id: newId.toString() }]);
setShowAddModal(false);
setSuccess('Элемент добавлен');
setTimeout(() => setSuccess(''), 3000);
};
const handleEditSubmit = () => {
if (!editingItem.hostName || !editingItem.provider) {
setError('Заполните обязательные поля');
return;
}
// Снимаем флаг _external при редактировании (запись теперь заполнена)
setBillingData(prev => prev.map(item =>
item.id === editingItem.id ? { ...editingItem, _external: false } : item
));
setShowEditModal(false);
setSuccess('Элемент обновлен');
setTimeout(() => setSuccess(''), 3000);
};
// Удаление платежа
const handleDeletePayment = () => {
if (!paymentToDelete) return;
const { serverId, index } = paymentToDelete;
setBillingData(prev => prev.map(s => {
if (s.id !== serverId) return s;
const payments = [...(s.payments || [])];
payments.splice(index, 1);
const last = payments.slice().sort((a,b) => new Date(b.date) - new Date(a.date))[0];
const nextPaymentDate = last ? computeNextPaymentDate(last, s) : '';
return {
...s,
payments,
lastPaymentDate: last?.date || '',
lastPaymentAmount: last?.amount || 0,
lastPaymentCurrency: last?.currency || 'USD',
nextPaymentDate: nextPaymentDate || s.nextPaymentDate
};
}));
setShowDeletePaymentModal(false);
setPaymentToDelete(null);
setSuccess('Платеж удалён');
setTimeout(() => setSuccess(''), 3000);
};
// Экспорт данных
const exportData = () => {
const csvContent = [
['Host', 'Provider', 'Monthly Cost', 'Currency', 'Next Payment', 'Status'],
...filteredData.map(item => [
item.hostName,
item.provider,
item.monthlyCost,
item.monthlyCostCurrency,
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-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
};
// Компонент бейджа срочности
const UrgencyBadge = ({ days }) => {
if (days === null) return <span className="text-muted"></span>;
if (days < 0) {
return (
<span className="badge bg-red-lt text-red">
<IconAlertTriangle size={12} className="me-1" />
{Math.abs(days)} дн. назад
</span>
);
} else if (days === 0) {
return (
<span className="badge bg-red-lt text-red">
Сегодня
</span>
);
} else if (days <= 7) {
return (
<span className="badge bg-yellow-lt text-yellow">
{days} дн.
</span>
);
} else if (days <= 30) {
return (
<span className="badge bg-azure-lt text-azure">
{days} дн.
</span>
);
} else {
return (
<span className="text-muted">
{days} дн.
</span>
);
}
};
return (
<div>
{/* Заголовок */}
<PageHeader
title="Биллинг"
icon={<IconCreditCard size={24} />}
actions={
<div className="d-flex flex-column flex-sm-row align-items-stretch align-items-sm-center gap-2">
<div className="btn-group me-sm-2" role="group">
<button
className={`btn ${activeTab === 'subscriptions' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('subscriptions')}
>
<IconServer size={16} className="me-1 d-none d-sm-inline" />
Подписки
<span className={`badge ms-1 ${activeTab === 'subscriptions' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
{billingData.length}
</span>
</button>
<button
className={`btn ${activeTab === 'payments' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('payments')}
>
<IconHistory size={16} className="me-1 d-none d-sm-inline" />
История платежей
<span className={`badge ms-1 ${activeTab === 'payments' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
{totalPaymentsCount}
</span>
</button>
</div>
<PageHeaderActions
loading={loading}
onRefresh={() => { fetchBillingData(); fetchServers(); }}
disableRefresh={loading}
onExport={exportData}
disableExport={loading}
onSave={handleSaveChanges}
disableSave={loading}
/>
</div>
}
/>
{/* Уведомления */}
{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" size={18} />
{success}
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
{/* Статистика */}
<div className="row g-3 mb-4">
<div className="col-md-4">
<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">
<IconCurrencyDollar size={32} />
</span>
<div>
<div className="h3 mb-0 fw-bold">
{formatCurrencyInRUB(totalMonthlyCosts, 'RUB')}
</div>
<div className="text-muted small">Месячные затраты</div>
</div>
</div>
</div>
</div>
<div className="col-md-4">
<div className="card h-100">
<div className="card-body d-flex align-items-center">
<span className={`avatar avatar-lg me-3 ${overduePayments.length > 0 ? 'bg-red-lt text-red' : 'bg-green-lt text-green'} border-0`}>
<IconAlertTriangle size={32} />
</span>
<div>
<div className="h3 mb-0 fw-bold">
{overduePayments.length}
</div>
<div className="text-muted small">Просрочено платежей</div>
</div>
</div>
</div>
</div>
<div className="col-md-4">
<div className="card h-100">
<div className="card-body d-flex align-items-center">
<span className={`avatar avatar-lg me-3 ${urgentPayments.length > 0 ? 'bg-yellow-lt text-yellow' : 'bg-azure-lt text-azure'} border-0`}>
<IconClock size={32} />
</span>
<div>
<div className="h3 mb-0 fw-bold">
{urgentPayments.length}
</div>
<div className="text-muted small">Оплата на этой неделе</div>
</div>
</div>
</div>
</div>
</div>
{/* Основная секция: Подписки */}
{activeTab === 'subscriptions' && (
<div className="card">
<div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<h3 className="card-title mb-0">
Подписки
<span className="badge bg-blue-lt text-blue ms-2">{filteredData.length}</span>
</h3>
<div className="d-flex flex-wrap align-items-center gap-2 flex-grow-1 justify-content-end">
{/* Поиск */}
<div className="input-icon" style={{ minWidth: '220px', maxWidth: '280px' }}>
<span className="input-icon-addon">
<IconSearch size={16} />
</span>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск..."
value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }}
/>
</div>
{/* Быстрые фильтры */}
<div className="btn-group" role="group">
<button
className={`btn ${filterUrgency === '' ? 'btn-outline-primary active' : 'btn-outline-secondary'}`}
onClick={() => { setFilterUrgency(''); setCurrentPage(1); }}
>
Все
</button>
<button
className={`btn ${filterUrgency === 'overdue' ? 'btn-outline-danger active' : 'btn-outline-secondary'}`}
onClick={() => { setFilterUrgency('overdue'); setCurrentPage(1); }}
>
Просрочено
</button>
<button
className={`btn ${filterUrgency === 'urgent' ? 'btn-outline-warning active' : 'btn-outline-secondary'}`}
onClick={() => { setFilterUrgency('urgent'); setCurrentPage(1); }}
>
Срочно
</button>
</div>
{/* Переключатель вида */}
<div className="btn-group" role="group">
<button
className={`btn ${viewMode === 'cards' ? 'btn-outline-primary active' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('cards')}
title="Карточки"
>
<IconLayoutGrid size={16} />
</button>
<button
className={`btn ${viewMode === 'table' ? 'btn-outline-primary active' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('table')}
title="Таблица"
>
<IconList size={16} />
</button>
</div>
<div className="card-actions">
<button className="btn btn-primary" onClick={handleAddItem}>
<IconPlus size={18} className="me-1" />
Добавить
</button>
</div>
</div>
</div>
{/* Контент: карточки или таблица */}
{paginatedData.length === 0 ? (
<div className="card-body text-center text-muted py-5">
<IconCreditCard size={48} className="mb-3 text-muted" />
<h3>{searchTerm || filterUrgency ? 'Ничего не найдено' : 'Нет подписок'}</h3>
<p className="text-muted">Добавьте первую подписку для отслеживания платежей</p>
<button className="btn btn-primary" onClick={handleAddItem}>
<IconPlus size={18} className="me-1" />
Добавить подписку
</button>
</div>
) : viewMode === 'cards' ? (
/* Карточный вид */
<div className="card-body">
{paginatedData.map(item => {
const days = getDaysUntilPayment(item.nextPaymentDate);
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
return (
<BillingCard
key={item.id}
item={item}
linkedServer={linkedServer}
daysUntilPayment={days}
onEdit={handleEditItem}
onDelete={handleDeleteItem}
onAddPayment={(item) => {
setPaymentDraft({
serverId: item.id,
date: new Date().toISOString().slice(0,10),
amount: item.monthlyCost || 0,
currency: item.monthlyCostCurrency || 'USD',
note: ''
});
setShowPaymentModal(true);
}}
formatCurrencyInRUB={formatCurrencyInRUB}
/>
);
})}
</div>
) : (
/* Табличный вид */
<div className="table-responsive">
<table className="table card-table table-vcenter mb-0">
<thead>
<tr>
<th
className="cursor-pointer"
onClick={() => handleSort('hostName')}
style={{ minWidth: '200px' }}
>
Сервис
{sortField === 'hostName' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer text-end"
onClick={() => handleSort('monthlyCost')}
style={{ width: '140px' }}
>
Стоимость
{sortField === 'monthlyCost' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer text-center"
onClick={() => handleSort('nextPaymentDate')}
style={{ width: '120px' }}
>
Платёж
{sortField === 'nextPaymentDate' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th className="text-center" style={{ width: '100px' }}>Срок</th>
<th className="text-center" style={{ width: '80px' }}>Статус</th>
<th style={{ width: '140px' }}></th>
</tr>
</thead>
<tbody>
{paginatedData.map(item => {
const days = getDaysUntilPayment(item.nextPaymentDate);
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
return (
<tr key={item.id}>
<td>
<div className="d-flex align-items-center">
<div>
<div className="fw-semibold">
{item.hostName}
{item._external && (
<span className="badge bg-yellow-lt text-yellow ms-2" title="Импортирован из серверов, требует заполнения">
не заполнен
</span>
)}
</div>
<div className="text-muted small d-flex align-items-center gap-2">
<span>{item.provider}</span>
{linkedServer && (
<>
<span></span>
<span className="text-blue">{linkedServer.ip}</span>
</>
)}
{item.loginUrl && (
<a
href={item.loginUrl.startsWith('http') ? item.loginUrl : `https://${item.loginUrl}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted"
title="Открыть панель"
>
<IconExternalLink size={12} />
</a>
)}
</div>
</div>
</div>
</td>
<td className="text-end">
<div className="fw-semibold">
{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
</div>
{item.monthlyCostCurrency !== 'RUB' && (
<div className="text-muted small">
{formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
</div>
)}
</td>
<td className="text-center">
{formatDate(item.nextPaymentDate)}
</td>
<td className="text-center">
<UrgencyBadge days={days} />
</td>
<td className="text-center">
{item.status === 'active' ? (
<span className="badge bg-green-lt text-green">Активен</span>
) : (
<span className="badge bg-secondary-lt text-secondary">Неактивен</span>
)}
</td>
<td className="text-end">
<div className="btn-group" role="group">
<button
className="btn btn-outline-success"
onClick={() => {
setPaymentDraft({
serverId: item.id,
date: new Date().toISOString().slice(0,10),
amount: item.monthlyCost || 0,
currency: item.monthlyCostCurrency || 'USD',
note: ''
});
setShowPaymentModal(true);
}}
title="Записать платёж"
>
<IconCreditCard size={14} className="me-1" />
Оплата
</button>
<button
className="btn btn-outline-secondary"
onClick={() => handleEditItem(item)}
title="Редактировать"
>
<IconEdit size={14} />
</button>
<button
className="btn btn-outline-danger"
onClick={() => handleDeleteItem(item)}
title="Удалить"
>
<IconTrash size={14} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={filteredData.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
)}
</div>
)}
{/* История платежей — компактная версия */}
{activeTab === 'payments' && (
<div className="card mt-4">
<div className="card-header">
<h3 className="card-title d-flex align-items-center gap-2">
<IconHistory size={18} />
История платежей
</h3>
<div className="card-actions">
<button
className="btn btn-primary"
onClick={() => {
setPaymentDraft({ serverId: '', date: '', amount: 0, currency: 'USD', note: '' });
setShowPaymentModal(true);
}}
>
<IconPlus size={18} className="me-1" />
Добавить
</button>
</div>
</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table card-table table-vcenter mb-0">
<thead>
<tr>
<th>Сервис</th>
<th>Дата</th>
<th className="text-end">Сумма</th>
<th>Комментарий</th>
<th style={{ width: '80px' }}></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.note?.toLowerCase().includes(term)
);
}
// Сортировка по дате (новые сверху)
allRows.sort((a, b) => new Date(b.date) - new Date(a.date));
// Пагинация
const paginatedRows = allRows.slice(0, paymentPageSize);
if (paginatedRows.length === 0) {
return (
<tr>
<td colSpan="5" className="text-center text-muted py-4">
Платежей пока нет
</td>
</tr>
);
}
return paginatedRows.map(r => (
<tr key={r._key}>
<td>
<span className="fw-medium">{r.server.hostName}</span>
<span className="text-muted ms-2 small">{r.server.provider}</span>
</td>
<td>{formatDate(r.date)}</td>
<td className="text-end">
<span className="fw-semibold">{formatCurrency(r.amount, r.currency)}</span>
</td>
<td className="text-muted small">{r.note || '—'}</td>
<td>
<div className="btn-list gap-1 justify-content-end flex-nowrap">
<button
className="btn btn-icon btn-sm btn-outline-secondary"
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);
}}
title="Редактировать"
>
<IconEdit size={14} />
</button>
<button
className="btn btn-icon btn-sm btn-outline-danger"
onClick={() => {
setPaymentToDelete({ serverId: r.server.id, index: r._index, data: r });
setShowDeletePaymentModal(true);
}}
title="Удалить"
>
<IconTrash size={14} />
</button>
</div>
</td>
</tr>
));
})()}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Модальные окна */}
<FormModal
show={showAddModal}
title="Добавить подписку"
onSubmit={(e) => { e.preventDefault(); handleAddSubmit(); }}
onClose={() => setShowAddModal(false)}
submitLabel="Добавить"
submitIcon={IconPlus}
size="md"
>
<BillingForm
item={newItem}
onItemChange={setNewItem}
servers={servers}
/>
</FormModal>
<FormModal
show={showEditModal}
title="Редактировать подписку"
onSubmit={(e) => { e.preventDefault(); handleEditSubmit(); }}
onClose={() => setShowEditModal(false)}
submitLabel="Сохранить"
submitIcon={IconCheck}
size="md"
>
<BillingForm
item={editingItem}
onItemChange={setEditingItem}
servers={servers}
/>
</FormModal>
<ConfirmModal
show={showDeleteModal}
title="Удалить подписку"
message={
<>
Удалить <strong>{selectedItem?.hostName}</strong>?
<div className="text-muted small mt-1">Это действие нельзя отменить</div>
</>
}
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;
}
if (editingPayment) {
setBillingData(prev => prev.map(s => {
if (s.id !== editingPayment.serverId) return s;
const payments = [...(s.payments || [])];
payments[editingPayment.index] = {
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];
const nextPaymentDate = computeNextPaymentDate(last, s);
return { ...s, payments, lastPaymentDate: last?.date || '', lastPaymentAmount: last?.amount || 0, lastPaymentCurrency: last?.currency || 'USD', nextPaymentDate: nextPaymentDate || s.nextPaymentDate };
}));
setEditingPayment(null);
} else {
setBillingData(prev => prev.map(s => {
if (s.id !== paymentDraft.serverId) return s;
const payments = [...(s.payments || []), {
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];
const nextPaymentDate = computeNextPaymentDate(last, s);
return { ...s, payments, lastPaymentDate: last?.date || '', lastPaymentAmount: last?.amount || 0, lastPaymentCurrency: last?.currency || 'USD', nextPaymentDate: nextPaymentDate || s.nextPaymentDate };
}));
}
setShowPaymentModal(false);
setSuccess(editingPayment ? 'Платеж обновлен' : 'Платеж записан');
setTimeout(() => setSuccess(''), 3000);
}}
onClose={() => { setShowPaymentModal(false); setEditingPayment(null); }}
submitLabel={editingPayment ? "Сохранить" : "Записать"}
submitIcon={editingPayment ? IconCheck : IconPlus}
>
<PaymentForm
servers={billingData}
payment={paymentDraft}
onChange={setPaymentDraft}
/>
</FormModal>
<ConfirmModal
show={showDeletePaymentModal}
title="Удалить платеж"
message={
paymentToDelete && (
<>
Удалить платеж от <strong>{formatDate(paymentToDelete.data?.date)}</strong> на сумму{' '}
<strong>{formatCurrency(paymentToDelete.data?.amount, paymentToDelete.data?.currency)}</strong>?
</>
)
}
onConfirm={handleDeletePayment}
onClose={() => setShowDeletePaymentModal(false)}
confirmLabel="Удалить"
variant="danger"
/>
</div>
);
}
// Компактная форма для добавления/редактирования подписки
function BillingForm({ item, onItemChange, servers }) {
const currencyOptions = [
{ value: 'USD', label: 'USD' },
{ value: 'EUR', label: 'EUR' },
{ value: 'RUB', label: 'RUB' }
];
return (
<div className="row g-3">
<div className="col-12">
<FormField
label="Имя сервиса"
name="hostName"
value={item.hostName}
onChange={(value) => onItemChange({ ...item, hostName: value })}
placeholder="Например: Hetzner VPS #1"
required
/>
</div>
<div className="col-md-6">
<FormField
label="Провайдер"
name="provider"
value={item.provider}
onChange={(value) => onItemChange({ ...item, provider: value })}
placeholder="Hetzner, AWS, etc."
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="Опционально"
/>
</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-6">
<FormField
label="Ссылка для входа"
name="loginUrl"
value={item.loginUrl}
onChange={(value) => onItemChange({ ...item, loginUrl: value })}
placeholder="panel.hetzner.com"
/>
</div>
<div className="col-12">
<div className="form-label">Статус</div>
<div className="d-flex gap-2">
<label className="form-selectgroup-item flex-fill">
<input
type="radio"
name="status"
value="active"
className="form-selectgroup-input"
checked={item.status === 'active'}
onChange={() => onItemChange({ ...item, status: 'active' })}
/>
<span className="form-selectgroup-label">
<IconCheck size={16} className="me-1" />
Активен
</span>
</label>
<label className="form-selectgroup-item flex-fill">
<input
type="radio"
name="status"
value="inactive"
className="form-selectgroup-input"
checked={item.status === 'inactive'}
onChange={() => onItemChange({ ...item, status: 'inactive' })}
/>
<span className="form-selectgroup-label">
<IconX size={16} className="me-1" />
Неактивен
</span>
</label>
</div>
</div>
<div className="col-12">
<FormField
label="Заметки"
name="notes"
value={item.notes}
onChange={(value) => onItemChange({ ...item, notes: value })}
placeholder="Опционально"
/>
</div>
</div>
);
}
// Форма добавления платежа
function PaymentForm({ servers, payment, onChange }) {
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) {
onChange({
...payment,
serverId,
amount: server.monthlyCost || 0,
currency: server.monthlyCostCurrency || 'USD'
});
} else {
onChange({ ...payment, serverId });
}
};
return (
<div className="row g-3">
<div className="col-12">
<FormField
label="Сервис"
name="serverId"
type="select"
value={payment.serverId}
onChange={handleServerChange}
options={serverOptions}
required
/>
</div>
<div className="col-12">
<FormField
label="Дата платежа"
name="date"
type="date"
value={payment.date}
onChange={(value) => onChange({ ...payment, date: value })}
required
/>
</div>
<div className="col-md-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-md-4">
<FormField
label="Валюта"
name="currency"
type="select"
value={payment.currency}
onChange={(value) => onChange({ ...payment, currency: value })}
options={currencyOptions}
/>
</div>
<div className="col-12">
<FormField
label="Комментарий"
name="note"
value={payment.note}
onChange={(value) => onChange({ ...payment, note: value })}
placeholder="Опционально"
/>
</div>
</div>
);
}
export default BillingManager;