diff --git a/frontend/src/BillingManager.jsx b/frontend/src/BillingManager.jsx index 86abb41..6875219 100644 --- a/frontend/src/BillingManager.jsx +++ b/frontend/src/BillingManager.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import api from './lib/api.js'; import axios from 'axios'; import FormModal from './components/FormModal.jsx'; @@ -41,8 +41,43 @@ import { import BulkActionsBar from './components/BulkActionsBar.jsx'; import { BillingCard } from './components/billing/index.js'; +function normalizeBillingItems(rawItems, rates) { + 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; + }; + return (rawItems || []).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; + }); +} + function BillingManager() { - const [billingData, setBillingData] = useState([]); + const [rawBillingData, setRawBillingData] = useState([]); const [servers, setServers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -167,51 +202,17 @@ function BillingManager() { const opts = abortSignal ? { signal: abortSignal } : {}; try { const response = await api.get(`/billing`, opts); - const rates = exchangeRates; - 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); + setRawBillingData(Array.isArray(response.data) ? response.data : []); setError(''); } catch (err) { if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return; console.error('Ошибка при загрузке данных биллинга:', err); setError('Ошибка при загрузке данных'); - setBillingData([]); + setRawBillingData([]); } finally { setLoading(false); } - }, [exchangeRates]); + }, []); useEffect(() => { const controller = new AbortController(); @@ -222,11 +223,17 @@ function BillingManager() { return () => controller.abort(); }, [fetchBillingData, fetchExchangeRates, fetchServers]); + // Нормализация с учётом курсов валют (без повторных запросов) + const billingData = useMemo( + () => normalizeBillingItems(rawBillingData, exchangeRates), + [rawBillingData, exchangeRates] + ); + // Автоматическая синхронизация: добавляем серверы без записей в биллинге useEffect(() => { if (servers.length === 0 || loading) return; - setBillingData((prev) => { + setRawBillingData((prev) => { const existingServerIds = new Set( prev.map(b => b.serverId).filter(Boolean) ); @@ -273,7 +280,7 @@ function BillingManager() { const payload = billingData.map(({ _external: _ext, ...rest }) => rest); await api.post(`/billing`, { domains: payload }); // После сохранения снимаем флаг _external со всех записей - setBillingData(prev => prev.map(item => ({ ...item, _external: false }))); + setRawBillingData(prev => prev.map(item => ({ ...item, _external: false }))); setSuccess('Данные успешно сохранены'); setTimeout(() => setSuccess(''), 3000); } catch (err) { @@ -463,7 +470,7 @@ function BillingManager() { const confirmDelete = () => { if (selectedItem) { - setBillingData(prev => prev.filter(item => item.id !== selectedItem.id)); + setRawBillingData(prev => prev.filter(item => item.id !== selectedItem.id)); setShowDeleteModal(false); setSelectedItem(null); } @@ -476,7 +483,7 @@ function BillingManager() { } const newId = Math.max(...billingData.map(item => parseInt(item.id) || 0), 0) + 1; - setBillingData(prev => [...prev, { ...newItem, id: newId.toString() }]); + setRawBillingData(prev => [...prev, { ...newItem, id: newId.toString() }]); setShowAddModal(false); setSuccess('Элемент добавлен'); setTimeout(() => setSuccess(''), 3000); @@ -489,7 +496,7 @@ function BillingManager() { } // Снимаем флаг _external при редактировании (запись теперь заполнена) - setBillingData(prev => prev.map(item => + setRawBillingData(prev => prev.map(item => item.id === editingItem.id ? { ...editingItem, _external: false } : item )); setShowEditModal(false); @@ -501,7 +508,7 @@ function BillingManager() { const handleDeletePayment = () => { if (!paymentToDelete) return; const { serverId, index } = paymentToDelete; - setBillingData(prev => prev.map(s => { + setRawBillingData(prev => prev.map(s => { if (s.id !== serverId) return s; const payments = [...(s.payments || [])]; payments.splice(index, 1); @@ -1148,7 +1155,7 @@ function BillingManager() { } if (editingPayment) { - setBillingData(prev => prev.map(s => { + setRawBillingData(prev => prev.map(s => { if (s.id !== editingPayment.serverId) return s; const payments = [...(s.payments || [])]; payments[editingPayment.index] = { @@ -1163,7 +1170,7 @@ function BillingManager() { })); setEditingPayment(null); } else { - setBillingData(prev => prev.map(s => { + setRawBillingData(prev => prev.map(s => { if (s.id !== paymentDraft.serverId) return s; const payments = [...(s.payments || []), { date: paymentDraft.date,