From 4c541b77da097f8d079acb422b962b15c10b760b Mon Sep 17 00:00:00 2001 From: shats Date: Mon, 1 Dec 2025 13:43:09 +0700 Subject: [PATCH] feat: Enhance BillingManager with advanced filtering, sorting, and bulk actions for payment management. Added UI components for search, date filtering, and improved user experience with confirmation for future payment dates. --- frontend/src/BillingManager.jsx | 653 ++++++++++++++++++++++++++------ 1 file changed, 543 insertions(+), 110 deletions(-) diff --git a/frontend/src/BillingManager.jsx b/frontend/src/BillingManager.jsx index 00a088a..88061d8 100644 --- a/frontend/src/BillingManager.jsx +++ b/frontend/src/BillingManager.jsx @@ -26,8 +26,13 @@ import { IconUpload, IconEye, IconEyeOff, - IconHistory + IconHistory, + IconChevronUp, + IconChevronDown, + IconX, + IconChecks } from '@tabler/icons-react'; +import BulkActionsBar from './components/BulkActionsBar.jsx'; function BillingManager() { const [billingData, setBillingData] = useState([]); @@ -63,6 +68,21 @@ function BillingManager() { const [editingPayment, setEditingPayment] = useState(null); // { serverId, index } const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false); const [paymentToDelete, setPaymentToDelete] = useState(null); // { serverId, index, data } + const [futureDateConfirm, setFutureDateConfirm] = useState(false); // Подтверждение будущей даты + + // Состояния для истории платежей: поиск, сортировка, фильтры + const [paymentSearchTerm, setPaymentSearchTerm] = useState(''); + const [paymentSortField, setPaymentSortField] = useState('date'); + const [paymentSortOrder, setPaymentSortOrder] = useState('desc'); + const [paymentFilterServer, setPaymentFilterServer] = useState(''); + const [paymentFilterDateFrom, setPaymentFilterDateFrom] = useState(''); + const [paymentFilterDateTo, setPaymentFilterDateTo] = useState(''); + const [paymentFilterAmountFrom, setPaymentFilterAmountFrom] = useState(''); + const [paymentFilterAmountTo, setPaymentFilterAmountTo] = useState(''); + const [showPaymentFilters, setShowPaymentFilters] = useState(false); + const [selectedPayments, setSelectedPayments] = useState(new Set()); + const [paymentPage, setPaymentPage] = useState(1); + const paymentPageSize = 20; // Состояние для курсов валют const [exchangeRates, setExchangeRates] = useState({ @@ -868,23 +888,272 @@ function BillingManager() { История платежей
- + + Фильтры + + +
+ {/* Поиск и фильтры */} +
+
+ + + + { setPaymentSearchTerm(e.target.value); setPaymentPage(1); }} + aria-label="Поиск платежей" + style={{ paddingRight: paymentSearchTerm ? '2.5rem' : undefined }} + /> + {paymentSearchTerm && ( + + )} +
+
+ + {/* Панель фильтров */} + {showPaymentFilters && ( +
+
+
+
+ + +
+
+ + { setPaymentFilterDateFrom(e.target.value); setPaymentPage(1); }} + /> +
+
+ + { setPaymentFilterDateTo(e.target.value); setPaymentPage(1); }} + /> +
+
+ + { setPaymentFilterAmountFrom(e.target.value); setPaymentPage(1); }} + /> +
+
+ + { setPaymentFilterAmountTo(e.target.value); setPaymentPage(1); }} + /> +
+
+ +
+
+
+
+ )} + + {/* Bulk Actions Bar */} + { + const allRows = billingData.flatMap(server => + (server.payments || []).map((p, idx) => ({ server, ...p, _key: `${server.id}-${idx}`, _index: idx })) + ); + return allRows.length; + })()} + onSelectAll={() => { + const allRows = billingData.flatMap(server => + (server.payments || []).map((p, idx) => ({ server, ...p, _key: `${server.id}-${idx}`, _index: idx })) + ); + setSelectedPayments(new Set(allRows.map(r => r._key))); + }} + onDeselectAll={() => setSelectedPayments(new Set())} + onDelete={() => { + if (selectedPayments.size === 0) return; + const keys = Array.from(selectedPayments); + setBillingData(prev => prev.map(server => { + const payments = Array.isArray(server.payments) ? [...server.payments] : []; + const filtered = payments.filter((p, idx) => { + const key = `${server.id}-${idx}`; + return !keys.includes(key); + }); + const last = filtered + .slice() + .sort((a,b) => new Date(b.date) - new Date(a.date))[0]; + return { + ...server, + payments: filtered, + lastPaymentDate: last?.date || '', + lastPaymentAmount: last?.amount || 0, + lastPaymentCurrency: last?.currency || server.monthlyCostCurrency || 'USD' + }; + })); + setSelectedPayments(new Set()); + setSuccess(`Удалено платежей: ${selectedPayments.size}`); + setTimeout(() => setSuccess(''), 3000); + }} + onEdit={() => { + // Массовое редактирование - можно добавить позже + setError('Массовое редактирование пока не реализовано'); + setTimeout(() => setError(''), 3000); + }} + /> + + {/* Таблица платежей */}
- - - + + + + @@ -892,106 +1161,203 @@ function BillingManager() { {(() => { - const rows = billingData.flatMap(server => (server.payments || []).map((p, idx) => ({ server, ...p, _key: `${server.id}-${idx}`, _index: idx }))).sort((a,b) => new Date(b.date) - new Date(a.date)); - if (rows.length === 0) { - return null; + // Получаем все платежи + 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.server.provider?.toLowerCase().includes(term) || + formatDate(r.date).toLowerCase().includes(term) || + String(r.amount).includes(term) || + r.currency?.toLowerCase().includes(term) || + r.note?.toLowerCase().includes(term) + ); } - return rows.slice(0, 20).map(r => ( - - + + + ); + } + + return ( + <> + {paginatedRows.map(r => ( + + - - - - - - - )); + + + + + + + + + ))} + {totalPaymentPages > 1 && ( + + + + )} + + ); })()} - {billingData.every(i => !i.payments || i.payments.length === 0) && ( - - - - )}
Имя хостераДатаСумма + 0 && (() => { + const allRows = billingData.flatMap(server => + (server.payments || []).map((p, idx) => ({ server, ...p, _key: `${server.id}-${idx}`, _index: idx })) + ); + return selectedPayments.size === allRows.length; + })()} + onChange={(e) => { + if (e.target.checked) { + const allRows = billingData.flatMap(server => + (server.payments || []).map((p, idx) => ({ server, ...p, _key: `${server.id}-${idx}`, _index: idx })) + ); + setSelectedPayments(new Set(allRows.map(r => r._key))); + } else { + setSelectedPayments(new Set()); + } + }} + /> + { + if (paymentSortField === 'hostName') { + setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setPaymentSortField('hostName'); + setPaymentSortOrder('asc'); + } + }} + > +
+ Имя хостера + {paymentSortField === 'hostName' && ( + paymentSortOrder === 'asc' ? : + )} +
+
{ + if (paymentSortField === 'date') { + setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setPaymentSortField('date'); + setPaymentSortOrder('desc'); + } + }} + > +
+ Дата + {paymentSortField === 'date' && ( + paymentSortOrder === 'asc' ? : + )} +
+
{ + if (paymentSortField === 'amount') { + setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setPaymentSortField('amount'); + setPaymentSortOrder('desc'); + } + }} + > +
+ Сумма + {paymentSortField === 'amount' && ( + paymentSortOrder === 'asc' ? : + )} +
+
Валюта Комментарий Действия
-
{r.server.hostName}
-
- {r.server.loginUrl && ( - { e.currentTarget.style.display = 'none'; }} + + // Фильтр по серверу + if (paymentFilterServer) { + allRows = allRows.filter(r => r.server.id === paymentFilterServer); + } + + // Фильтр по дате + if (paymentFilterDateFrom) { + allRows = allRows.filter(r => new Date(r.date) >= new Date(paymentFilterDateFrom)); + } + if (paymentFilterDateTo) { + allRows = allRows.filter(r => new Date(r.date) <= new Date(paymentFilterDateTo)); + } + + // Фильтр по сумме + if (paymentFilterAmountFrom) { + const from = parseFloat(paymentFilterAmountFrom); + allRows = allRows.filter(r => r.amount >= from); + } + if (paymentFilterAmountTo) { + const to = parseFloat(paymentFilterAmountTo); + allRows = allRows.filter(r => r.amount <= to); + } + + // Сортировка + allRows.sort((a, b) => { + let valA, valB; + if (paymentSortField === 'date') { + valA = new Date(a.date); + valB = new Date(b.date); + } else if (paymentSortField === 'amount') { + valA = a.amount || 0; + valB = b.amount || 0; + } else if (paymentSortField === 'hostName') { + valA = (a.server.hostName || '').toLowerCase(); + valB = (b.server.hostName || '').toLowerCase(); + } else { + valA = a[paymentSortField] || ''; + valB = b[paymentSortField] || ''; + } + + if (valA < valB) return paymentSortOrder === 'asc' ? -1 : 1; + if (valA > valB) return paymentSortOrder === 'asc' ? 1 : -1; + return 0; + }); + + // Пагинация + const totalPaymentPages = Math.max(1, Math.ceil(allRows.length / paymentPageSize)); + const paginatedRows = allRows.slice( + (paymentPage - 1) * paymentPageSize, + paymentPage * paymentPageSize + ); + + if (paginatedRows.length === 0) { + return ( +
+
+ +
Платежи не найдены
+ {allRows.length === 0 && billingData.every(i => !i.payments || i.payments.length === 0) && ( + <> +
+ Добавьте первый платеж, чтобы начать отслеживание +
+ + + )} +
+
+ { + const newSet = new Set(selectedPayments); + if (e.target.checked) { + newSet.add(r._key); + } else { + newSet.delete(r._key); + } + setSelectedPayments(newSet); + }} /> - )} - {r.server.provider} - - {formatDate(r.date)} -
{formatCurrency(r.amount, r.currency)}
-
{formatCurrencyInRUB(r.amount, r.currency)}
-
{r.currency || 'USD'}{r.note || ''} -
- - -
-
+
{r.server.hostName}
+
+ {r.server.loginUrl && ( + { e.currentTarget.style.display = 'none'; }} + /> + )} + {r.server.provider} +
+
{formatDate(r.date)} +
{formatCurrency(r.amount, r.currency)}
+
{formatCurrencyInRUB(r.amount, r.currency)}
+
{r.currency || 'USD'}{r.note || ''} +
+ + +
+
+ +
-
- -
История платежей пуста
-
- Добавьте первый платеж, чтобы начать отслеживание -
- -
-
@@ -1060,6 +1426,21 @@ function BillingManager() { setTimeout(() => setError(''), 3000); return; } + + // Валидация даты (нельзя добавить платеж в будущем без подтверждения) + const selectedDate = new Date(paymentDraft.date); + const today = new Date(); + today.setHours(0, 0, 0, 0); + selectedDate.setHours(0, 0, 0, 0); + + if (selectedDate > today && !futureDateConfirm) { + if (!window.confirm('Внимание: Выбранная дата в будущем. Вы уверены, что хотите добавить платеж с будущей датой?')) { + return; + } + setFutureDateConfirm(true); + // Продолжаем выполнение после подтверждения + } + // Режим редактирования платежа if (editingPayment && editingPayment.serverId) { setBillingData(prev => prev.map(s => { @@ -1102,12 +1483,14 @@ function BillingManager() { return { ...s, payments, lastPaymentDate: last?.date || '', lastPaymentAmount: last?.amount || 0, lastPaymentCurrency: last?.currency || s.monthlyCostCurrency || 'USD' }; })); setShowPaymentModal(false); + setFutureDateConfirm(false); setSuccess('Платеж добавлен'); setTimeout(() => setSuccess(''), 3000); }} onClose={() => { setShowPaymentModal(false); setEditingPayment(null); + setFutureDateConfirm(false); }} submitLabel={editingPayment ? "Сохранить" : "Добавить"} submitIcon={editingPayment ? IconCheck : IconPlus} @@ -1116,6 +1499,11 @@ function BillingManager() { servers={billingData} payment={paymentDraft} onChange={setPaymentDraft} + onDateChange={(date, requiresConfirm) => { + if (requiresConfirm) { + setFutureDateConfirm(false); + } + }} /> @@ -1417,7 +1805,7 @@ function EditBillingForm({ item, onItemChange }) { } // Форма добавления платежа -function AddPaymentForm({ servers, payment, onChange }) { +function AddPaymentForm({ servers, payment, onChange, onDateChange }) { const serverOptions = [ { value: '', label: 'Выберите сервер' }, ...servers.map(s => ({ value: s.id, label: `${s.hostName} (${s.provider})` })) @@ -1429,6 +1817,49 @@ function AddPaymentForm({ servers, payment, onChange }) { { value: 'RUB', label: 'RUB' } ]; + // Автозаполнение при выборе сервера + const handleServerChange = (serverId) => { + const server = servers.find(s => s.id === serverId); + if (server) { + // Используем последний платеж или месячную стоимость + const lastAmount = server.lastPaymentAmount || server.monthlyCost || 0; + const lastCurrency = server.lastPaymentCurrency || server.monthlyCostCurrency || 'USD'; + + onChange({ + ...payment, + serverId, + amount: lastAmount, + currency: lastCurrency + }); + } else { + onChange({ ...payment, serverId }); + } + }; + + // Валидация даты (нельзя добавить платеж в будущем без подтверждения) + const handleDateChange = (date) => { + if (date) { + const selectedDate = new Date(date); + const today = new Date(); + today.setHours(0, 0, 0, 0); + selectedDate.setHours(0, 0, 0, 0); + + if (selectedDate > today) { + if (onDateChange) { + onDateChange(date, true); // true = требуется подтверждение + } else { + // Если нет обработчика, просто предупреждаем + alert('Внимание: Выбранная дата в будущем. Пожалуйста, подтвердите корректность даты.'); + } + } else { + if (onDateChange) { + onDateChange(date, false); + } + } + } + onChange({ ...payment, date }); + }; + return ( <> onChange({ ...payment, serverId: value })} + onChange={handleServerChange} options={serverOptions} required + helpText={payment.serverId ? "Сумма и валюта заполнены автоматически из последнего платежа" : ""} /> onChange({ ...payment, date: value })} + onChange={handleDateChange} required + helpText="Нельзя добавить платеж в будущем без подтверждения" />