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:
+201
-140
@@ -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' }}
|
|
||||||
>
|
|
||||||
Стоимость
|
|
||||||
{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 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan="6" className="text-center text-muted py-4">
|
|
||||||
{searchTerm || filterUrgency ? 'Ничего не найдено' : 'Нет данных'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : paginatedData.map(item => {
|
|
||||||
const days = getDaysUntilPayment(item.nextPaymentDate);
|
|
||||||
const linkedServer = item.serverId ? serversById.get(item.serverId) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={item.id}>
|
<BillingCard
|
||||||
<td>
|
key={item.id}
|
||||||
<div className="d-flex align-items-center">
|
item={item}
|
||||||
<div>
|
linkedServer={linkedServer}
|
||||||
<div className="fw-semibold">{item.hostName}</div>
|
daysUntilPayment={days}
|
||||||
<div className="text-muted small d-flex align-items-center gap-2">
|
onEdit={handleEditItem}
|
||||||
<span>{item.provider}</span>
|
onDelete={handleDeleteItem}
|
||||||
{linkedServer && (
|
onAddPayment={(item) => {
|
||||||
<>
|
setPaymentDraft({
|
||||||
<span>•</span>
|
serverId: item.id,
|
||||||
<span className="text-blue">{linkedServer.ip}</span>
|
date: new Date().toISOString().slice(0,10),
|
||||||
</>
|
amount: item.monthlyCost || 0,
|
||||||
)}
|
currency: item.monthlyCostCurrency || 'USD',
|
||||||
{item.loginUrl && (
|
note: ''
|
||||||
<a
|
});
|
||||||
href={item.loginUrl.startsWith('http') ? item.loginUrl : `https://${item.loginUrl}`}
|
setShowPaymentModal(true);
|
||||||
target="_blank"
|
}}
|
||||||
rel="noopener noreferrer"
|
formatCurrencyInRUB={formatCurrencyInRUB}
|
||||||
className="text-muted"
|
/>
|
||||||
title="Открыть панель"
|
);
|
||||||
>
|
})}
|
||||||
<IconExternalLink size={12} />
|
</div>
|
||||||
</a>
|
) : (
|
||||||
)}
|
/* Табличный вид */
|
||||||
|
<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}</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-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as BillingCard } from './BillingCard.jsx';
|
||||||
Reference in New Issue
Block a user