Compare commits
2
Commits
cb46c74209
...
4c541b77da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c541b77da | ||
|
|
b340f73dc6 |
+545
-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({
|
||||
@@ -484,6 +504,8 @@ function BillingManager() {
|
||||
disableRefresh={loading}
|
||||
onExport={exportData}
|
||||
disableExport={loading}
|
||||
onSave={handleSaveChanges}
|
||||
disableSave={loading}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -866,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>
|
||||
@@ -890,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>
|
||||
@@ -1058,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 => {
|
||||
@@ -1100,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}
|
||||
@@ -1114,6 +1499,11 @@ function BillingManager() {
|
||||
servers={billingData}
|
||||
payment={paymentDraft}
|
||||
onChange={setPaymentDraft}
|
||||
onDateChange={(date, requiresConfirm) => {
|
||||
if (requiresConfirm) {
|
||||
setFutureDateConfirm(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormModal>
|
||||
|
||||
@@ -1415,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})` }))
|
||||
@@ -1427,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
|
||||
@@ -1434,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