feat(BillingManager): implement billing items normalization and refactor state management for improved data handling
Publish Docker image / build-and-push (push) Successful in 1m45s

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