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 [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>
);
}
+6 -6
View File
@@ -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 : '—')}