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 ( {Math.abs(days)} дн. назад ); } else if (days === 0) { return ( Сегодня ); } else if (days <= 7) { return ( {days} дн. ); } else if (days <= 30) { return ( {days} дн. ); } else { return ( {days} дн. ); } }; return (
{/* Заголовок */} } actions={
{ fetchBillingData(); fetchServers(); }} disableRefresh={loading} onExport={exportData} disableExport={loading} onSave={handleSaveChanges} disableSave={loading} />
} /> {/* Уведомления */} {error && ( setError('')} onRetry={fetchBillingData} className="mb-4" /> )} {success && (
{success}
)} {/* Статистика */}
{formatCurrencyInRUB(totalMonthlyCosts, 'RUB')}
Месячные затраты
0 ? 'bg-red-lt text-red' : 'bg-green-lt text-green'} border-0`}>
{overduePayments.length}
Просрочено платежей
0 ? 'bg-yellow-lt text-yellow' : 'bg-azure-lt text-azure'} border-0`}>
{urgentPayments.length}
Оплата на этой неделе
{/* Основная секция: Подписки */} {activeTab === 'subscriptions' && (

Подписки {filteredData.length}

{/* Поиск */}
{ setSearchTerm(e.target.value); setCurrentPage(1); }} />
{/* Быстрые фильтры */}
{/* Переключатель вида */}
{/* Контент: карточки или таблица */} {paginatedData.length === 0 ? (

{searchTerm || filterUrgency ? 'Ничего не найдено' : 'Нет подписок'}

Добавьте первую подписку для отслеживания платежей

) : viewMode === 'cards' ? ( /* Карточный вид */
{paginatedData.map(item => { const days = getDaysUntilPayment(item.nextPaymentDate); const linkedServer = item.serverId ? serversById.get(item.serverId) : null; return ( { setPaymentDraft({ serverId: item.id, date: new Date().toISOString().slice(0,10), amount: item.monthlyCost || 0, currency: item.monthlyCostCurrency || 'USD', note: '' }); setShowPaymentModal(true); }} formatCurrencyInRUB={formatCurrencyInRUB} /> ); })}
) : ( /* Табличный вид */
{paginatedData.map(item => { const days = getDaysUntilPayment(item.nextPaymentDate); const linkedServer = item.serverId ? serversById.get(item.serverId) : null; 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' ? '↑' : '↓'} )} Срок Статус
{item.hostName} {item._external && ( не заполнен )}
{item.provider} {linkedServer && ( <> {linkedServer.ip} )} {item.loginUrl && ( )}
{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
{item.monthlyCostCurrency !== 'RUB' && (
≈ {formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
)}
{formatDate(item.nextPaymentDate)} {item.status === 'active' ? ( Активен ) : ( Неактивен )}
)} {totalPages > 1 && ( )}
)} {/* История платежей — компактная версия */} {activeTab === 'payments' && (

История платежей

{(() => { 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 ( ); } return paginatedRows.map(r => ( )); })()}
Сервис Дата Сумма Комментарий
Платежей пока нет
{r.server.hostName} {r.server.provider} {formatDate(r.date)} {formatCurrency(r.amount, r.currency)} {r.note || '—'}
)} {/* Модальные окна */} { e.preventDefault(); handleAddSubmit(); }} onClose={() => setShowAddModal(false)} submitLabel="Добавить" submitIcon={IconPlus} size="md" > { e.preventDefault(); handleEditSubmit(); }} onClose={() => setShowEditModal(false)} submitLabel="Сохранить" submitIcon={IconCheck} size="md" > Удалить {selectedItem?.hostName}?
Это действие нельзя отменить
} onConfirm={confirmDelete} onClose={() => setShowDeleteModal(false)} confirmLabel="Удалить" variant="danger" /> {/* Модалка платежа */} { 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} > Удалить платеж от {formatDate(paymentToDelete.data?.date)} на сумму{' '} {formatCurrency(paymentToDelete.data?.amount, paymentToDelete.data?.currency)}? ) } onConfirm={handleDeletePayment} onClose={() => setShowDeletePaymentModal(false)} confirmLabel="Удалить" variant="danger" />
); } // Компактная форма для добавления/редактирования подписки function BillingForm({ item, onItemChange, servers }) { const currencyOptions = [ { value: 'USD', label: 'USD' }, { value: 'EUR', label: 'EUR' }, { value: 'RUB', label: 'RUB' } ]; return (
onItemChange({ ...item, hostName: value })} placeholder="Например: Hetzner VPS #1" required />
onItemChange({ ...item, provider: value })} placeholder="Hetzner, AWS, etc." required />
onItemChange({ ...item, serverId: value || '' })} servers={servers} placeholder="Опционально" />
onItemChange({ ...item, monthlyCost: parseFloat(value) || 0 })} step="0.01" />
onItemChange({ ...item, monthlyCostCurrency: value })} options={currencyOptions} />
onItemChange({ ...item, nextPaymentDate: value })} />
onItemChange({ ...item, loginUrl: value })} placeholder="panel.hetzner.com" />
Статус
onItemChange({ ...item, notes: value })} placeholder="Опционально" />
); } // Форма добавления платежа 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 (
onChange({ ...payment, date: value })} required />
onChange({ ...payment, amount: parseFloat(value) || 0 })} step="0.01" required />
onChange({ ...payment, currency: value })} options={currencyOptions} />
onChange({ ...payment, note: value })} placeholder="Опционально" />
); } export default BillingManager;