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 [editingItem, setEditingItem] = useState({});
|
||||
|
||||
// Модалка добавления платежа, привязанного к серверу
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
const [paymentDraft, setPaymentDraft] = useState({
|
||||
serverId: '',
|
||||
date: '',
|
||||
amount: 0,
|
||||
currency: 'USD',
|
||||
note: ''
|
||||
});
|
||||
|
||||
// Состояние для курсов валют
|
||||
const [exchangeRates, setExchangeRates] = useState({
|
||||
USD: 1,
|
||||
@@ -102,7 +112,17 @@ function BillingManager() {
|
||||
setLoading(true);
|
||||
try {
|
||||
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('');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных биллинга:', err);
|
||||
@@ -686,22 +706,14 @@ function BillingManager() {
|
||||
<button
|
||||
className="btn btn-sm btn-outline-success"
|
||||
onClick={() => {
|
||||
setNewItem({
|
||||
hostName: item.hostName,
|
||||
purpose: item.purpose,
|
||||
country: item.country,
|
||||
provider: item.provider,
|
||||
loginUrl: item.loginUrl,
|
||||
monthlyCost: item.monthlyCost,
|
||||
monthlyCostCurrency: item.monthlyCostCurrency,
|
||||
nextPaymentDate: item.nextPaymentDate,
|
||||
lastPaymentDate: '',
|
||||
lastPaymentAmount: 0,
|
||||
lastPaymentCurrency: item.monthlyCostCurrency || 'USD',
|
||||
status: item.status,
|
||||
notes: ''
|
||||
setPaymentDraft({
|
||||
serverId: item.id,
|
||||
date: new Date().toISOString().slice(0,10),
|
||||
amount: item.monthlyCost || 0,
|
||||
currency: item.monthlyCostCurrency || 'USD',
|
||||
note: ''
|
||||
});
|
||||
setShowAddModal(true);
|
||||
setShowPaymentModal(true);
|
||||
}}
|
||||
title="Добавить платеж"
|
||||
>
|
||||
@@ -774,27 +786,10 @@ function BillingManager() {
|
||||
История платежей
|
||||
</h3>
|
||||
<div className="card-actions">
|
||||
<button
|
||||
className="btn btn-outline-primary btn-sm"
|
||||
onClick={() => {
|
||||
setNewItem({
|
||||
hostName: '',
|
||||
purpose: '',
|
||||
country: '',
|
||||
provider: '',
|
||||
loginUrl: '',
|
||||
monthlyCost: 0,
|
||||
monthlyCostCurrency: 'USD',
|
||||
nextPaymentDate: '',
|
||||
lastPaymentDate: '',
|
||||
lastPaymentAmount: 0,
|
||||
lastPaymentCurrency: 'USD',
|
||||
status: 'active',
|
||||
notes: ''
|
||||
});
|
||||
setShowAddModal(true);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-outline-primary btn-sm"
|
||||
onClick={() => { setPaymentDraft({ serverId: '', date: '', amount: 0, currency: 'USD', note: '' }); setShowPaymentModal(true); }}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
Добавить платеж
|
||||
</button>
|
||||
@@ -806,57 +801,35 @@ function BillingManager() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Имя хостера</th>
|
||||
<th>Дата платежа</th>
|
||||
<th>Дата</th>
|
||||
<th>Сумма</th>
|
||||
<th>Валюта</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
<th>Комментарий</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{billingData
|
||||
.filter(item => item.lastPaymentDate && item.lastPaymentAmount)
|
||||
.sort((a, b) => new Date(b.lastPaymentDate) - new Date(a.lastPaymentDate))
|
||||
.slice(0, 10)
|
||||
.map(item => (
|
||||
<tr key={item.id}>
|
||||
{(() => {
|
||||
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));
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return rows.slice(0, 20).map(r => (
|
||||
<tr key={r._key}>
|
||||
<td>
|
||||
<div className="fw-bold">{item.hostName}</div>
|
||||
<div className="text-muted small">{item.provider}</div>
|
||||
<div className="fw-bold">{r.server.hostName}</div>
|
||||
<div className="text-muted small">{r.server.provider}</div>
|
||||
</td>
|
||||
<td>{formatDate(item.lastPaymentDate)}</td>
|
||||
<td>{formatDate(r.date)}</td>
|
||||
<td>
|
||||
<div className="fw-bold">
|
||||
{formatCurrency(item.lastPaymentAmount, item.lastPaymentCurrency)}
|
||||
</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>
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
{billingData.filter(item => item.lastPaymentDate && item.lastPaymentAmount).length === 0 && (
|
||||
));
|
||||
})()}
|
||||
{billingData.every(i => !i.payments || i.payments.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan="6" className="text-center text-muted py-4">
|
||||
<div className="py-3">
|
||||
@@ -922,6 +895,32 @@ function BillingManager() {
|
||||
onDelete={confirmDelete}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -930,8 +929,7 @@ function BillingManager() {
|
||||
function AddBillingModal({ show, item, onItemChange, onSubmit, onClose }) {
|
||||
if (!show) return null;
|
||||
|
||||
const isHistoricalPayment = item.hostName && !item.lastPaymentDate;
|
||||
const modalTitle = isHistoricalPayment ? 'Добавить исторический платеж' : 'Добавить новый элемент';
|
||||
const modalTitle = 'Добавить новый элемент';
|
||||
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
<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="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 className="row g-3 mb-4">
|
||||
<div className="col-md-2">
|
||||
<div className="row g-3 mb-4 justify-content-center">
|
||||
<div className="col-sm-6 col-md-3">
|
||||
<MetricCard
|
||||
title="Стран"
|
||||
value={loading ? '...' : stats.countriesCount ?? '—'}
|
||||
@@ -222,7 +222,7 @@ function Dashboard() {
|
||||
description="Географическое покрытие"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<div className="col-sm-6 col-md-3">
|
||||
<MetricCard
|
||||
title="Провайдеров"
|
||||
value={loading ? '...' : stats.providersCount ?? '—'}
|
||||
@@ -231,16 +231,16 @@ function Dashboard() {
|
||||
description="Облачные провайдеры"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<div className="col-sm-6 col-md-3">
|
||||
<MetricCard
|
||||
title="Онлайн серверов"
|
||||
value={loading ? '...' : `${stats.onlineServers ?? '—'}/${stats.totalServers ?? '—'}`}
|
||||
icon={IconShield}
|
||||
icon={IconServer}
|
||||
color="success"
|
||||
description="Активные серверы"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<div className="col-sm-6 col-md-3">
|
||||
<MetricCard
|
||||
title="Последнее обновление"
|
||||
value={loading ? '...' : (stats.lastModified ? stats.lastModified : '—')}
|
||||
|
||||
Reference in New Issue
Block a user