feat(BillingManager): implement billing items normalization and refactor state management for improved data handling
Publish Docker image / build-and-push (push) Successful in 1m45s
Publish Docker image / build-and-push (push) Successful in 1m45s
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user