1437 lines
53 KiB
React
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;
|