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

This commit is contained in:
2025-08-08 11:21:22 +07:00
parent 6d7ed80646
commit 6c7142d45d
2 changed files with 140 additions and 94 deletions
+134 -88
View File
@@ -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>
);
}
+6 -6
View File
@@ -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 : '—')}