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

This commit is contained in:
2026-01-20 17:13:12 +07:00
parent 8f1668668c
commit ed34332df8
3 changed files with 455 additions and 139 deletions
+200 -139
View File
@@ -34,9 +34,12 @@ import {
IconX, IconX,
IconChecks, IconChecks,
IconServer, IconServer,
IconClock IconClock,
IconLayoutGrid,
IconList
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import BulkActionsBar from './components/BulkActionsBar.jsx'; import BulkActionsBar from './components/BulkActionsBar.jsx';
import { BillingCard } from './components/billing/index.js';
function BillingManager() { function BillingManager() {
const [billingData, setBillingData] = useState([]); const [billingData, setBillingData] = useState([]);
@@ -50,6 +53,7 @@ function BillingManager() {
const [sortOrder, setSortOrder] = useState('asc'); const [sortOrder, setSortOrder] = useState('asc');
const [filterStatus, setFilterStatus] = useState(''); const [filterStatus, setFilterStatus] = useState('');
const [filterUrgency, setFilterUrgency] = useState(''); const [filterUrgency, setFilterUrgency] = useState('');
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
const pageSize = 15; const pageSize = 15;
// Модальные окна // Модальные окна
@@ -566,7 +570,7 @@ function BillingManager() {
</div> </div>
</div> </div>
{/* Основная таблица */} {/* Основная секция */}
<div className="card"> <div className="card">
<div className="card-header"> <div className="card-header">
<div className="d-flex align-items-center gap-3 flex-grow-1"> <div className="d-flex align-items-center gap-3 flex-grow-1">
@@ -610,6 +614,24 @@ function BillingManager() {
Срочно Срочно
</button> </button>
</div> </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>
<div className="card-actions"> <div className="card-actions">
@@ -620,148 +642,187 @@ function BillingManager() {
</div> </div>
</div> </div>
<div className="table-responsive"> {/* Контент: карточки или таблица */}
<table className="table card-table table-vcenter mb-0"> {paginatedData.length === 0 ? (
<thead> <div className="card-body text-center text-muted py-5">
<tr> <IconCreditCard size={48} className="mb-3 text-muted" />
<th <h3>{searchTerm || filterUrgency ? 'Ничего не найдено' : 'Нет подписок'}</h3>
className="cursor-pointer" <p className="text-muted">Добавьте первую подписку для отслеживания платежей</p>
onClick={() => handleSort('hostName')} <button className="btn btn-primary" onClick={handleAddItem}>
style={{ minWidth: '200px' }} <IconPlus size={18} className="me-1" />
> Добавить подписку
Сервис </button>
{sortField === 'hostName' && ( </div>
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span> ) : viewMode === 'cards' ? (
)} /* Карточный вид */
</th> <div className="card-body">
<th {paginatedData.map(item => {
className="cursor-pointer text-end" const days = getDaysUntilPayment(item.nextPaymentDate);
onClick={() => handleSort('monthlyCost')} const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
style={{ width: '140px' }}
> return (
Стоимость <BillingCard
{sortField === 'monthlyCost' && ( key={item.id}
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span> item={item}
)} linkedServer={linkedServer}
</th> daysUntilPayment={days}
<th onEdit={handleEditItem}
className="cursor-pointer text-center" onDelete={handleDeleteItem}
onClick={() => handleSort('nextPaymentDate')} onAddPayment={(item) => {
style={{ width: '120px' }} setPaymentDraft({
> serverId: item.id,
Платёж date: new Date().toISOString().slice(0,10),
{sortField === 'nextPaymentDate' && ( amount: item.monthlyCost || 0,
<span className="ms-1">{sortOrder === 'asc' ? '↑' : '↓'}</span> currency: item.monthlyCostCurrency || 'USD',
)} note: ''
</th> });
<th className="text-center" style={{ width: '100px' }}>Срок</th> setShowPaymentModal(true);
<th className="text-center" style={{ width: '80px' }}>Статус</th> }}
<th style={{ width: '120px' }}></th> formatCurrencyInRUB={formatCurrencyInRUB}
</tr> />
</thead> );
<tbody> })}
{paginatedData.length === 0 ? ( </div>
) : (
/* Табличный вид */
<div className="table-responsive">
<table className="table card-table table-vcenter mb-0">
<thead>
<tr> <tr>
<td colSpan="6" className="text-center text-muted py-4"> <th
{searchTerm || filterUrgency ? 'Ничего не найдено' : 'Нет данных'} className="cursor-pointer"
</td> 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> </tr>
) : paginatedData.map(item => { </thead>
const days = getDaysUntilPayment(item.nextPaymentDate); <tbody>
const linkedServer = item.serverId ? serversById.get(item.serverId) : null; {paginatedData.map(item => {
const days = getDaysUntilPayment(item.nextPaymentDate);
return ( const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
<tr key={item.id}>
<td> return (
<div className="d-flex align-items-center"> <tr key={item.id}>
<div> <td>
<div className="fw-semibold">{item.hostName}</div> <div className="d-flex align-items-center">
<div className="text-muted small d-flex align-items-center gap-2"> <div>
<span>{item.provider}</span> <div className="fw-semibold">{item.hostName}</div>
{linkedServer && ( <div className="text-muted small d-flex align-items-center gap-2">
<> <span>{item.provider}</span>
<span></span> {linkedServer && (
<span className="text-blue">{linkedServer.ip}</span> <>
</> <span></span>
)} <span className="text-blue">{linkedServer.ip}</span>
{item.loginUrl && ( </>
<a )}
href={item.loginUrl.startsWith('http') ? item.loginUrl : `https://${item.loginUrl}`} {item.loginUrl && (
target="_blank" <a
rel="noopener noreferrer" href={item.loginUrl.startsWith('http') ? item.loginUrl : `https://${item.loginUrl}`}
className="text-muted" target="_blank"
title="Открыть панель" rel="noopener noreferrer"
> className="text-muted"
<IconExternalLink size={12} /> title="Открыть панель"
</a> >
)} <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-icon btn-sm btn-outline-success" </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={16} /> setShowPaymentModal(true);
</button> }}
<button title="Записать платёж"
className="btn btn-icon btn-sm btn-outline-primary" >
onClick={() => handleEditItem(item)} <IconCreditCard size={14} className="me-1" />
title="Редактировать" Оплата
> </button>
<IconEdit size={16} /> <button
</button> className="btn btn-outline-secondary btn-sm"
<button onClick={() => handleEditItem(item)}
className="btn btn-icon btn-sm btn-outline-danger" title="Редактировать"
onClick={() => handleDeleteItem(item)} >
title="Удалить" <IconEdit size={14} />
> </button>
<IconTrash size={16} /> <button
</button> className="btn btn-outline-danger btn-sm"
</div> onClick={() => handleDeleteItem(item)}
</td> title="Удалить"
</tr> >
); <IconTrash size={14} />
})} </button>
</tbody> </div>
</table> </td>
</div> </tr>
);
})}
</tbody>
</table>
</div>
)}
{totalPages > 1 && ( {totalPages > 1 && (
<Pagination <Pagination
@@ -0,0 +1,254 @@
import {
IconEdit,
IconTrash,
IconChevronDown,
IconChevronUp,
IconCreditCard,
IconExternalLink,
IconCalendar,
IconAlertTriangle,
IconCheck,
IconServer,
IconClock
} from '@tabler/icons-react';
import { useState } from 'react';
// Форматирование даты
function formatDate(dateString) {
if (!dateString) return '—';
try {
return new Date(dateString).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' });
} catch {
return dateString;
}
}
// Форматирование валюты
function formatCurrency(amount, currency = 'USD') {
if (amount === undefined || amount === null || amount === '') return '—';
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount);
}
export default function BillingCard({
item,
linkedServer,
daysUntilPayment,
onEdit,
onDelete,
onAddPayment,
formatCurrencyInRUB
}) {
const [expanded, setExpanded] = useState(false);
const days = daysUntilPayment;
const isOverdue = days !== null && days < 0;
const isUrgent = days !== null && days >= 0 && days <= 7;
const isActive = item.status === 'active';
// Определяем цвет левой панели
const getPanelColor = () => {
if (isOverdue) return 'bg-red-lt';
if (isUrgent) return 'bg-yellow-lt';
if (!isActive) return 'bg-secondary-lt';
return 'bg-green-lt';
};
const getPanelTextColor = () => {
if (isOverdue) return 'text-red';
if (isUrgent) return 'text-yellow';
if (!isActive) return 'text-secondary';
return 'text-green';
};
return (
<div
className="card mb-2"
style={{
borderRadius: '12px',
borderWidth: '1px',
transition: 'all 0.15s ease'
}}
>
<div className="card-body p-0">
{/* Основная строка */}
<div className="d-flex align-items-stretch">
{/* Левая часть: статус */}
<div
className={`d-flex align-items-center justify-content-center px-3 ${getPanelColor()}`}
style={{
minWidth: '90px',
borderTopLeftRadius: '11px',
borderBottomLeftRadius: expanded ? '0' : '11px'
}}
>
<div className="text-center">
<div className="mb-1">
{isOverdue ? (
<IconAlertTriangle size={22} className="text-red" />
) : isUrgent ? (
<IconClock size={22} className="text-yellow" />
) : isActive ? (
<IconCheck size={22} className="text-green" />
) : (
<IconServer size={22} className="text-secondary" />
)}
</div>
<div className={`fw-bold ${getPanelTextColor()}`} style={{ fontSize: '0.6rem', letterSpacing: '0.5px' }}>
{isOverdue ? 'ПРОСРОЧЕН' : isUrgent ? 'СРОЧНО' : isActive ? 'АКТИВЕН' : 'НЕАКТИВЕН'}
</div>
</div>
</div>
{/* Центральная часть: информация */}
<div className="flex-grow-1 py-2 px-3" style={{ cursor: 'pointer' }} onClick={() => setExpanded(!expanded)}>
{/* Верхняя строка */}
<div className="d-flex align-items-center mb-1">
<div className="d-flex align-items-center me-3">
<div>
<span className="fw-semibold">{item.hostName}</span>
<span className="text-muted mx-2"></span>
<span className="text-muted">{item.provider}</span>
</div>
</div>
{/* Стоимость справа */}
<div className="ms-auto text-end">
<span className="fw-bold">{formatCurrency(item.monthlyCost, item.monthlyCostCurrency)}</span>
{item.monthlyCostCurrency !== 'RUB' && formatCurrencyInRUB && (
<span className="text-muted small ms-2">
{formatCurrencyInRUB(item.monthlyCost, item.monthlyCostCurrency)}
</span>
)}
</div>
</div>
{/* Нижняя строка */}
<div className="d-flex align-items-center gap-2">
{linkedServer && (
<span className="badge bg-blue-lt text-blue">
<IconServer size={12} className="me-1" />
{linkedServer.ip}
</span>
)}
<span className={`badge ${isOverdue ? 'bg-red-lt text-red' : isUrgent ? 'bg-yellow-lt text-yellow' : 'bg-secondary-lt text-secondary'}`}>
<IconCalendar size={12} className="me-1" />
{formatDate(item.nextPaymentDate)}
{days !== null && (
<span className="ms-1">
({days < 0 ? `${Math.abs(days)} дн. назад` : days === 0 ? 'сегодня' : `через ${days} дн.`})
</span>
)}
</span>
{item.loginUrl && (
<a
href={item.loginUrl.startsWith('http') ? item.loginUrl : `https://${item.loginUrl}`}
target="_blank"
rel="noopener noreferrer"
className="badge bg-azure-lt text-azure text-decoration-none"
onClick={(e) => e.stopPropagation()}
>
<IconExternalLink size={12} className="me-1" />
Панель
</a>
)}
</div>
</div>
{/* Правая часть: действия */}
<div className="d-flex align-items-center gap-2 px-3 border-start">
<button
className="btn btn-outline-success"
title="Записать платёж"
onClick={(e) => { e.stopPropagation(); onAddPayment?.(item); }}
>
<IconCreditCard size={16} className="me-1" />
Оплатить
</button>
<button
className="btn btn-outline-secondary"
title="Редактировать"
onClick={(e) => { e.stopPropagation(); onEdit?.(item); }}
>
<IconEdit size={16} className="me-1" />
Изменить
</button>
<button
className="btn btn-outline-danger"
title="Удалить"
onClick={(e) => { e.stopPropagation(); onDelete?.(item); }}
>
<IconTrash size={16} />
</button>
<button
className="btn btn-ghost-secondary btn-icon ms-1"
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
title={expanded ? 'Свернуть' : 'Подробнее'}
>
{expanded ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />}
</button>
</div>
</div>
{/* Расширенная информация */}
{expanded && (
<div className="border-top bg-light" style={{ borderBottomLeftRadius: '11px', borderBottomRightRadius: '11px' }}>
<div className="row g-0">
{/* Детали подписки */}
<div className="col-md-6 p-3 border-end">
<div className="text-muted small mb-2 fw-medium">
<IconServer size={14} className="me-1" />
Детали подписки
</div>
<div className="row g-2">
<div className="col-6">
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase' }}>Провайдер</div>
<div className="fw-medium">{item.provider || '—'}</div>
</div>
<div className="col-6">
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase' }}>Статус</div>
<span className={`badge ${isActive ? 'bg-green-lt text-green' : 'bg-secondary-lt text-secondary'}`}>
{isActive ? 'Активен' : 'Неактивен'}
</span>
</div>
{item.notes && (
<div className="col-12 mt-2">
<div className="text-muted" style={{ fontSize: '0.65rem', textTransform: 'uppercase' }}>Заметки</div>
<div className="text-muted small">{item.notes}</div>
</div>
)}
</div>
</div>
{/* История платежей */}
<div className="col-md-6 p-3">
<div className="text-muted small mb-2 fw-medium">
<IconCreditCard size={14} className="me-1" />
Последние платежи ({item.payments?.length || 0})
</div>
{item.payments?.length > 0 ? (
<div className="d-flex flex-column gap-1">
{item.payments.slice(-3).reverse().map((p, idx) => (
<div key={idx} className="d-flex justify-content-between align-items-center small">
<span className="text-muted">{formatDate(p.date)}</span>
<span className="fw-medium">{formatCurrency(p.amount, p.currency)}</span>
</div>
))}
</div>
) : (
<span className="text-muted small">Нет платежей</span>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { default as BillingCard } from './BillingCard.jsx';