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.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s

This commit is contained in:
2025-12-01 13:43:09 +07:00
parent b340f73dc6
commit 4c541b77da
+543 -110
View File
@@ -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() {
История платежей
</h3>
<div className="card-actions">
<button
className="btn btn-outline-primary"
onClick={() => { setPaymentDraft({ serverId: '', date: '', amount: 0, currency: 'USD', note: '' }); setShowPaymentModal(true); }}
<div className="btn-list">
<button
className="btn btn-outline-secondary"
onClick={() => setShowPaymentFilters(!showPaymentFilters)}
>
<IconPlus size={16} />
Добавить платеж
</button>
<IconFilter size={16} className="me-1" />
Фильтры
</button>
<button
className="btn btn-primary"
onClick={() => {
setPaymentDraft({ serverId: '', date: '', amount: 0, currency: 'USD', note: '' });
setShowPaymentModal(true);
}}
>
<IconPlus size={16} className="me-1" />
Добавить платеж
</button>
</div>
</div>
</div>
<div className="card-body">
{/* Поиск и фильтры */}
<div className="mb-3">
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск по серверу, дате, сумме, комментарию..."
value={paymentSearchTerm}
onChange={(e) => { setPaymentSearchTerm(e.target.value); setPaymentPage(1); }}
aria-label="Поиск платежей"
style={{ paddingRight: paymentSearchTerm ? '2.5rem' : undefined }}
/>
{paymentSearchTerm && (
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm"
style={{
position: 'absolute',
right: '0.25rem',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 2
}}
onClick={() => { setPaymentSearchTerm(''); setPaymentPage(1); }}
aria-label="Очистить поиск"
>
<IconX size={16} />
</button>
)}
</div>
</div>
{/* Панель фильтров */}
{showPaymentFilters && (
<div className="card mb-3">
<div className="card-body">
<div className="row g-3">
<div className="col-md-3">
<label className="form-label">Сервер</label>
<select
className="form-select"
value={paymentFilterServer}
onChange={(e) => { setPaymentFilterServer(e.target.value); setPaymentPage(1); }}
>
<option value="">Все серверы</option>
{billingData.map(server => (
<option key={server.id} value={server.id}>
{server.hostName} ({server.provider})
</option>
))}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Дата от</label>
<input
type="date"
className="form-control"
value={paymentFilterDateFrom}
onChange={(e) => { setPaymentFilterDateFrom(e.target.value); setPaymentPage(1); }}
/>
</div>
<div className="col-md-3">
<label className="form-label">Дата до</label>
<input
type="date"
className="form-control"
value={paymentFilterDateTo}
onChange={(e) => { setPaymentFilterDateTo(e.target.value); setPaymentPage(1); }}
/>
</div>
<div className="col-md-3">
<label className="form-label">Сумма от</label>
<input
type="number"
className="form-control"
placeholder="0"
step="0.01"
value={paymentFilterAmountFrom}
onChange={(e) => { setPaymentFilterAmountFrom(e.target.value); setPaymentPage(1); }}
/>
</div>
<div className="col-md-3">
<label className="form-label">Сумма до</label>
<input
type="number"
className="form-control"
placeholder="0"
step="0.01"
value={paymentFilterAmountTo}
onChange={(e) => { setPaymentFilterAmountTo(e.target.value); setPaymentPage(1); }}
/>
</div>
<div className="col-md-3 d-flex align-items-end">
<button
className="btn btn-ghost-secondary"
onClick={() => {
setPaymentFilterServer('');
setPaymentFilterDateFrom('');
setPaymentFilterDateTo('');
setPaymentFilterAmountFrom('');
setPaymentFilterAmountTo('');
setPaymentPage(1);
}}
>
<IconX size={16} className="me-1" />
Сбросить
</button>
</div>
</div>
</div>
</div>
)}
{/* Bulk Actions Bar */}
<BulkActionsBar
selectedCount={selectedPayments.size}
totalCount={(() => {
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);
}}
/>
{/* Таблица платежей */}
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th>Имя хостера</th>
<th>Дата</th>
<th>Сумма</th>
<th style={{ width: '40px' }}>
<input
type="checkbox"
className="form-check-input"
checked={selectedPayments.size > 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());
}
}}
/>
</th>
<th
style={{ cursor: 'pointer' }}
onClick={() => {
if (paymentSortField === 'hostName') {
setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc');
} else {
setPaymentSortField('hostName');
setPaymentSortOrder('asc');
}
}}
>
<div className="d-flex align-items-center gap-2">
Имя хостера
{paymentSortField === 'hostName' && (
paymentSortOrder === 'asc' ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />
)}
</div>
</th>
<th
style={{ cursor: 'pointer' }}
onClick={() => {
if (paymentSortField === 'date') {
setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc');
} else {
setPaymentSortField('date');
setPaymentSortOrder('desc');
}
}}
>
<div className="d-flex align-items-center gap-2">
Дата
{paymentSortField === 'date' && (
paymentSortOrder === 'asc' ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />
)}
</div>
</th>
<th
style={{ cursor: 'pointer' }}
onClick={() => {
if (paymentSortField === 'amount') {
setPaymentSortOrder(paymentSortOrder === 'asc' ? 'desc' : 'asc');
} else {
setPaymentSortField('amount');
setPaymentSortOrder('desc');
}
}}
>
<div className="d-flex align-items-center gap-2">
Сумма
{paymentSortField === 'amount' && (
paymentSortOrder === 'asc' ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />
)}
</div>
</th>
<th>Валюта</th>
<th>Комментарий</th>
<th>Действия</th>
@@ -892,106 +1161,203 @@ function BillingManager() {
</thead>
<tbody>
{(() => {
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 => (
<tr key={r._key}>
<td>
<div className="fw-bold">{r.server.hostName}</div>
<div className="text-muted small d-flex align-items-center gap-2">
{r.server.loginUrl && (
<img
src={getFaviconUrl(r.server.loginUrl)}
alt=""
width="14"
height="14"
style={{ borderRadius: 3 }}
onError={(e) => { 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 (
<tr>
<td colSpan="7" className="text-center text-muted py-4">
<div className="py-3">
<IconHistory size={48} className="text-muted mb-3" />
<div>Платежи не найдены</div>
{allRows.length === 0 && billingData.every(i => !i.payments || i.payments.length === 0) && (
<>
<div className="small text-muted mt-2">
Добавьте первый платеж, чтобы начать отслеживание
</div>
<button
className="btn btn-primary btn-sm mt-3"
onClick={() => {
setPaymentDraft({ serverId: '', date: '', amount: 0, currency: 'USD', note: '' });
setShowPaymentModal(true);
}}
>
<IconPlus size={16} className="me-1" />
Добавить первый платеж
</button>
</>
)}
</div>
</td>
</tr>
);
}
return (
<>
{paginatedRows.map(r => (
<tr key={r._key}>
<td>
<input
type="checkbox"
className="form-check-input"
checked={selectedPayments.has(r._key)}
onChange={(e) => {
const newSet = new Set(selectedPayments);
if (e.target.checked) {
newSet.add(r._key);
} else {
newSet.delete(r._key);
}
setSelectedPayments(newSet);
}}
/>
)}
<span>{r.server.provider}</span>
</div>
</td>
<td>{formatDate(r.date)}</td>
<td>
<div className="fw-bold">{formatCurrency(r.amount, r.currency)}</div>
<div className="text-muted small">{formatCurrencyInRUB(r.amount, r.currency)}</div>
</td>
<td><span className="badge bg-blue-lt text-blue">{r.currency || 'USD'}</span></td>
<td className="text-muted small">{r.note || ''}</td>
<td>
<div className="btn-list">
<button
className="btn btn-outline-primary btn-icon"
title="Редактировать платеж"
onClick={() => {
setPaymentDraft({
serverId: r.server.id,
date: r.date,
amount: r.amount,
currency: r.currency || 'USD',
note: r.note || ''
});
setEditingPayment({ serverId: r.server.id, index: r._index });
setShowPaymentModal(true);
}}
>
<IconEdit size={18} />
</button>
<button
className="btn btn-outline-danger btn-icon"
title="Удалить платеж"
onClick={() => {
setPaymentToDelete({ serverId: r.server.id, index: r._index, data: r });
setShowDeletePaymentModal(true);
}}
>
<IconTrash size={18} />
</button>
</div>
</td>
</tr>
));
</td>
<td>
<div className="fw-bold">{r.server.hostName}</div>
<div className="text-muted small d-flex align-items-center gap-2">
{r.server.loginUrl && (
<img
src={getFaviconUrl(r.server.loginUrl)}
alt=""
width="14"
height="14"
style={{ borderRadius: 3 }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
)}
<span>{r.server.provider}</span>
</div>
</td>
<td>{formatDate(r.date)}</td>
<td>
<div className="fw-bold">{formatCurrency(r.amount, r.currency)}</div>
<div className="text-muted small">{formatCurrencyInRUB(r.amount, r.currency)}</div>
</td>
<td><span className="badge bg-blue-lt text-blue">{r.currency || 'USD'}</span></td>
<td className="text-muted small">{r.note || ''}</td>
<td>
<div className="btn-list">
<button
className="btn btn-outline-primary btn-icon"
title="Редактировать платеж"
onClick={() => {
setPaymentDraft({
serverId: r.server.id,
date: r.date,
amount: r.amount,
currency: r.currency || 'USD',
note: r.note || ''
});
setEditingPayment({ serverId: r.server.id, index: r._index });
setShowPaymentModal(true);
}}
>
<IconEdit size={18} />
</button>
<button
className="btn btn-outline-danger btn-icon"
title="Удалить платеж"
onClick={() => {
setPaymentToDelete({ serverId: r.server.id, index: r._index, data: r });
setShowDeletePaymentModal(true);
}}
>
<IconTrash size={18} />
</button>
</div>
</td>
</tr>
))}
{totalPaymentPages > 1 && (
<tr>
<td colSpan="7" className="p-0">
<Pagination
currentPage={paymentPage}
totalPages={totalPaymentPages}
totalItems={allRows.length}
pageSize={paymentPageSize}
onPageChange={setPaymentPage}
/>
</td>
</tr>
)}
</>
);
})()}
{billingData.every(i => !i.payments || i.payments.length === 0) && (
<tr>
<td colSpan="6" className="text-center text-muted py-4">
<div className="py-3">
<IconHistory size={48} className="text-muted mb-3" />
<div>История платежей пуста</div>
<div className="small text-muted mt-2">
Добавьте первый платеж, чтобы начать отслеживание
</div>
<button
className="btn btn-primary btn-sm mt-3"
onClick={() => {
setNewItem({
hostName: '',
purpose: '',
country: '',
provider: '',
loginUrl: '',
monthlyCost: 0,
monthlyCostCurrency: 'USD',
nextPaymentDate: '',
lastPaymentDate: '',
lastPaymentAmount: 0,
lastPaymentCurrency: 'USD',
status: 'active',
notes: ''
});
setShowAddModal(true);
}}
>
<IconPlus size={16} />
Добавить первый платеж
</button>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
@@ -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);
}
}}
/>
</FormModal>
@@ -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 (
<>
<FormField
@@ -1436,17 +1867,19 @@ function AddPaymentForm({ servers, payment, onChange }) {
name="serverId"
type="select"
value={payment.serverId}
onChange={(value) => onChange({ ...payment, serverId: value })}
onChange={handleServerChange}
options={serverOptions}
required
helpText={payment.serverId ? "Сумма и валюта заполнены автоматически из последнего платежа" : ""}
/>
<FormField
label="Дата"
name="date"
type="date"
value={payment.date}
onChange={(value) => onChange({ ...payment, date: value })}
onChange={handleDateChange}
required
helpText="Нельзя добавить платеж в будущем без подтверждения"
/>
<div className="row g-2">
<div className="col-8">