feat: Add payment modal to BillingManager for streamlined payment entry and management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m42s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m42s
This commit is contained in:
+134
-88
@@ -45,6 +45,16 @@ function BillingManager() {
|
|||||||
const [selectedItem, setSelectedItem] = useState(null);
|
const [selectedItem, setSelectedItem] = useState(null);
|
||||||
const [editingItem, setEditingItem] = useState({});
|
const [editingItem, setEditingItem] = useState({});
|
||||||
|
|
||||||
|
// Модалка добавления платежа, привязанного к серверу
|
||||||
|
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||||
|
const [paymentDraft, setPaymentDraft] = useState({
|
||||||
|
serverId: '',
|
||||||
|
date: '',
|
||||||
|
amount: 0,
|
||||||
|
currency: 'USD',
|
||||||
|
note: ''
|
||||||
|
});
|
||||||
|
|
||||||
// Состояние для курсов валют
|
// Состояние для курсов валют
|
||||||
const [exchangeRates, setExchangeRates] = useState({
|
const [exchangeRates, setExchangeRates] = useState({
|
||||||
USD: 1,
|
USD: 1,
|
||||||
@@ -102,7 +112,17 @@ function BillingManager() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${API_URL}/billing`);
|
const response = await axios.get(`${API_URL}/billing`);
|
||||||
setBillingData(response.data);
|
const normalized = (response.data || []).map((item, idx) => ({
|
||||||
|
id: item.id ?? String(idx + 1),
|
||||||
|
payments: Array.isArray(item.payments)
|
||||||
|
? item.payments
|
||||||
|
: (item.lastPaymentDate && item.lastPaymentAmount
|
||||||
|
? [{ date: item.lastPaymentDate, amount: item.lastPaymentAmount, currency: item.lastPaymentCurrency || 'USD', note: 'Импортировано' }]
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
...item,
|
||||||
|
}));
|
||||||
|
setBillingData(normalized);
|
||||||
setError('');
|
setError('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка при загрузке данных биллинга:', err);
|
console.error('Ошибка при загрузке данных биллинга:', err);
|
||||||
@@ -686,22 +706,14 @@ function BillingManager() {
|
|||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-outline-success"
|
className="btn btn-sm btn-outline-success"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNewItem({
|
setPaymentDraft({
|
||||||
hostName: item.hostName,
|
serverId: item.id,
|
||||||
purpose: item.purpose,
|
date: new Date().toISOString().slice(0,10),
|
||||||
country: item.country,
|
amount: item.monthlyCost || 0,
|
||||||
provider: item.provider,
|
currency: item.monthlyCostCurrency || 'USD',
|
||||||
loginUrl: item.loginUrl,
|
note: ''
|
||||||
monthlyCost: item.monthlyCost,
|
|
||||||
monthlyCostCurrency: item.monthlyCostCurrency,
|
|
||||||
nextPaymentDate: item.nextPaymentDate,
|
|
||||||
lastPaymentDate: '',
|
|
||||||
lastPaymentAmount: 0,
|
|
||||||
lastPaymentCurrency: item.monthlyCostCurrency || 'USD',
|
|
||||||
status: item.status,
|
|
||||||
notes: ''
|
|
||||||
});
|
});
|
||||||
setShowAddModal(true);
|
setShowPaymentModal(true);
|
||||||
}}
|
}}
|
||||||
title="Добавить платеж"
|
title="Добавить платеж"
|
||||||
>
|
>
|
||||||
@@ -774,27 +786,10 @@ function BillingManager() {
|
|||||||
История платежей
|
История платежей
|
||||||
</h3>
|
</h3>
|
||||||
<div className="card-actions">
|
<div className="card-actions">
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary btn-sm"
|
className="btn btn-outline-primary btn-sm"
|
||||||
onClick={() => {
|
onClick={() => { setPaymentDraft({ serverId: '', date: '', amount: 0, currency: 'USD', note: '' }); setShowPaymentModal(true); }}
|
||||||
setNewItem({
|
>
|
||||||
hostName: '',
|
|
||||||
purpose: '',
|
|
||||||
country: '',
|
|
||||||
provider: '',
|
|
||||||
loginUrl: '',
|
|
||||||
monthlyCost: 0,
|
|
||||||
monthlyCostCurrency: 'USD',
|
|
||||||
nextPaymentDate: '',
|
|
||||||
lastPaymentDate: '',
|
|
||||||
lastPaymentAmount: 0,
|
|
||||||
lastPaymentCurrency: 'USD',
|
|
||||||
status: 'active',
|
|
||||||
notes: ''
|
|
||||||
});
|
|
||||||
setShowAddModal(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconPlus size={16} />
|
<IconPlus size={16} />
|
||||||
Добавить платеж
|
Добавить платеж
|
||||||
</button>
|
</button>
|
||||||
@@ -806,57 +801,35 @@ function BillingManager() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Имя хостера</th>
|
<th>Имя хостера</th>
|
||||||
<th>Дата платежа</th>
|
<th>Дата</th>
|
||||||
<th>Сумма</th>
|
<th>Сумма</th>
|
||||||
<th>Валюта</th>
|
<th>Валюта</th>
|
||||||
<th>Статус</th>
|
<th>Комментарий</th>
|
||||||
<th>Действия</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{billingData
|
{(() => {
|
||||||
.filter(item => item.lastPaymentDate && item.lastPaymentAmount)
|
const rows = billingData.flatMap(server => (server.payments || []).map((p, idx) => ({ server, ...p, _key: `${server.id}-${idx}` }))).sort((a,b) => new Date(b.date) - new Date(a.date));
|
||||||
.sort((a, b) => new Date(b.lastPaymentDate) - new Date(a.lastPaymentDate))
|
if (rows.length === 0) {
|
||||||
.slice(0, 10)
|
return null;
|
||||||
.map(item => (
|
}
|
||||||
<tr key={item.id}>
|
return rows.slice(0, 20).map(r => (
|
||||||
|
<tr key={r._key}>
|
||||||
<td>
|
<td>
|
||||||
<div className="fw-bold">{item.hostName}</div>
|
<div className="fw-bold">{r.server.hostName}</div>
|
||||||
<div className="text-muted small">{item.provider}</div>
|
<div className="text-muted small">{r.server.provider}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatDate(item.lastPaymentDate)}</td>
|
<td>{formatDate(r.date)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="fw-bold">
|
<div className="fw-bold">{formatCurrency(r.amount, r.currency)}</div>
|
||||||
{formatCurrency(item.lastPaymentAmount, item.lastPaymentCurrency)}
|
<div className="text-muted small">{formatCurrencyInRUB(r.amount, r.currency)}</div>
|
||||||
</div>
|
|
||||||
<div className="text-muted small">
|
|
||||||
{formatCurrencyInRUB(item.lastPaymentAmount, item.lastPaymentCurrency)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="badge bg-blue-lt text-blue">
|
|
||||||
{item.lastPaymentCurrency || 'USD'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="badge bg-success-lt text-success">
|
|
||||||
Оплачен
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="btn-list">
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-primary"
|
|
||||||
onClick={() => handleEditItem(item)}
|
|
||||||
title="Редактировать"
|
|
||||||
>
|
|
||||||
<IconEdit size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
|
<td><span className="badge bg-blue-lt text-blue">{r.currency || 'USD'}</span></td>
|
||||||
|
<td className="text-muted small">{r.note || ''}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
));
|
||||||
{billingData.filter(item => item.lastPaymentDate && item.lastPaymentAmount).length === 0 && (
|
})()}
|
||||||
|
{billingData.every(i => !i.payments || i.payments.length === 0) && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan="6" className="text-center text-muted py-4">
|
<td colSpan="6" className="text-center text-muted py-4">
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
@@ -922,6 +895,32 @@ function BillingManager() {
|
|||||||
onDelete={confirmDelete}
|
onDelete={confirmDelete}
|
||||||
onClose={() => setShowDeleteModal(false)}
|
onClose={() => setShowDeleteModal(false)}
|
||||||
/>
|
/>
|
||||||
|
{/* Модалка добавления платежа */}
|
||||||
|
{showPaymentModal && (
|
||||||
|
<AddPaymentModal
|
||||||
|
show={showPaymentModal}
|
||||||
|
servers={billingData}
|
||||||
|
payment={paymentDraft}
|
||||||
|
onChange={setPaymentDraft}
|
||||||
|
onClose={() => setShowPaymentModal(false)}
|
||||||
|
onSubmit={() => {
|
||||||
|
if (!paymentDraft.serverId || !paymentDraft.date || !paymentDraft.amount) {
|
||||||
|
setError('Заполните сервер, дату и сумму.');
|
||||||
|
setTimeout(() => setError(''), 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBillingData(prev => prev.map(s => {
|
||||||
|
if (s.id !== paymentDraft.serverId) return s;
|
||||||
|
const payments = Array.isArray(s.payments) ? [...s.payments] : [];
|
||||||
|
payments.push({ date: paymentDraft.date, amount: paymentDraft.amount, currency: paymentDraft.currency || 'USD', note: paymentDraft.note || '' });
|
||||||
|
return { ...s, payments, lastPaymentDate: paymentDraft.date, lastPaymentAmount: paymentDraft.amount, lastPaymentCurrency: paymentDraft.currency || s.monthlyCostCurrency || 'USD' };
|
||||||
|
}));
|
||||||
|
setShowPaymentModal(false);
|
||||||
|
setSuccess('Платеж добавлен');
|
||||||
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -930,8 +929,7 @@ function BillingManager() {
|
|||||||
function AddBillingModal({ show, item, onItemChange, onSubmit, onClose }) {
|
function AddBillingModal({ show, item, onItemChange, onSubmit, onClose }) {
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
const isHistoricalPayment = item.hostName && !item.lastPaymentDate;
|
const modalTitle = 'Добавить новый элемент';
|
||||||
const modalTitle = isHistoricalPayment ? 'Добавить исторический платеж' : 'Добавить новый элемент';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
@@ -942,13 +940,6 @@ function AddBillingModal({ show, item, onItemChange, onSubmit, onClose }) {
|
|||||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
{isHistoricalPayment && (
|
|
||||||
<div className="alert alert-info mb-3">
|
|
||||||
<IconHistory size={16} className="me-2" />
|
|
||||||
<strong>Исторический платеж:</strong> Вы добавляете платеж для существующего хоста "{item.hostName}".
|
|
||||||
Заполните информацию о платеже ниже.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
{/* Основная информация */}
|
{/* Основная информация */}
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
@@ -1332,4 +1323,59 @@ function DeleteBillingModal({ show, item, onDelete, onClose }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BillingManager;
|
export default BillingManager;
|
||||||
|
|
||||||
|
// Модалка добавления платежа
|
||||||
|
function AddPaymentModal({ show, servers, payment, onChange, onSubmit, onClose }) {
|
||||||
|
if (!show) return null;
|
||||||
|
const serverOptions = servers.map(s => ({ id: s.id, label: `${s.hostName} (${s.provider})` }));
|
||||||
|
return (
|
||||||
|
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Добавить платеж</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Сервер *</label>
|
||||||
|
<select className="form-select" value={payment.serverId} onChange={e => onChange({ ...payment, serverId: e.target.value })}>
|
||||||
|
<option value="">Выберите сервер</option>
|
||||||
|
{serverOptions.map(o => (
|
||||||
|
<option key={o.id} value={o.id}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Дата *</label>
|
||||||
|
<input type="date" className="form-control" value={payment.date} onChange={e => onChange({ ...payment, date: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="row g-2">
|
||||||
|
<div className="col-8">
|
||||||
|
<label className="form-label">Сумма *</label>
|
||||||
|
<input type="number" step="0.01" className="form-control" value={payment.amount} onChange={e => onChange({ ...payment, amount: parseFloat(e.target.value) || 0 })} />
|
||||||
|
</div>
|
||||||
|
<div className="col-4">
|
||||||
|
<label className="form-label">Валюта</label>
|
||||||
|
<select className="form-select" value={payment.currency} onChange={e => onChange({ ...payment, currency: e.target.value })}>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
<option value="EUR">EUR</option>
|
||||||
|
<option value="RUB">RUB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="form-label">Комментарий</label>
|
||||||
|
<input type="text" className="form-control" value={payment.note} onChange={e => onChange({ ...payment, note: e.target.value })} placeholder="Необязательно" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-secondary" onClick={onClose}>Отмена</button>
|
||||||
|
<button className="btn btn-primary" onClick={onSubmit}>Добавить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -212,8 +212,8 @@ function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Дополнительные метрики */}
|
{/* Дополнительные метрики */}
|
||||||
<div className="row g-3 mb-4">
|
<div className="row g-3 mb-4 justify-content-center">
|
||||||
<div className="col-md-2">
|
<div className="col-sm-6 col-md-3">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Стран"
|
title="Стран"
|
||||||
value={loading ? '...' : stats.countriesCount ?? '—'}
|
value={loading ? '...' : stats.countriesCount ?? '—'}
|
||||||
@@ -222,7 +222,7 @@ function Dashboard() {
|
|||||||
description="Географическое покрытие"
|
description="Географическое покрытие"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-2">
|
<div className="col-sm-6 col-md-3">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Провайдеров"
|
title="Провайдеров"
|
||||||
value={loading ? '...' : stats.providersCount ?? '—'}
|
value={loading ? '...' : stats.providersCount ?? '—'}
|
||||||
@@ -231,16 +231,16 @@ function Dashboard() {
|
|||||||
description="Облачные провайдеры"
|
description="Облачные провайдеры"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-2">
|
<div className="col-sm-6 col-md-3">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Онлайн серверов"
|
title="Онлайн серверов"
|
||||||
value={loading ? '...' : `${stats.onlineServers ?? '—'}/${stats.totalServers ?? '—'}`}
|
value={loading ? '...' : `${stats.onlineServers ?? '—'}/${stats.totalServers ?? '—'}`}
|
||||||
icon={IconShield}
|
icon={IconServer}
|
||||||
color="success"
|
color="success"
|
||||||
description="Активные серверы"
|
description="Активные серверы"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-2">
|
<div className="col-sm-6 col-md-3">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Последнее обновление"
|
title="Последнее обновление"
|
||||||
value={loading ? '...' : (stats.lastModified ? stats.lastModified : '—')}
|
value={loading ? '...' : (stats.lastModified ? stats.lastModified : '—')}
|
||||||
|
|||||||
Reference in New Issue
Block a user