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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
This commit is contained in:
+543
-110
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user