feat(BillingManager): add view mode toggle for billing display; implement card and table layouts for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
This commit is contained in:
+200
-139
@@ -34,9 +34,12 @@ import {
|
||||
IconX,
|
||||
IconChecks,
|
||||
IconServer,
|
||||
IconClock
|
||||
IconClock,
|
||||
IconLayoutGrid,
|
||||
IconList
|
||||
} from '@tabler/icons-react';
|
||||
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
||||
import { BillingCard } from './components/billing/index.js';
|
||||
|
||||
function BillingManager() {
|
||||
const [billingData, setBillingData] = useState([]);
|
||||
@@ -50,6 +53,7 @@ function BillingManager() {
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterUrgency, setFilterUrgency] = useState('');
|
||||
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
|
||||
const pageSize = 15;
|
||||
|
||||
// Модальные окна
|
||||
@@ -566,7 +570,7 @@ function BillingManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основная таблица */}
|
||||
{/* Основная секция */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="d-flex align-items-center gap-3 flex-grow-1">
|
||||
@@ -610,6 +614,24 @@ function BillingManager() {
|
||||
Срочно
|
||||
</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">
|
||||
@@ -620,148 +642,187 @@ function BillingManager() {
|
||||
</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: '120px' }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedData.length === 0 ? (
|
||||
{/* Контент: карточки или таблица */}
|
||||
{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>
|
||||
<td colSpan="6" className="text-center text-muted py-4">
|
||||
{searchTerm || filterUrgency ? 'Ничего не найдено' : 'Нет данных'}
|
||||
</td>
|
||||
<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>
|
||||
) : 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}</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>
|
||||
)}
|
||||
</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}</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>
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<div className="fw-semibold">
|
||||
{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
|
||||
</div>
|
||||
{item.monthlyCostCurrency !== 'RUB' && (
|
||||
<div className="text-muted small">
|
||||
≈ {formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<div className="fw-semibold">
|
||||
{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{formatDate(item.nextPaymentDate)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<UrgencyBadge days={days} />
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{item.status === 'active' ? (
|
||||
<span className="badge bg-green-lt text-green">Активен</span>
|
||||
) : (
|
||||
<span className="badge bg-secondary-lt text-secondary">Неактивен</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="btn-list gap-1 justify-content-end flex-nowrap">
|
||||
<button
|
||||
className="btn btn-icon btn-sm btn-outline-success"
|
||||
onClick={() => {
|
||||
setPaymentDraft({
|
||||
serverId: item.id,
|
||||
date: new Date().toISOString().slice(0,10),
|
||||
amount: item.monthlyCost || 0,
|
||||
currency: item.monthlyCostCurrency || 'USD',
|
||||
note: ''
|
||||
});
|
||||
setShowPaymentModal(true);
|
||||
}}
|
||||
title="Записать платёж"
|
||||
>
|
||||
<IconCreditCard size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-icon btn-sm btn-outline-primary"
|
||||
onClick={() => handleEditItem(item)}
|
||||
title="Редактировать"
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-icon btn-sm btn-outline-danger"
|
||||
onClick={() => handleDeleteItem(item)}
|
||||
title="Удалить"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{item.monthlyCostCurrency !== 'RUB' && (
|
||||
<div className="text-muted small">
|
||||
≈ {formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{formatDate(item.nextPaymentDate)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<UrgencyBadge days={days} />
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{item.status === 'active' ? (
|
||||
<span className="badge bg-green-lt text-green">Активен</span>
|
||||
) : (
|
||||
<span className="badge bg-secondary-lt text-secondary">Неактивен</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="btn-list gap-1 justify-content-end flex-nowrap">
|
||||
<button
|
||||
className="btn btn-outline-success btn-sm"
|
||||
onClick={() => {
|
||||
setPaymentDraft({
|
||||
serverId: item.id,
|
||||
date: new Date().toISOString().slice(0,10),
|
||||
amount: item.monthlyCost || 0,
|
||||
currency: item.monthlyCostCurrency || 'USD',
|
||||
note: ''
|
||||
});
|
||||
setShowPaymentModal(true);
|
||||
}}
|
||||
title="Записать платёж"
|
||||
>
|
||||
<IconCreditCard size={14} className="me-1" />
|
||||
Оплата
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
onClick={() => handleEditItem(item)}
|
||||
title="Редактировать"
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={() => handleDeleteItem(item)}
|
||||
title="Удалить"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
|
||||
Reference in New Issue
Block a user