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 —;
if (days < 0) {
return (
Добавьте первую подписку для отслеживания платежей
| handleSort('hostName')} style={{ minWidth: '200px' }} > Сервис {sortField === 'hostName' && ( {sortOrder === 'asc' ? '↑' : '↓'} )} | handleSort('monthlyCost')} style={{ width: '140px' }} > Стоимость {sortField === 'monthlyCost' && ( {sortOrder === 'asc' ? '↑' : '↓'} )} | handleSort('nextPaymentDate')} style={{ width: '120px' }} > Платёж {sortField === 'nextPaymentDate' && ( {sortOrder === 'asc' ? '↑' : '↓'} )} | Срок | Статус | |
|---|---|---|---|---|---|
|
{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
{item.monthlyCostCurrency !== 'RUB' && (
≈ {formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
)}
|
{formatDate(item.nextPaymentDate)} |
|
{item.status === 'active' ? ( Активен ) : ( Неактивен )} |
|
| Сервис | Дата | Сумма | Комментарий | |
|---|---|---|---|---|
| Платежей пока нет | ||||
| {r.server.hostName} {r.server.provider} | {formatDate(r.date)} | {formatCurrency(r.amount, r.currency)} | {r.note || '—'} |
|