Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c541b77da | ||
|
|
b340f73dc6 |
+545
-110
@@ -26,8 +26,13 @@ import {
|
|||||||
IconUpload,
|
IconUpload,
|
||||||
IconEye,
|
IconEye,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
IconHistory
|
IconHistory,
|
||||||
|
IconChevronUp,
|
||||||
|
IconChevronDown,
|
||||||
|
IconX,
|
||||||
|
IconChecks
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
||||||
|
|
||||||
function BillingManager() {
|
function BillingManager() {
|
||||||
const [billingData, setBillingData] = useState([]);
|
const [billingData, setBillingData] = useState([]);
|
||||||
@@ -63,6 +68,21 @@ function BillingManager() {
|
|||||||
const [editingPayment, setEditingPayment] = useState(null); // { serverId, index }
|
const [editingPayment, setEditingPayment] = useState(null); // { serverId, index }
|
||||||
const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false);
|
const [showDeletePaymentModal, setShowDeletePaymentModal] = useState(false);
|
||||||
const [paymentToDelete, setPaymentToDelete] = useState(null); // { serverId, index, data }
|
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({
|
const [exchangeRates, setExchangeRates] = useState({
|
||||||
@@ -484,6 +504,8 @@ function BillingManager() {
|
|||||||
disableRefresh={loading}
|
disableRefresh={loading}
|
||||||
onExport={exportData}
|
onExport={exportData}
|
||||||
disableExport={loading}
|
disableExport={loading}
|
||||||
|
onSave={handleSaveChanges}
|
||||||
|
disableSave={loading}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -866,23 +888,272 @@ function BillingManager() {
|
|||||||
История платежей
|
История платежей
|
||||||
</h3>
|
</h3>
|
||||||
<div className="card-actions">
|
<div className="card-actions">
|
||||||
<button
|
<div className="btn-list">
|
||||||
className="btn btn-outline-primary"
|
<button
|
||||||
onClick={() => { setPaymentDraft({ serverId: '', date: '', amount: 0, currency: 'USD', note: '' }); setShowPaymentModal(true); }}
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => setShowPaymentFilters(!showPaymentFilters)}
|
||||||
>
|
>
|
||||||
<IconPlus size={16} />
|
<IconFilter size={16} className="me-1" />
|
||||||
Добавить платеж
|
Фильтры
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<div className="card-body">
|
<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">
|
<div className="table-responsive">
|
||||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Имя хостера</th>
|
<th style={{ width: '40px' }}>
|
||||||
<th>Дата</th>
|
<input
|
||||||
<th>Сумма</th>
|
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>
|
<th>Комментарий</th>
|
||||||
<th>Действия</th>
|
<th>Действия</th>
|
||||||
@@ -890,106 +1161,203 @@ function BillingManager() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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) {
|
let allRows = billingData.flatMap(server =>
|
||||||
return null;
|
(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>
|
if (paymentFilterServer) {
|
||||||
<div className="fw-bold">{r.server.hostName}</div>
|
allRows = allRows.filter(r => r.server.id === paymentFilterServer);
|
||||||
<div className="text-muted small d-flex align-items-center gap-2">
|
}
|
||||||
{r.server.loginUrl && (
|
|
||||||
<img
|
// Фильтр по дате
|
||||||
src={getFaviconUrl(r.server.loginUrl)}
|
if (paymentFilterDateFrom) {
|
||||||
alt=""
|
allRows = allRows.filter(r => new Date(r.date) >= new Date(paymentFilterDateFrom));
|
||||||
width="14"
|
}
|
||||||
height="14"
|
if (paymentFilterDateTo) {
|
||||||
style={{ borderRadius: 3 }}
|
allRows = allRows.filter(r => new Date(r.date) <= new Date(paymentFilterDateTo));
|
||||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
}
|
||||||
|
|
||||||
|
// Фильтр по сумме
|
||||||
|
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);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
</td>
|
||||||
<span>{r.server.provider}</span>
|
<td>
|
||||||
</div>
|
<div className="fw-bold">{r.server.hostName}</div>
|
||||||
</td>
|
<div className="text-muted small d-flex align-items-center gap-2">
|
||||||
<td>{formatDate(r.date)}</td>
|
{r.server.loginUrl && (
|
||||||
<td>
|
<img
|
||||||
<div className="fw-bold">{formatCurrency(r.amount, r.currency)}</div>
|
src={getFaviconUrl(r.server.loginUrl)}
|
||||||
<div className="text-muted small">{formatCurrencyInRUB(r.amount, r.currency)}</div>
|
alt=""
|
||||||
</td>
|
width="14"
|
||||||
<td><span className="badge bg-blue-lt text-blue">{r.currency || 'USD'}</span></td>
|
height="14"
|
||||||
<td className="text-muted small">{r.note || ''}</td>
|
style={{ borderRadius: 3 }}
|
||||||
<td>
|
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||||
<div className="btn-list">
|
/>
|
||||||
<button
|
)}
|
||||||
className="btn btn-outline-primary btn-icon"
|
<span>{r.server.provider}</span>
|
||||||
title="Редактировать платеж"
|
</div>
|
||||||
onClick={() => {
|
</td>
|
||||||
setPaymentDraft({
|
<td>{formatDate(r.date)}</td>
|
||||||
serverId: r.server.id,
|
<td>
|
||||||
date: r.date,
|
<div className="fw-bold">{formatCurrency(r.amount, r.currency)}</div>
|
||||||
amount: r.amount,
|
<div className="text-muted small">{formatCurrencyInRUB(r.amount, r.currency)}</div>
|
||||||
currency: r.currency || 'USD',
|
</td>
|
||||||
note: r.note || ''
|
<td><span className="badge bg-blue-lt text-blue">{r.currency || 'USD'}</span></td>
|
||||||
});
|
<td className="text-muted small">{r.note || ''}</td>
|
||||||
setEditingPayment({ serverId: r.server.id, index: r._index });
|
<td>
|
||||||
setShowPaymentModal(true);
|
<div className="btn-list">
|
||||||
}}
|
<button
|
||||||
>
|
className="btn btn-outline-primary btn-icon"
|
||||||
<IconEdit size={18} />
|
title="Редактировать платеж"
|
||||||
</button>
|
onClick={() => {
|
||||||
<button
|
setPaymentDraft({
|
||||||
className="btn btn-outline-danger btn-icon"
|
serverId: r.server.id,
|
||||||
title="Удалить платеж"
|
date: r.date,
|
||||||
onClick={() => {
|
amount: r.amount,
|
||||||
setPaymentToDelete({ serverId: r.server.id, index: r._index, data: r });
|
currency: r.currency || 'USD',
|
||||||
setShowDeletePaymentModal(true);
|
note: r.note || ''
|
||||||
}}
|
});
|
||||||
>
|
setEditingPayment({ serverId: r.server.id, index: r._index });
|
||||||
<IconTrash size={18} />
|
setShowPaymentModal(true);
|
||||||
</button>
|
}}
|
||||||
</div>
|
>
|
||||||
</td>
|
<IconEdit size={18} />
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -1058,6 +1426,21 @@ function BillingManager() {
|
|||||||
setTimeout(() => setError(''), 3000);
|
setTimeout(() => setError(''), 3000);
|
||||||
return;
|
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) {
|
if (editingPayment && editingPayment.serverId) {
|
||||||
setBillingData(prev => prev.map(s => {
|
setBillingData(prev => prev.map(s => {
|
||||||
@@ -1100,12 +1483,14 @@ function BillingManager() {
|
|||||||
return { ...s, payments, lastPaymentDate: last?.date || '', lastPaymentAmount: last?.amount || 0, lastPaymentCurrency: last?.currency || s.monthlyCostCurrency || 'USD' };
|
return { ...s, payments, lastPaymentDate: last?.date || '', lastPaymentAmount: last?.amount || 0, lastPaymentCurrency: last?.currency || s.monthlyCostCurrency || 'USD' };
|
||||||
}));
|
}));
|
||||||
setShowPaymentModal(false);
|
setShowPaymentModal(false);
|
||||||
|
setFutureDateConfirm(false);
|
||||||
setSuccess('Платеж добавлен');
|
setSuccess('Платеж добавлен');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowPaymentModal(false);
|
setShowPaymentModal(false);
|
||||||
setEditingPayment(null);
|
setEditingPayment(null);
|
||||||
|
setFutureDateConfirm(false);
|
||||||
}}
|
}}
|
||||||
submitLabel={editingPayment ? "Сохранить" : "Добавить"}
|
submitLabel={editingPayment ? "Сохранить" : "Добавить"}
|
||||||
submitIcon={editingPayment ? IconCheck : IconPlus}
|
submitIcon={editingPayment ? IconCheck : IconPlus}
|
||||||
@@ -1114,6 +1499,11 @@ function BillingManager() {
|
|||||||
servers={billingData}
|
servers={billingData}
|
||||||
payment={paymentDraft}
|
payment={paymentDraft}
|
||||||
onChange={setPaymentDraft}
|
onChange={setPaymentDraft}
|
||||||
|
onDateChange={(date, requiresConfirm) => {
|
||||||
|
if (requiresConfirm) {
|
||||||
|
setFutureDateConfirm(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|
||||||
@@ -1415,7 +1805,7 @@ function EditBillingForm({ item, onItemChange }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Форма добавления платежа
|
// Форма добавления платежа
|
||||||
function AddPaymentForm({ servers, payment, onChange }) {
|
function AddPaymentForm({ servers, payment, onChange, onDateChange }) {
|
||||||
const serverOptions = [
|
const serverOptions = [
|
||||||
{ value: '', label: 'Выберите сервер' },
|
{ value: '', label: 'Выберите сервер' },
|
||||||
...servers.map(s => ({ value: s.id, label: `${s.hostName} (${s.provider})` }))
|
...servers.map(s => ({ value: s.id, label: `${s.hostName} (${s.provider})` }))
|
||||||
@@ -1427,6 +1817,49 @@ function AddPaymentForm({ servers, payment, onChange }) {
|
|||||||
{ value: 'RUB', label: 'RUB' }
|
{ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -1434,17 +1867,19 @@ function AddPaymentForm({ servers, payment, onChange }) {
|
|||||||
name="serverId"
|
name="serverId"
|
||||||
type="select"
|
type="select"
|
||||||
value={payment.serverId}
|
value={payment.serverId}
|
||||||
onChange={(value) => onChange({ ...payment, serverId: value })}
|
onChange={handleServerChange}
|
||||||
options={serverOptions}
|
options={serverOptions}
|
||||||
required
|
required
|
||||||
|
helpText={payment.serverId ? "Сумма и валюта заполнены автоматически из последнего платежа" : ""}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
label="Дата"
|
label="Дата"
|
||||||
name="date"
|
name="date"
|
||||||
type="date"
|
type="date"
|
||||||
value={payment.date}
|
value={payment.date}
|
||||||
onChange={(value) => onChange({ ...payment, date: value })}
|
onChange={handleDateChange}
|
||||||
required
|
required
|
||||||
|
helpText="Нельзя добавить платеж в будущем без подтверждения"
|
||||||
/>
|
/>
|
||||||
<div className="row g-2">
|
<div className="row g-2">
|
||||||
<div className="col-8">
|
<div className="col-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user