2 Commits
+545 -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({
@@ -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">