refactor(BillingManager): add tab navigation for subscriptions and payments, enhancing user experience and data visibility
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 58s

This commit is contained in:
2026-02-16 21:28:58 +07:00
parent 5af5abb02d
commit 08a64b5be4
+305 -273
View File
@@ -54,6 +54,7 @@ function BillingManager() {
const [filterStatus, setFilterStatus] = useState(''); const [filterStatus, setFilterStatus] = useState('');
const [filterUrgency, setFilterUrgency] = useState(''); const [filterUrgency, setFilterUrgency] = useState('');
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table' const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
const [activeTab, setActiveTab] = useState('subscriptions'); // 'subscriptions' | 'payments'
const pageSize = 15; const pageSize = 15;
// Модальные окна // Модальные окна
@@ -298,6 +299,10 @@ function BillingManager() {
return sum + convertToRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD'); return sum + convertToRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD');
}, 0); }, 0);
const totalPaymentsCount = billingData.reduce((sum, item) => {
return sum + ((item.payments && Array.isArray(item.payments) ? item.payments.length : 0));
}, 0);
const urgentPayments = billingData.filter(item => { const urgentPayments = billingData.filter(item => {
const nextPayment = new Date(item.nextPaymentDate); const nextPayment = new Date(item.nextPaymentDate);
const now = new Date(); const now = new Date();
@@ -578,15 +583,39 @@ function BillingManager() {
title="Биллинг" title="Биллинг"
icon={<IconCreditCard size={24} />} icon={<IconCreditCard size={24} />}
actions={ actions={
<PageHeaderActions <div className="d-flex flex-column flex-sm-row align-items-stretch align-items-sm-center gap-2">
loading={loading} <div className="btn-group me-sm-2" role="group">
onRefresh={() => { fetchBillingData(); fetchServers(); }} <button
disableRefresh={loading} className={`btn ${activeTab === 'subscriptions' ? 'btn-primary' : 'btn-outline-primary'}`}
onExport={exportData} onClick={() => setActiveTab('subscriptions')}
disableExport={loading} >
onSave={handleSaveChanges} <IconServer size={16} className="me-1 d-none d-sm-inline" />
disableSave={loading} Подписки
/> <span className={`badge ms-1 ${activeTab === 'subscriptions' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
{billingData.length}
</span>
</button>
<button
className={`btn ${activeTab === 'payments' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('payments')}
>
<IconHistory size={16} className="me-1 d-none d-sm-inline" />
История платежей
<span className={`badge ms-1 ${activeTab === 'payments' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
{totalPaymentsCount}
</span>
</button>
</div>
<PageHeaderActions
loading={loading}
onRefresh={() => { fetchBillingData(); fetchServers(); }}
disableRefresh={loading}
onExport={exportData}
disableExport={loading}
onSave={handleSaveChanges}
disableSave={loading}
/>
</div>
} }
/> />
@@ -656,279 +685,282 @@ function BillingManager() {
</div> </div>
</div> </div>
{/* Основная секция */} {/* Основная секция: Подписки */}
<div className="card"> {activeTab === 'subscriptions' && (
<div className="card-header"> <div className="card">
<div className="d-flex align-items-center gap-3 flex-grow-1"> <div className="card-header">
<h3 className="card-title mb-0"> <div className="d-flex align-items-center gap-3 flex-grow-1">
Подписки <h3 className="card-title mb-0">
<span className="badge bg-blue-lt text-blue ms-2">{filteredData.length}</span> Подписки
</h3> <span className="badge bg-blue-lt text-blue ms-2">{filteredData.length}</span>
</h3>
{/* Поиск */} {/* Поиск */}
<div className="input-icon flex-grow-1" style={{ maxWidth: '280px' }}> <div className="input-icon flex-grow-1" style={{ maxWidth: '280px' }}>
<span className="input-icon-addon"> <span className="input-icon-addon">
<IconSearch size={16} /> <IconSearch size={16} />
</span> </span>
<input <input
type="text" type="text"
className="form-control form-control-sm" className="form-control form-control-sm"
placeholder="Поиск..." placeholder="Поиск..."
value={searchTerm} value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }} onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }}
/>
</div>
{/* Быстрые фильтры */}
<div className="d-flex gap-1">
<button
className={`btn btn-sm ${filterUrgency === '' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => { setFilterUrgency(''); setCurrentPage(1); }}
>
Все
</button>
<button
className={`btn btn-sm ${filterUrgency === 'overdue' ? 'btn-red' : 'btn-outline-secondary'}`}
onClick={() => { setFilterUrgency('overdue'); setCurrentPage(1); }}
>
Просрочено
</button>
<button
className={`btn btn-sm ${filterUrgency === 'urgent' ? 'btn-yellow' : 'btn-outline-secondary'}`}
onClick={() => { setFilterUrgency('urgent'); setCurrentPage(1); }}
>
Срочно
</button>
</div>
{/* Переключатель вида */}
<div className="btn-group btn-group-sm" role="group">
<button
className={`btn ${viewMode === 'cards' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('cards')}
title="Карточки"
>
<IconLayoutGrid size={16} />
</button>
<button
className={`btn ${viewMode === 'table' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('table')}
title="Таблица"
>
<IconList size={16} />
</button>
</div>
</div>
<div className="card-actions">
<button className="btn btn-primary" onClick={handleAddItem}>
<IconPlus size={18} className="me-1" />
Добавить
</button>
</div>
</div>
{/* Контент: карточки или таблица */}
{paginatedData.length === 0 ? (
<div className="card-body text-center text-muted py-5">
<IconCreditCard size={48} className="mb-3 text-muted" />
<h3>{searchTerm || filterUrgency ? 'Ничего не найдено' : 'Нет подписок'}</h3>
<p className="text-muted">Добавьте первую подписку для отслеживания платежей</p>
<button className="btn btn-primary" onClick={handleAddItem}>
<IconPlus size={18} className="me-1" />
Добавить подписку
</button>
</div>
) : viewMode === 'cards' ? (
/* Карточный вид */
<div className="card-body">
{paginatedData.map(item => {
const days = getDaysUntilPayment(item.nextPaymentDate);
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
return (
<BillingCard
key={item.id}
item={item}
linkedServer={linkedServer}
daysUntilPayment={days}
onEdit={handleEditItem}
onDelete={handleDeleteItem}
onAddPayment={(item) => {
setPaymentDraft({
serverId: item.id,
date: new Date().toISOString().slice(0,10),
amount: item.monthlyCost || 0,
currency: item.monthlyCostCurrency || 'USD',
note: ''
});
setShowPaymentModal(true);
}}
formatCurrencyInRUB={formatCurrencyInRUB}
/> />
); </div>
})}
</div>
) : (
/* Табличный вид */
<div className="table-responsive">
<table className="table card-table table-vcenter mb-0">
<thead>
<tr>
<th
className="cursor-pointer"
onClick={() => handleSort('hostName')}
style={{ minWidth: '200px' }}
>
Сервис
{sortField === 'hostName' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer text-end"
onClick={() => handleSort('monthlyCost')}
style={{ width: '140px' }}
>
Стоимость
{sortField === 'monthlyCost' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer text-center"
onClick={() => handleSort('nextPaymentDate')}
style={{ width: '120px' }}
>
Платёж
{sortField === 'nextPaymentDate' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th className="text-center" style={{ width: '100px' }}>Срок</th>
<th className="text-center" style={{ width: '80px' }}>Статус</th>
<th style={{ width: '140px' }}></th>
</tr>
</thead>
<tbody>
{paginatedData.map(item => {
const days = getDaysUntilPayment(item.nextPaymentDate);
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
return ( {/* Быстрые фильтры */}
<tr key={item.id}> <div className="d-flex gap-1">
<td> <button
<div className="d-flex align-items-center"> className={`btn btn-sm ${filterUrgency === '' ? 'btn-primary' : 'btn-outline-secondary'}`}
<div> onClick={() => { setFilterUrgency(''); setCurrentPage(1); }}
<div className="fw-semibold"> >
{item.hostName} Все
{item._external && ( </button>
<span className="badge bg-yellow-lt text-yellow ms-2" title="Импортирован из серверов, требует заполнения"> <button
не заполнен className={`btn btn-sm ${filterUrgency === 'overdue' ? 'btn-red' : 'btn-outline-secondary'}`}
</span> onClick={() => { setFilterUrgency('overdue'); setCurrentPage(1); }}
)} >
</div> Просрочено
<div className="text-muted small d-flex align-items-center gap-2"> </button>
<span>{item.provider}</span> <button
{linkedServer && ( className={`btn btn-sm ${filterUrgency === 'urgent' ? 'btn-yellow' : 'btn-outline-secondary'}`}
<> onClick={() => { setFilterUrgency('urgent'); setCurrentPage(1); }}
<span></span> >
<span className="text-blue">{linkedServer.ip}</span> Срочно
</> </button>
)} </div>
{item.loginUrl && (
<a {/* Переключатель вида */}
href={item.loginUrl.startsWith('http') ? item.loginUrl : `https://${item.loginUrl}`} <div className="btn-group btn-group-sm" role="group">
target="_blank" <button
rel="noopener noreferrer" className={`btn ${viewMode === 'cards' ? 'btn-primary' : 'btn-outline-secondary'}`}
className="text-muted" onClick={() => setViewMode('cards')}
title="Открыть панель" title="Карточки"
> >
<IconExternalLink size={12} /> <IconLayoutGrid size={16} />
</a> </button>
)} <button
className={`btn ${viewMode === 'table' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('table')}
title="Таблица"
>
<IconList size={16} />
</button>
</div>
</div>
<div className="card-actions">
<button className="btn btn-primary" onClick={handleAddItem}>
<IconPlus size={18} className="me-1" />
Добавить
</button>
</div>
</div>
{/* Контент: карточки или таблица */}
{paginatedData.length === 0 ? (
<div className="card-body text-center text-muted py-5">
<IconCreditCard size={48} className="mb-3 text-muted" />
<h3>{searchTerm || filterUrgency ? 'Ничего не найдено' : 'Нет подписок'}</h3>
<p className="text-muted">Добавьте первую подписку для отслеживания платежей</p>
<button className="btn btn-primary" onClick={handleAddItem}>
<IconPlus size={18} className="me-1" />
Добавить подписку
</button>
</div>
) : viewMode === 'cards' ? (
/* Карточный вид */
<div className="card-body">
{paginatedData.map(item => {
const days = getDaysUntilPayment(item.nextPaymentDate);
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
return (
<BillingCard
key={item.id}
item={item}
linkedServer={linkedServer}
daysUntilPayment={days}
onEdit={handleEditItem}
onDelete={handleDeleteItem}
onAddPayment={(item) => {
setPaymentDraft({
serverId: item.id,
date: new Date().toISOString().slice(0,10),
amount: item.monthlyCost || 0,
currency: item.monthlyCostCurrency || 'USD',
note: ''
});
setShowPaymentModal(true);
}}
formatCurrencyInRUB={formatCurrencyInRUB}
/>
);
})}
</div>
) : (
/* Табличный вид */
<div className="table-responsive">
<table className="table card-table table-vcenter mb-0">
<thead>
<tr>
<th
className="cursor-pointer"
onClick={() => handleSort('hostName')}
style={{ minWidth: '200px' }}
>
Сервис
{sortField === 'hostName' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer text-end"
onClick={() => handleSort('monthlyCost')}
style={{ width: '140px' }}
>
Стоимость
{sortField === 'monthlyCost' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th
className="cursor-pointer text-center"
onClick={() => handleSort('nextPaymentDate')}
style={{ width: '120px' }}
>
Платёж
{sortField === 'nextPaymentDate' && (
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</th>
<th className="text-center" style={{ width: '100px' }}>Срок</th>
<th className="text-center" style={{ width: '80px' }}>Статус</th>
<th style={{ width: '140px' }}></th>
</tr>
</thead>
<tbody>
{paginatedData.map(item => {
const days = getDaysUntilPayment(item.nextPaymentDate);
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
return (
<tr key={item.id}>
<td>
<div className="d-flex align-items-center">
<div>
<div className="fw-semibold">
{item.hostName}
{item._external && (
<span className="badge bg-yellow-lt text-yellow ms-2" title="Импортирован из серверов, требует заполнения">
не заполнен
</span>
)}
</div>
<div className="text-muted small d-flex align-items-center gap-2">
<span>{item.provider}</span>
{linkedServer && (
<>
<span></span>
<span className="text-blue">{linkedServer.ip}</span>
</>
)}
{item.loginUrl && (
<a
href={item.loginUrl.startsWith('http') ? item.loginUrl : `https://${item.loginUrl}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted"
title="Открыть панель"
>
<IconExternalLink size={12} />
</a>
)}
</div>
</div> </div>
</div> </div>
</div> </td>
</td> <td className="text-end">
<td className="text-end"> <div className="fw-semibold">
<div className="fw-semibold"> {formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
</div>
{item.monthlyCostCurrency !== 'RUB' && (
<div className="text-muted small">
{formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
</div> </div>
)} {item.monthlyCostCurrency !== 'RUB' && (
</td> <div className="text-muted small">
<td className="text-center"> {formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
{formatDate(item.nextPaymentDate)} </div>
</td> )}
<td className="text-center"> </td>
<UrgencyBadge days={days} /> <td className="text-center">
</td> {formatDate(item.nextPaymentDate)}
<td className="text-center"> </td>
{item.status === 'active' ? ( <td className="text-center">
<span className="badge bg-green-lt text-green">Активен</span> <UrgencyBadge days={days} />
) : ( </td>
<span className="badge bg-secondary-lt text-secondary">Неактивен</span> <td className="text-center">
)} {item.status === 'active' ? (
</td> <span className="badge bg-green-lt text-green">Активен</span>
<td> ) : (
<div className="btn-list gap-1 justify-content-end flex-nowrap"> <span className="badge bg-secondary-lt text-secondary">Неактивен</span>
<button )}
className="btn btn-outline-success btn-sm" </td>
onClick={() => { <td>
setPaymentDraft({ <div className="btn-list gap-1 justify-content-end flex-nowrap">
serverId: item.id, <button
date: new Date().toISOString().slice(0,10), className="btn btn-outline-success btn-sm"
amount: item.monthlyCost || 0, onClick={() => {
currency: item.monthlyCostCurrency || 'USD', setPaymentDraft({
note: '' serverId: item.id,
}); date: new Date().toISOString().slice(0,10),
setShowPaymentModal(true); amount: item.monthlyCost || 0,
}} currency: item.monthlyCostCurrency || 'USD',
title="Записать платёж" note: ''
> });
<IconCreditCard size={14} className="me-1" /> setShowPaymentModal(true);
Оплата }}
</button> title="Записать платёж"
<button >
className="btn btn-outline-secondary btn-sm" <IconCreditCard size={14} className="me-1" />
onClick={() => handleEditItem(item)} Оплата
title="Редактировать" </button>
> <button
<IconEdit size={14} /> className="btn btn-outline-secondary btn-sm"
</button> onClick={() => handleEditItem(item)}
<button title="Редактировать"
className="btn btn-outline-danger btn-sm" >
onClick={() => handleDeleteItem(item)} <IconEdit size={14} />
title="Удалить" </button>
> <button
<IconTrash size={14} /> className="btn btn-outline-danger btn-sm"
</button> onClick={() => handleDeleteItem(item)}
</div> title="Удалить"
</td> >
</tr> <IconTrash size={14} />
); </button>
})} </div>
</tbody> </td>
</table> </tr>
</div> );
)} })}
</tbody>
</table>
</div>
)}
{totalPages > 1 && ( {totalPages > 1 && (
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
totalItems={filteredData.length} totalItems={filteredData.length}
pageSize={pageSize} pageSize={pageSize}
onPageChange={setCurrentPage} onPageChange={setCurrentPage}
/> />
)} )}
</div> </div>
)}
{/* История платежей — компактная версия */} {/* История платежей — компактная версия */}
{activeTab === 'payments' && (
<div className="card mt-4"> <div className="card mt-4">
<div className="card-header"> <div className="card-header">
<h3 className="card-title d-flex align-items-center gap-2"> <h3 className="card-title d-flex align-items-center gap-2">