redesign: admin subscription tab — two-level navigation for multi-tariff

Level 1 (subscription list):
- Cards with tariff name, status badge, traffic, date, devices
- Click to drill into subscription detail
- "Создать подписку" with filtered purchased tariffs

Level 2 (subscription detail):
- Back button to return to list
- All actions scoped to selected subscription
- No "Сменить тариф" in multi-subscription mode
- Panel info, devices, traffic stats per-subscription

Single subscription: keeps current behavior (direct detail view)
This commit is contained in:
c0mrade
2026-03-24 17:44:58 +03:00
parent c7c2167908
commit 3a3cd0b445

View File

@@ -336,6 +336,7 @@ export default function AdminUserDetail() {
const [subDays, setSubDays] = useState<number | ''>(30); const [subDays, setSubDays] = useState<number | ''>(30);
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null); const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
const [activeSubscriptionId, setActiveSubscriptionId] = useState<number | null>(null); const [activeSubscriptionId, setActiveSubscriptionId] = useState<number | null>(null);
const [subscriptionDetailView, setSubscriptionDetailView] = useState(false);
// Promo group // Promo group
const [promoGroups, setPromoGroups] = useState<PromoGroup[]>([]); const [promoGroups, setPromoGroups] = useState<PromoGroup[]>([]);
@@ -1379,40 +1380,133 @@ export default function AdminUserDetail() {
{/* Subscription Tab */} {/* Subscription Tab */}
{activeTab === 'subscription' && ( {activeTab === 'subscription' && (
<div className="space-y-4"> <div className="space-y-4">
{/* Subscription selector (when multiple) */} {/* Multi-subscription: Level 1 — subscription list */}
{userSubscriptions.length > 1 && ( {userSubscriptions.length > 1 && !subscriptionDetailView && (
<div className="rounded-xl bg-dark-800/50 p-4"> <>
<div className="mb-2 text-xs font-medium text-dark-500"> <div className="space-y-3">
{t('admin.users.detail.subscription.selectSubscription', 'Выберите подписку')} (
{userSubscriptions.length})
</div>
<div className="flex flex-wrap gap-2">
{userSubscriptions.map((sub) => ( {userSubscriptions.map((sub) => (
<button <button
key={sub.id} key={sub.id}
onClick={() => setActiveSubscriptionId(sub.id)} onClick={() => {
className={`rounded-lg px-3 py-1.5 text-sm transition-all ${ setActiveSubscriptionId(sub.id);
activeSubscriptionId === sub.id setSubscriptionDetailView(true);
? 'bg-accent-500 text-white' }}
: 'bg-dark-700/50 text-dark-300 hover:bg-dark-700' className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 p-4 text-left transition-all hover:border-dark-600"
}`}
> >
<span className="flex items-center gap-1.5"> <div className="flex items-center justify-between">
<span <div className="flex items-center gap-2">
className={`inline-block h-2 w-2 rounded-full ${ <span className="text-sm font-semibold text-dark-100">
sub.is_active ? 'bg-emerald-400' : 'bg-red-400' {sub.tariff_name || `#${sub.id}`}
}`} </span>
/> <StatusBadge status={sub.status} />
{sub.tariff_name || `#${sub.id}`} </div>
</span> <svg
className="h-4 w-4 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</div>
<div className="mt-2 flex items-center gap-4 text-xs text-dark-400">
<span>
{sub.traffic_used_gb.toFixed(1)} / {sub.traffic_limit_gb}{' '}
{t('common.units.gb')}
</span>
<span>{formatDate(sub.end_date)}</span>
<span>
{sub.device_limit}{' '}
{t('admin.users.detail.subscription.devices', 'устройств')}
</span>
</div>
</button> </button>
))} ))}
</div> </div>
</div>
{/* Create new subscription — at list level */}
{hasPermission('users:subscription') && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.subscription.createNew', 'Создать подписку')}
</div>
<div className="space-y-3">
<select
value={selectedTariffId || ''}
onChange={(e) =>
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
}
className="input"
>
<option value="">
{t('admin.users.detail.subscription.selectTariff')}
</option>
{tariffs
.filter((tariffItem) => {
const purchasedIds = new Set(
userSubscriptions
.filter((s) => s.is_active || s.status === 'trial')
.map((s) => s.tariff_id),
);
return !purchasedIds.has(tariffItem.id);
})
.map((tariffItem) => (
<option key={tariffItem.id} value={tariffItem.id}>
{tariffItem.name}
</option>
))}
</select>
<input
type="number"
value={subDays}
onChange={createNumberInputHandler(setSubDays, 1)}
placeholder={t('admin.users.detail.subscription.days')}
className="input"
min={1}
max={3650}
/>
<button
onClick={() => handleUpdateSubscription('create')}
disabled={actionLoading}
className="btn-primary w-full"
>
{actionLoading
? t('admin.users.detail.subscription.creating')
: t('admin.users.detail.subscription.create')}
</button>
</div>
</div>
)}
</>
)} )}
{selectedSub ? ( {/* Level 2 — subscription detail (or single subscription) */}
{(subscriptionDetailView || userSubscriptions.length <= 1) && selectedSub ? (
<> <>
{/* Back to list (multi-subscription) */}
{subscriptionDetailView && userSubscriptions.length > 1 && (
<button
onClick={() => setSubscriptionDetailView(false)}
className="flex items-center gap-1.5 text-sm text-dark-400 transition-colors hover:text-dark-200"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
{t('admin.users.detail.subscription.backToList', 'Все подписки')}
</button>
)}
{/* Current subscription */} {/* Current subscription */}
<div className="rounded-xl bg-dark-800/50 p-4"> <div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
@@ -1602,9 +1696,11 @@ export default function AdminUserDetail() {
<option value="shorten"> <option value="shorten">
{t('admin.users.detail.subscription.shorten')} {t('admin.users.detail.subscription.shorten')}
</option> </option>
<option value="change_tariff"> {userSubscriptions.length <= 1 && (
{t('admin.users.detail.subscription.changeTariff')} <option value="change_tariff">
</option> {t('admin.users.detail.subscription.changeTariff')}
</option>
)}
<option value="cancel"> <option value="cancel">
{t('admin.users.detail.subscription.cancel')} {t('admin.users.detail.subscription.cancel')}
</option> </option>
@@ -1661,66 +1757,67 @@ export default function AdminUserDetail() {
</> </>
) : null} ) : null}
{/* Create new subscription (shown when no subs, or always in multi-tariff) */} {/* Create new subscription (shown when no subs, or single-tariff mode) */}
{hasPermission('users:subscription') && ( {hasPermission('users:subscription') &&
<div className="rounded-xl bg-dark-800/50 p-4"> !(subscriptionDetailView && userSubscriptions.length > 1) && (
{userSubscriptions.length === 0 && ( <div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-4 text-center text-dark-400"> {userSubscriptions.length === 0 && (
{t('admin.users.detail.subscription.noActive')} <div className="mb-4 text-center text-dark-400">
{t('admin.users.detail.subscription.noActive')}
</div>
)}
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.subscription.createNew', 'Создать подписку')}
</div>
<div className="space-y-3">
<select
value={selectedTariffId || ''}
onChange={(e) =>
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
}
className="input"
>
<option value="">{t('admin.users.detail.subscription.selectTariff')}</option>
{tariffs
.filter((tariffItem) => {
// In multi-tariff: hide tariffs user already has
if (userSubscriptions.length > 0) {
const purchasedIds = new Set(
userSubscriptions
.filter((s) => s.is_active || s.status === 'trial')
.map((s) => s.tariff_id),
);
return !purchasedIds.has(tariffItem.id);
}
return true;
})
.map((tariffItem) => (
<option key={tariffItem.id} value={tariffItem.id}>
{tariffItem.name}
</option>
))}
</select>
<input
type="number"
value={subDays}
onChange={createNumberInputHandler(setSubDays, 1)}
placeholder={t('admin.users.detail.subscription.days')}
className="input"
min={1}
max={3650}
/>
<button
onClick={() => handleUpdateSubscription('create')}
disabled={actionLoading}
className="btn-primary w-full"
>
{actionLoading
? t('admin.users.detail.subscription.creating')
: t('admin.users.detail.subscription.create')}
</button>
</div> </div>
)}
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.subscription.createNew', 'Создать подписку')}
</div> </div>
<div className="space-y-3"> )}
<select
value={selectedTariffId || ''}
onChange={(e) =>
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
}
className="input"
>
<option value="">{t('admin.users.detail.subscription.selectTariff')}</option>
{tariffs
.filter((tariffItem) => {
// In multi-tariff: hide tariffs user already has
if (userSubscriptions.length > 0) {
const purchasedIds = new Set(
userSubscriptions
.filter((s) => s.is_active || s.status === 'trial')
.map((s) => s.tariff_id),
);
return !purchasedIds.has(tariffItem.id);
}
return true;
})
.map((tariffItem) => (
<option key={tariffItem.id} value={tariffItem.id}>
{tariffItem.name}
</option>
))}
</select>
<input
type="number"
value={subDays}
onChange={createNumberInputHandler(setSubDays, 1)}
placeholder={t('admin.users.detail.subscription.days')}
className="input"
min={1}
max={3650}
/>
<button
onClick={() => handleUpdateSubscription('create')}
disabled={actionLoading}
className="btn-primary w-full"
>
{actionLoading
? t('admin.users.detail.subscription.creating')
: t('admin.users.detail.subscription.create')}
</button>
</div>
</div>
)}
{/* Panel Info */} {/* Panel Info */}
{panelInfoLoading ? ( {panelInfoLoading ? (