feat: tune visual of web app admin panel, tune limit messages and etc
This commit is contained in:
+275
-8
@@ -14,7 +14,7 @@ import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from aiogram import Bot
|
||||
from aiohttp import web
|
||||
@@ -163,10 +163,15 @@ def _serialize_user(user: User) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
||||
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
|
||||
regular_bonus_bytes = int(getattr(sub, "regular_bonus_bytes", 0) or 0)
|
||||
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
|
||||
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
|
||||
premium_limit_bytes = (
|
||||
int(sub.premium_baseline_bytes or 0)
|
||||
+ int(sub.premium_topup_balance_bytes or 0)
|
||||
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
|
||||
+ premium_bonus_bytes
|
||||
)
|
||||
return {
|
||||
"subscription_id": int(sub.subscription_id),
|
||||
@@ -186,6 +191,10 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
||||
"premium_baseline_bytes": sub.premium_baseline_bytes,
|
||||
"premium_topup_balance_bytes": sub.premium_topup_balance_bytes,
|
||||
"premium_topup_used_bytes": getattr(sub, "premium_topup_used_bytes", 0),
|
||||
"premium_bonus_bytes": premium_bonus_bytes,
|
||||
"regular_bonus_bytes": regular_bonus_bytes,
|
||||
"regular_unlimited_override": regular_unlimited_override,
|
||||
"premium_unlimited_override": premium_unlimited_override,
|
||||
"premium_is_limited": bool(sub.premium_is_limited),
|
||||
"tariff_key": sub.tariff_key,
|
||||
"auto_renew_enabled": bool(sub.auto_renew_enabled),
|
||||
@@ -194,17 +203,47 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Optional[float]]:
|
||||
"""For traffic purchases: ``(regular_gb, premium_gb)``. Other payments → (None, None)."""
|
||||
if payment.purchased_gb is None:
|
||||
return None, None
|
||||
try:
|
||||
gb = float(payment.purchased_gb)
|
||||
except (TypeError, ValueError):
|
||||
return None, None
|
||||
sm = (payment.sale_mode or "").strip()
|
||||
if not sm:
|
||||
return None, None
|
||||
base = sm.split("@", 1)[0].split("|", 1)[0].lower()
|
||||
if base == "premium_topup":
|
||||
return None, gb
|
||||
if base in {"traffic", "traffic_package", "topup"}:
|
||||
return gb, None
|
||||
return None, None
|
||||
|
||||
|
||||
def _serialize_payment(payment: Payment) -> Dict[str, Any]:
|
||||
# Avoid lazy-loading `payment.user` outside an active SQLAlchemy session.
|
||||
# Some admin routes serialize payments after the session scope is closed.
|
||||
user_label = str(payment.user_id)
|
||||
telegram_id = None
|
||||
loaded_user = payment.__dict__.get("user")
|
||||
if loaded_user is not None:
|
||||
user_label = loaded_user.username or loaded_user.first_name or str(payment.user_id)
|
||||
tid = getattr(loaded_user, "telegram_id", None)
|
||||
if tid is not None:
|
||||
try:
|
||||
telegram_id = int(tid)
|
||||
except (TypeError, ValueError):
|
||||
telegram_id = None
|
||||
reg_gb, prem_gb = _payment_traffic_gb_split(payment)
|
||||
return {
|
||||
"payment_id": int(payment.payment_id),
|
||||
"user_id": int(payment.user_id),
|
||||
"user_label": user_label,
|
||||
"telegram_id": telegram_id,
|
||||
"traffic_regular_gb": reg_gb,
|
||||
"traffic_premium_gb": prem_gb,
|
||||
"provider": payment.provider,
|
||||
"provider_payment_id": payment.provider_payment_id,
|
||||
"amount": float(payment.amount),
|
||||
@@ -992,6 +1031,204 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response:
|
||||
return _ok({})
|
||||
|
||||
|
||||
async def admin_user_premium_override_route(request: web.Request) -> web.Response:
|
||||
"""Premium-squad traffic overrides only (unlimited toggle + bonus GB)."""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
|
||||
unlimited = bool(payload.get("unlimited"))
|
||||
bonus_bytes_raw = payload.get("bonus_bytes")
|
||||
bonus_gb_raw = payload.get("bonus_gb")
|
||||
if bonus_bytes_raw is None and bonus_gb_raw is None:
|
||||
bonus_bytes = 0
|
||||
elif bonus_bytes_raw is not None:
|
||||
try:
|
||||
bonus_bytes = int(bonus_bytes_raw)
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_bonus", "bonus_bytes must be an integer")
|
||||
else:
|
||||
try:
|
||||
bonus_bytes = int(round(float(bonus_gb_raw) * (1024 ** 3)))
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_bonus", "bonus_gb must be a number")
|
||||
|
||||
if bonus_bytes < 0:
|
||||
return _error(400, "invalid_bonus", "bonus must be non-negative")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if not active:
|
||||
return _error(404, "no_active_subscription")
|
||||
|
||||
active.premium_unlimited_override = bool(unlimited)
|
||||
active.premium_bonus_bytes = int(bonus_bytes)
|
||||
if active.premium_unlimited_override:
|
||||
active.premium_is_limited = False
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_premium_override_webapp",
|
||||
"content": (
|
||||
f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes)}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response:
|
||||
"""Main (regular) traffic: unlimited-style ceiling + admin bonus GB."""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
|
||||
unlimited = bool(payload.get("unlimited"))
|
||||
regular_bonus_bytes_raw = payload.get("regular_bonus_bytes")
|
||||
regular_bonus_gb_raw = payload.get("regular_bonus_gb")
|
||||
if regular_bonus_bytes_raw is None and regular_bonus_gb_raw is None:
|
||||
regular_bonus_bytes = 0
|
||||
elif regular_bonus_bytes_raw is not None:
|
||||
try:
|
||||
regular_bonus_bytes = int(regular_bonus_bytes_raw)
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_regular_bonus", "regular_bonus_bytes must be an integer")
|
||||
else:
|
||||
try:
|
||||
regular_bonus_bytes = int(round(float(regular_bonus_gb_raw) * (1024 ** 3)))
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_regular_bonus", "regular_bonus_gb must be a number")
|
||||
|
||||
if regular_bonus_bytes < 0:
|
||||
return _error(400, "invalid_regular_bonus", "regular bonus must be non-negative")
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if not active:
|
||||
return _error(404, "no_active_subscription")
|
||||
|
||||
active.regular_unlimited_override = bool(unlimited)
|
||||
active.regular_bonus_bytes = int(regular_bonus_bytes)
|
||||
|
||||
if subscription_service is not None:
|
||||
await subscription_service.sync_main_traffic_limit_to_panel(session, target_id)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_regular_traffic_override_webapp",
|
||||
"content": (
|
||||
f"unlimited={bool(unlimited)} regular_bonus_bytes={int(regular_bonus_bytes)}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
||||
"""Credit regular or premium traffic to a user without a payment.
|
||||
|
||||
Body: ``{"kind": "regular" | "premium", "gb": float}`` (alternatively
|
||||
``"bytes": int``). Mirrors the same effect as a user-purchased top-up:
|
||||
the chosen balance grows, panel limit/squads are refreshed, and an entry
|
||||
is added to ``traffic_topups`` with ``kind="admin_topup"`` or
|
||||
``kind="admin_premium_topup"`` and ``payment_id=NULL``.
|
||||
"""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
payload = await _read_json(request)
|
||||
|
||||
kind = str(payload.get("kind") or "regular").strip().lower()
|
||||
if kind not in {"regular", "premium"}:
|
||||
return _error(400, "invalid_kind", "kind must be 'regular' or 'premium'")
|
||||
|
||||
bytes_raw = payload.get("bytes")
|
||||
gb_raw = payload.get("gb")
|
||||
if bytes_raw is None and gb_raw is None:
|
||||
return _error(400, "missing_amount", "either 'gb' or 'bytes' is required")
|
||||
try:
|
||||
if bytes_raw is not None:
|
||||
grant_bytes = int(bytes_raw)
|
||||
gb_value = grant_bytes / (1024 ** 3)
|
||||
else:
|
||||
gb_value = float(gb_raw)
|
||||
grant_bytes = int(round(gb_value * (1024 ** 3)))
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_amount", "amount must be a positive number")
|
||||
if gb_value <= 0 or grant_bytes <= 0:
|
||||
return _error(400, "invalid_amount", "amount must be positive")
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if subscription_service is None:
|
||||
return _error(503, "subscription_service_unavailable")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if not active:
|
||||
return _error(404, "no_active_subscription")
|
||||
|
||||
if kind == "regular":
|
||||
result = await subscription_service.admin_grant_topup(
|
||||
session, target_id, gb_value
|
||||
)
|
||||
else:
|
||||
result = await subscription_service.admin_grant_premium_topup(
|
||||
session, target_id, gb_value
|
||||
)
|
||||
if not result:
|
||||
await session.rollback()
|
||||
return _error(
|
||||
422,
|
||||
"grant_failed",
|
||||
"Unable to credit traffic (missing tariff/squads or panel error)",
|
||||
)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_traffic_grant_webapp",
|
||||
"content": f"kind={kind} bytes={grant_bytes}",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
refreshed = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, target_id
|
||||
)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"subscription": _serialize_subscription(refreshed) if refreshed else None,
|
||||
"grant": {
|
||||
"kind": kind,
|
||||
"granted_bytes": grant_bytes,
|
||||
"granted_gb": gb_value,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
@@ -1003,14 +1240,22 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
if days <= 0:
|
||||
return _error(400, "invalid_days")
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if subscription_service is None:
|
||||
return _error(503, "subscription_service_unavailable")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if not active:
|
||||
return _error(404, "no_active_subscription")
|
||||
new_end = await subscription_service.extend_active_subscription_days(
|
||||
session,
|
||||
target_id,
|
||||
days,
|
||||
"admin_extend_subscription_webapp",
|
||||
)
|
||||
if not new_end:
|
||||
await session.rollback()
|
||||
return _error(500, "extend_failed")
|
||||
|
||||
new_end = (active.end_date or datetime.now(timezone.utc)) + timedelta(days=days)
|
||||
active.end_date = new_end
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
@@ -1022,8 +1267,18 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
refreshed = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, target_id
|
||||
)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"subscription": _serialize_subscription(refreshed)
|
||||
if refreshed
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ─── Payments ──────────────────────────────────────────────────────
|
||||
@@ -1589,6 +1844,18 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route)
|
||||
router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/premium-override",
|
||||
admin_user_premium_override_route,
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
|
||||
admin_user_regular_traffic_override_route,
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
|
||||
admin_user_traffic_grant_route,
|
||||
)
|
||||
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
|
||||
|
||||
router.add_get("/api/admin/payments", admin_payments_list_route)
|
||||
|
||||
@@ -454,6 +454,16 @@
|
||||
});
|
||||
}
|
||||
|
||||
function stripTopupQueryFromUrl() {
|
||||
if (typeof window === "undefined") return;
|
||||
const u = new URL(window.location.href);
|
||||
if (!u.searchParams.has("topup")) return;
|
||||
u.searchParams.delete("topup");
|
||||
const search = u.searchParams.toString();
|
||||
const qs = search ? `?${search}` : "";
|
||||
window.history.replaceState(null, "", `${u.pathname}${qs}${u.hash}`);
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const payload = await api("/me");
|
||||
if (!payload.ok) throw new Error(payload.error || "load_failed");
|
||||
@@ -482,6 +492,35 @@
|
||||
if (topupModalOpen) await billingStore.loadTopupOptions(topupKind);
|
||||
if (deviceTopupModalOpen) await billingStore.loadDeviceTopupOptions();
|
||||
if (changeModalOpen) await billingStore.loadTariffChangeOptions();
|
||||
|
||||
const topupDeep = new URLSearchParams(window.location.search).get("topup");
|
||||
if (topupDeep === "regular" || topupDeep === "premium") {
|
||||
const plansList = payload.plans?.length ? payload.plans : [];
|
||||
const tariffCatalogLocal = buildTariffCatalog(plansList);
|
||||
const sub = payload.subscription || {};
|
||||
const tariffModeLocal = plansList.some((plan) => plan?.tariff_key);
|
||||
const hasTariffSub = Boolean(
|
||||
tariffModeLocal &&
|
||||
sub?.active &&
|
||||
sub?.tariff_key &&
|
||||
tariffCatalogLocal.some((t) => t.key === sub.tariff_key),
|
||||
);
|
||||
const canRegular =
|
||||
hasTariffSub &&
|
||||
(sub?.can_topup_regular_traffic ?? sub?.can_topup_traffic) &&
|
||||
Number(sub?.traffic_limit_bytes || 0) > 0;
|
||||
const canPremium =
|
||||
hasTariffSub &&
|
||||
(sub?.can_topup_premium_traffic ?? sub?.can_topup_traffic) &&
|
||||
Number(sub?.premium_limit_bytes || 0) > 0;
|
||||
if (topupDeep === "regular" && canRegular) {
|
||||
billingStore.openTopupModal("regular", payload.payment_methods?.[0]?.id || "");
|
||||
stripTopupQueryFromUrl();
|
||||
} else if (topupDeep === "premium" && canPremium) {
|
||||
billingStore.openTopupModal("premium", payload.payment_methods?.[0]?.id || "");
|
||||
stripTopupQueryFromUrl();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
|
||||
@@ -219,6 +219,19 @@
|
||||
window.open("/api/admin/payments/export.csv", "_blank", "noopener");
|
||||
}
|
||||
|
||||
function openPaymentUserCard(userId) {
|
||||
const uid = Number(userId);
|
||||
if (!Number.isFinite(uid) || uid <= 0) return;
|
||||
const next = normalizeSection("users");
|
||||
sidebarOpen = false;
|
||||
if (active !== next) {
|
||||
active = next;
|
||||
usersStore.closeUser();
|
||||
onSectionChange(next);
|
||||
}
|
||||
usersStore.openUser(uid);
|
||||
}
|
||||
|
||||
function resolvedAvatarUrl(user) {
|
||||
return userAvatarUrl(user) || (!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : "");
|
||||
}
|
||||
@@ -470,7 +483,7 @@
|
||||
{/if}
|
||||
|
||||
{#if active === "payments"}
|
||||
<PaymentsSection {at} {fmtDate} {fmtMoney} {paymentStatusVariant} />
|
||||
<PaymentsSection {at} {fmtDate} {fmtMoney} {paymentStatusVariant} onOpenUserCard={openPaymentUserCard} />
|
||||
{/if}
|
||||
|
||||
{#if active === "promos"}
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
import { getContext, onMount } from "svelte";
|
||||
import {
|
||||
AdminBadge,
|
||||
AdminButton,
|
||||
AdminEmptyState,
|
||||
AdminPagination,
|
||||
AdminTable,
|
||||
AdminTableSkeleton,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
import { User } from "$components/ui/icons.js";
|
||||
|
||||
export let at = (key) => key;
|
||||
export let fmtDate = (value) => value;
|
||||
export let fmtMoney = (value) => value;
|
||||
export let paymentStatusVariant = () => "muted";
|
||||
export let onOpenUserCard = () => {};
|
||||
|
||||
const paymentsStore = getContext("paymentsStore");
|
||||
|
||||
@@ -23,9 +26,60 @@
|
||||
} = $paymentsStore);
|
||||
|
||||
$: paymentsHasMore = payments.length > 0 && paymentsTotal > (paymentsPage + 1) * 25; // 25 is PAYMENTS_PAGE_SIZE
|
||||
|
||||
/** @param {number|null|undefined} v */
|
||||
function formatTrafficGbCell(v) {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return "—";
|
||||
let s;
|
||||
if (Math.abs(n - Math.round(n)) < 1e-9) {
|
||||
s = String(Math.round(n));
|
||||
} else {
|
||||
s = String(Math.round(n * 100) / 100);
|
||||
}
|
||||
return `${s} GB`;
|
||||
}
|
||||
|
||||
/** @param {number|null|undefined} v */
|
||||
function formatGbAmountPlain(v) {
|
||||
if (v == null || v === "") return "";
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return "";
|
||||
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
|
||||
return String(Math.round(n * 100) / 100);
|
||||
}
|
||||
|
||||
/** @param {Record<string, unknown>} p */
|
||||
function paymentDescriptionDisplay(p) {
|
||||
const r = p.traffic_regular_gb;
|
||||
const pr = p.traffic_premium_gb;
|
||||
if (r != null && pr == null) {
|
||||
const gb = formatGbAmountPlain(r);
|
||||
return at(
|
||||
"payments_desc_traffic_package_regular",
|
||||
{ gb },
|
||||
`Пакет трафика ${gb} ГБ (обычный)`,
|
||||
);
|
||||
}
|
||||
if (pr != null && r == null) {
|
||||
const gb = formatGbAmountPlain(pr);
|
||||
return at(
|
||||
"payments_desc_traffic_package_premium",
|
||||
{ gb },
|
||||
`Пакет трафика ${gb} ГБ (премиум)`,
|
||||
);
|
||||
}
|
||||
const raw = p.description && String(p.description).trim();
|
||||
return raw || "—";
|
||||
}
|
||||
|
||||
$: paymentHeaders = [
|
||||
at("id", {}, "ID"),
|
||||
at("user", {}, "Пользователь"),
|
||||
at("payments_col_telegram_id", {}, "Telegram ID"),
|
||||
at("payments_col_traffic_regular", {}, "Основной трафик"),
|
||||
at("payments_col_traffic_premium", {}, "Премиум"),
|
||||
at("amount", {}, "Сумма"),
|
||||
at("provider", {}, "Провайдер"),
|
||||
at("description", {}, "Описание"),
|
||||
@@ -40,7 +94,11 @@
|
||||
|
||||
<div class="admin-table-wrap">
|
||||
{#if paymentsLoading}
|
||||
<AdminTableSkeleton headers={paymentHeaders} rows={8} widths={["48px", "120px", "78px", "82px", "180px", "72px", "96px"]} />
|
||||
<AdminTableSkeleton
|
||||
headers={paymentHeaders}
|
||||
rows={8}
|
||||
widths={["48px", "148px", "88px", "72px", "72px", "78px", "82px", "140px", "72px", "96px"]}
|
||||
/>
|
||||
{:else if !payments.length}
|
||||
<AdminEmptyState tone="card"><span class="admin-muted">{at("payments_empty", {}, "Нет платежей")}</span></AdminEmptyState>
|
||||
{:else}
|
||||
@@ -49,6 +107,9 @@
|
||||
<tr>
|
||||
<th>{at("id", {}, "ID")}</th>
|
||||
<th>{at("user", {}, "Пользователь")}</th>
|
||||
<th>{at("payments_col_telegram_id", {}, "Telegram ID")}</th>
|
||||
<th>{at("payments_col_traffic_regular", {}, "Основной трафик")}</th>
|
||||
<th>{at("payments_col_traffic_premium", {}, "Премиум")}</th>
|
||||
<th>{at("amount", {}, "Сумма")}</th>
|
||||
<th>{at("provider", {}, "Провайдер")}</th>
|
||||
<th>{at("description", {}, "Описание")}</th>
|
||||
@@ -60,10 +121,33 @@
|
||||
{#each payments as p}
|
||||
<tr>
|
||||
<td class="admin-cell-id" data-label="ID">#{p.payment_id}</td>
|
||||
<td data-label={at("user", {}, "Пользователь")}>{p.user_label || p.user_id}</td>
|
||||
<td class="admin-cell-user-with-action" data-label={at("user", {}, "Пользователь")}>
|
||||
<span class="admin-payments-user-cell">
|
||||
<AdminButton
|
||||
class="admin-payments-user-btn"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={at("payments_open_user", {}, "Открыть карточку пользователя")}
|
||||
aria-label={at("payments_open_user", {}, "Открыть карточку пользователя")}
|
||||
onclick={() => onOpenUserCard(p.user_id)}
|
||||
>
|
||||
<User size={14} />
|
||||
</AdminButton>
|
||||
<span class="admin-payments-user-name">{p.user_label || p.user_id}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="admin-cell-mono" data-label={at("payments_col_telegram_id", {}, "Telegram ID")}>
|
||||
{p.telegram_id != null ? p.telegram_id : "—"}
|
||||
</td>
|
||||
<td class="admin-cell-traffic-gb" data-label={at("payments_col_traffic_regular", {}, "Основной трафик")}>
|
||||
{formatTrafficGbCell(p.traffic_regular_gb)}
|
||||
</td>
|
||||
<td class="admin-cell-traffic-gb" data-label={at("payments_col_traffic_premium", {}, "Премиум")}>
|
||||
{formatTrafficGbCell(p.traffic_premium_gb)}
|
||||
</td>
|
||||
<td data-label={at("amount", {}, "Сумма")}>{fmtMoney(p.amount, p.currency)}</td>
|
||||
<td data-label={at("provider", {}, "Провайдер")}>{p.provider}</td>
|
||||
<td class="admin-cell-wrap" data-label={at("description", {}, "Описание")}>{p.description || "—"}</td>
|
||||
<td class="admin-cell-wrap" data-label={at("description", {}, "Описание")}>{paymentDescriptionDisplay(p)}</td>
|
||||
<td data-label={at("status", {}, "Статус")}>
|
||||
<AdminBadge variant={paymentStatusVariant(p.status)}>{p.status}</AdminBadge>
|
||||
</td>
|
||||
@@ -81,6 +165,47 @@
|
||||
nextLabel={at("next", {}, "Далее")}
|
||||
prevDisabled={paymentsPage === 0}
|
||||
nextDisabled={!paymentsHasMore}
|
||||
onPrev={() => { paymentsStore.setPage(Math.max(0, paymentsPage - 1)); }}
|
||||
onNext={() => { paymentsStore.setPage(paymentsPage + 1); }}
|
||||
onPrev={() => {
|
||||
paymentsStore.setPage(Math.max(0, paymentsPage - 1));
|
||||
}}
|
||||
onNext={() => {
|
||||
paymentsStore.setPage(paymentsPage + 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.admin-payments-user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-payments-user-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-cell-user-with-action :global(.admin-payments-user-btn.admin-btn) {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.admin-cell-user-with-action :global(.admin-payments-user-btn svg) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.admin-cell-traffic-gb {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { Label, Select, Separator, Tabs } from "$components/ui/primitives.js";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
import { AdminBadge, AdminButton, AdminTrafficCard } from "$components/patterns/admin/index.js";
|
||||
import { AdminBadge, AdminButton, AdminSectionHeader, AdminSelect, AdminTrafficCard } from "$components/patterns/admin/index.js";
|
||||
import {
|
||||
CalendarDays, Copy, CreditCard, ExternalLink, Eye, Info, Key,
|
||||
Mail, Map, MessageSquare, MousePointerClick, QrCode, RefreshCw, Send,
|
||||
@@ -41,6 +41,8 @@
|
||||
userBanConfirmOpen,
|
||||
userMessageConfirmOpen,
|
||||
userDetailTab,
|
||||
premiumUnlimitedDraft,
|
||||
premiumBonusGbDraft,
|
||||
} = $usersStore);
|
||||
|
||||
</script>
|
||||
@@ -177,7 +179,17 @@
|
||||
warning={openedUserDetail.active_subscription.is_throttled}
|
||||
label={at("aria_label_main_traffic", {}, "Использование основного трафика")}
|
||||
/>
|
||||
{#if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0}
|
||||
{#if openedUserDetail.active_subscription.premium_unlimited_override}
|
||||
<AdminTrafficCard
|
||||
premium
|
||||
title={at("user_label_premium_squads", {}, "Premium-сквады")}
|
||||
value={at("user_premium_unlimited_value", { used: trafficLeftLabel(0, openedUserDetail.active_subscription.premium_used_bytes) }, "∞ (использовано " + trafficLeftLabel(0, openedUserDetail.active_subscription.premium_used_bytes) + ")")}
|
||||
left={at("user_premium_unlimited_hint", {}, "Безлимит (админ-оверрайд)")}
|
||||
percent={0}
|
||||
warning={false}
|
||||
label={at("aria_label_premium_traffic", {}, "Использование premium-трафика")}
|
||||
/>
|
||||
{:else if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0}
|
||||
<AdminTrafficCard
|
||||
premium
|
||||
title={at("user_label_premium_squads", {}, "Premium-сквады")}
|
||||
@@ -249,6 +261,149 @@
|
||||
</Label.Root>
|
||||
</div>
|
||||
|
||||
{#if openedUserDetail?.active_subscription}
|
||||
<section class="admin-user-action-sheet admin-user-action-sheet--premium-override">
|
||||
<AdminSectionHeader
|
||||
title={at("user_premium_override_card_title", {}, "Премиум-трафик")}
|
||||
description={at("user_premium_override_card_hint", {}, "Безлимит и дополнительный объём для премиум-сквадов поверх тарифа.")}
|
||||
/>
|
||||
<div class="admin-user-action-sheet-body admin-user-override-stack">
|
||||
<Label.Root class="admin-field-label admin-extend-field">
|
||||
<span>{at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}</span>
|
||||
<small>{at("user_premium_override_bonus_hint", {}, "")}</small>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
disabled={premiumUnlimitedDraft}
|
||||
aria-label={at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}
|
||||
bind:value={$usersStore.premiumBonusGbDraft}
|
||||
/>
|
||||
</Label.Root>
|
||||
</div>
|
||||
<div class="admin-user-action-sheet-footer admin-override-card-footer">
|
||||
<div class="admin-override-card-toolbar">
|
||||
<label class="admin-override-unlimited-label">
|
||||
<input type="checkbox" bind:checked={$usersStore.premiumUnlimitedDraft} aria-label={at("user_override_unlimited_short", {}, "Безлимит")} />
|
||||
<span>{at("user_override_unlimited_short", {}, "Безлимит")}</span>
|
||||
</label>
|
||||
<AdminButton variant="primary" onclick={usersStore.savePremiumTrafficOverride} disabled={userActionBusy}>
|
||||
{at("user_premium_override_save", {}, "Сохранить")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
<div class="admin-override-status-lines">
|
||||
{#if openedUserDetail.active_subscription.premium_unlimited_override}
|
||||
<span class="admin-meta-truncate">
|
||||
{at("user_premium_override_status_unlimited", {}, "Сейчас: безлимит")}
|
||||
</span>
|
||||
{:else if Number(openedUserDetail.active_subscription.premium_bonus_bytes || 0) > 0}
|
||||
<span class="admin-meta-truncate">
|
||||
{at(
|
||||
"user_premium_override_status_bonus",
|
||||
{ gb: +(Number(openedUserDetail.active_subscription.premium_bonus_bytes) / (1024 ** 3)).toFixed(2) },
|
||||
`Премиум сейчас: +${+(Number(openedUserDetail.active_subscription.premium_bonus_bytes) / (1024 ** 3)).toFixed(2)} GB`,
|
||||
)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="admin-muted">{at("user_premium_override_status_none", {}, "Премиум-оверрайд не задан")}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-user-action-sheet admin-user-action-sheet--regular-override">
|
||||
<AdminSectionHeader
|
||||
title={at("user_regular_override_card_title", {}, "Основной трафик")}
|
||||
description={at("user_regular_override_card_hint", {}, "Безлимит и постоянный бонус к лимиту основного трафика.")}
|
||||
/>
|
||||
<div class="admin-user-action-sheet-body admin-user-override-stack">
|
||||
<Label.Root class="admin-field-label admin-extend-field">
|
||||
<span>{at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}</span>
|
||||
<small>{at("user_regular_override_bonus_hint", {}, "")}</small>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
disabled={$usersStore.regularUnlimitedDraft}
|
||||
aria-label={at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}
|
||||
bind:value={$usersStore.regularBonusGbDraft}
|
||||
/>
|
||||
</Label.Root>
|
||||
</div>
|
||||
<div class="admin-user-action-sheet-footer admin-override-card-footer">
|
||||
<div class="admin-override-card-toolbar">
|
||||
<label class="admin-override-unlimited-label">
|
||||
<input type="checkbox" bind:checked={$usersStore.regularUnlimitedDraft} aria-label={at("user_override_unlimited_short", {}, "Безлимит")} />
|
||||
<span>{at("user_override_unlimited_short", {}, "Безлимит")}</span>
|
||||
</label>
|
||||
<AdminButton variant="primary" onclick={usersStore.saveRegularTrafficOverride} disabled={userActionBusy}>
|
||||
{at("user_regular_override_save", {}, "Сохранить")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
<div class="admin-override-status-lines">
|
||||
{#if openedUserDetail.active_subscription.regular_unlimited_override}
|
||||
<span class="admin-meta-truncate">
|
||||
{at("user_regular_override_status_unlimited", {}, "Сейчас: безлимит")}
|
||||
</span>
|
||||
{:else if Number(openedUserDetail.active_subscription.regular_bonus_bytes || 0) > 0}
|
||||
<span class="admin-meta-truncate">
|
||||
{at(
|
||||
"user_regular_override_status_bonus",
|
||||
{ gb: +(Number(openedUserDetail.active_subscription.regular_bonus_bytes) / (1024 ** 3)).toFixed(2) },
|
||||
`Основной сейчас: +${+(Number(openedUserDetail.active_subscription.regular_bonus_bytes) / (1024 ** 3)).toFixed(2)} GB`,
|
||||
)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="admin-muted">{at("user_regular_override_status_none", {}, "Бонус основного трафика не задан")}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-user-action-sheet admin-user-action-sheet--traffic-grant">
|
||||
<AdminSectionHeader
|
||||
title={at("user_traffic_grant_title", {}, "Выдать трафик")}
|
||||
description={at("user_traffic_grant_hint", {}, "Зачисление ГБ на баланс пользователя — как при докупке, но без оплаты. Лимит и сквады в панели обновятся сразу.")}
|
||||
/>
|
||||
<div class="admin-user-action-sheet-body admin-user-grant-stack">
|
||||
<Label.Root class="admin-field-label admin-extend-field">
|
||||
<span>{at("user_traffic_grant_kind", {}, "Тип трафика")}</span>
|
||||
<AdminSelect
|
||||
class="admin-grant-kind-select"
|
||||
value={$usersStore.grantTrafficKindDraft}
|
||||
items={[
|
||||
{ value: "regular", label: at("user_traffic_grant_kind_regular", {}, "Обычный") },
|
||||
{ value: "premium", label: at("user_traffic_grant_kind_premium", {}, "Премиум") },
|
||||
]}
|
||||
onValueChange={(v) => usersStore.updateState({ grantTrafficKindDraft: v })}
|
||||
ariaLabel={at("user_traffic_grant_kind", {}, "Тип трафика")}
|
||||
/>
|
||||
</Label.Root>
|
||||
<Label.Root class="admin-field-label admin-extend-field">
|
||||
<span>{at("user_traffic_grant_gb", {}, "ГБ к выдаче")}</span>
|
||||
<div class="admin-extend-control">
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
aria-label={at("user_traffic_grant_gb", {}, "ГБ к выдаче")}
|
||||
bind:value={$usersStore.grantTrafficGbDraft}
|
||||
/>
|
||||
<AdminButton variant="primary" onclick={usersStore.grantTraffic} disabled={userActionBusy}>
|
||||
<Plus size={14} /> {at("user_traffic_grant_submit", {}, "Выдать")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</Label.Root>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("user_label_telegram_msg", {}, "Сообщение в Telegram")}</span>
|
||||
<small>{at("user_hint_telegram_msg", {}, "Поддерживается HTML-разметка Telegram")}</small>
|
||||
@@ -339,3 +494,106 @@
|
||||
</AdminButton>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.admin-user-action-sheet {
|
||||
border: 1px solid var(--admin-border-muted, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 12px;
|
||||
margin-bottom: 14px;
|
||||
overflow: hidden;
|
||||
background: var(--admin-surface-1, rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
.admin-user-action-sheet :global(.admin-dashboard-section-head) {
|
||||
padding: 12px 14px 10px;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid var(--admin-border-muted, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
.admin-user-action-sheet-body {
|
||||
padding: 12px 14px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-user-override-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.admin-user-grant-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.admin-user-action-sheet-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 4px 14px 12px;
|
||||
}
|
||||
.admin-override-card-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
.admin-override-card-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px 14px;
|
||||
width: 100%;
|
||||
}
|
||||
.admin-override-card-toolbar :global(.admin-btn) {
|
||||
flex: 0 0 auto;
|
||||
min-height: 36px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
.admin-override-unlimited-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--admin-text, inherit);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
min-height: 36px;
|
||||
}
|
||||
.admin-override-unlimited-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.admin-override-card-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.admin-override-card-toolbar :global(.admin-btn) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.admin-override-status-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
:global(.admin-user-dialog .admin-actions-tab) {
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.admin-user-action-sheet--regular-override {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.admin-user-action-sheet--traffic-grant {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.admin-user-action-sheet :global(.admin-grant-kind-select) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,6 +23,12 @@ export function createUsersStore({ api, onToast, at }) {
|
||||
userBanConfirmOpen: false,
|
||||
userMessageConfirmOpen: false,
|
||||
userDetailTab: "profile",
|
||||
premiumUnlimitedDraft: false,
|
||||
premiumBonusGbDraft: "",
|
||||
regularUnlimitedDraft: false,
|
||||
regularBonusGbDraft: "",
|
||||
grantTrafficGbDraft: "",
|
||||
grantTrafficKindDraft: "regular",
|
||||
});
|
||||
|
||||
let _activeRef = "stats"; // fallback if active isn't tracked
|
||||
@@ -86,10 +92,19 @@ export function createUsersStore({ api, onToast, at }) {
|
||||
try {
|
||||
const res = await api(`/admin/users/${userId}`);
|
||||
if (res?.ok) {
|
||||
const sub = res.active_subscription || null;
|
||||
const bonusBytes = Number(sub?.premium_bonus_bytes || 0);
|
||||
const regularBonusBytes = Number(sub?.regular_bonus_bytes || 0);
|
||||
state.update(s => ({
|
||||
...s,
|
||||
openedUserDetail: res,
|
||||
openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser
|
||||
openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser,
|
||||
premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override),
|
||||
premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / (1024 ** 3)).toFixed(2) : "",
|
||||
regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override),
|
||||
regularBonusGbDraft: regularBonusBytes > 0 ? +(regularBonusBytes / (1024 ** 3)).toFixed(2) : "",
|
||||
grantTrafficGbDraft: "",
|
||||
grantTrafficKindDraft: "regular",
|
||||
}));
|
||||
} else {
|
||||
onToast(res?.error || "load_failed");
|
||||
@@ -249,6 +264,103 @@ export function createUsersStore({ api, onToast, at }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function savePremiumTrafficOverride() {
|
||||
let s;
|
||||
state.update(st => { s = st; return st; });
|
||||
if (!s.openedUser) return;
|
||||
state.update(st => ({ ...st, userActionBusy: true }));
|
||||
try {
|
||||
const bonusGbRaw = s.premiumBonusGbDraft;
|
||||
const bonusGb = bonusGbRaw === "" || bonusGbRaw === null || bonusGbRaw === undefined
|
||||
? 0
|
||||
: Number(bonusGbRaw);
|
||||
if (Number.isNaN(bonusGb) || bonusGb < 0) {
|
||||
onToast(at("premium_override_invalid_bonus", {}, "Некорректное значение GB"));
|
||||
return;
|
||||
}
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}/premium-override`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
unlimited: Boolean(s.premiumUnlimitedDraft),
|
||||
bonus_gb: bonusGb,
|
||||
}),
|
||||
});
|
||||
if (res?.ok) {
|
||||
onToast(at("premium_override_saved", {}, "Премиум-оверрайд сохранён"));
|
||||
await openUser(s.openedUser, { skipPush: true });
|
||||
} else {
|
||||
onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
}
|
||||
} finally {
|
||||
state.update(st => ({ ...st, userActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRegularTrafficOverride() {
|
||||
let s;
|
||||
state.update(st => { s = st; return st; });
|
||||
if (!s.openedUser) return;
|
||||
state.update(st => ({ ...st, userActionBusy: true }));
|
||||
try {
|
||||
const regGbRaw = s.regularBonusGbDraft;
|
||||
const regularGb = regGbRaw === "" || regGbRaw === null || regGbRaw === undefined
|
||||
? 0
|
||||
: Number(regGbRaw);
|
||||
if (Number.isNaN(regularGb) || regularGb < 0) {
|
||||
onToast(at("regular_override_invalid_bonus", {}, "Некорректное значение GB для основного трафика"));
|
||||
return;
|
||||
}
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}/regular-traffic-override`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
unlimited: Boolean(s.regularUnlimitedDraft),
|
||||
regular_bonus_gb: regularGb,
|
||||
}),
|
||||
});
|
||||
if (res?.ok) {
|
||||
onToast(at("regular_override_saved", {}, "Оверрайд основного трафика сохранён"));
|
||||
await openUser(s.openedUser, { skipPush: true });
|
||||
} else {
|
||||
onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
}
|
||||
} finally {
|
||||
state.update(st => ({ ...st, userActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function grantTraffic() {
|
||||
let s;
|
||||
state.update(st => { s = st; return st; });
|
||||
if (!s.openedUser) return;
|
||||
const gbRaw = s.grantTrafficGbDraft;
|
||||
const gb = Number(gbRaw);
|
||||
if (!gbRaw || Number.isNaN(gb) || gb <= 0) {
|
||||
onToast(at("traffic_grant_invalid_gb", {}, "Введите положительное число GB"));
|
||||
return;
|
||||
}
|
||||
const kind = s.grantTrafficKindDraft === "premium" ? "premium" : "regular";
|
||||
state.update(st => ({ ...st, userActionBusy: true }));
|
||||
try {
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}/traffic-grant`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind, gb }),
|
||||
});
|
||||
if (res?.ok) {
|
||||
onToast(
|
||||
kind === "premium"
|
||||
? at("traffic_grant_premium_done", { gb }, `+${gb} ГБ премиум-трафика`)
|
||||
: at("traffic_grant_regular_done", { gb }, `+${gb} ГБ трафика`)
|
||||
);
|
||||
state.update(st => ({ ...st, grantTrafficGbDraft: "" }));
|
||||
await openUser(s.openedUser, { skipPush: true });
|
||||
} else {
|
||||
onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
}
|
||||
} finally {
|
||||
state.update(st => ({ ...st, userActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
let s;
|
||||
state.update(st => { s = st; return st; });
|
||||
@@ -291,5 +403,8 @@ export function createUsersStore({ api, onToast, at }) {
|
||||
extendUser,
|
||||
resetTrialUser,
|
||||
deleteUser,
|
||||
savePremiumTrafficOverride,
|
||||
saveRegularTrafficOverride,
|
||||
grantTraffic,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -267,6 +267,17 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.admin-extend-control .admin-btn.admin-btn-primary {
|
||||
background: var(--accent);
|
||||
color: #02110a;
|
||||
border-color: color-mix(in srgb, var(--accent) 70%, #000);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-extend-control .admin-btn.admin-btn-primary:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent) 90%, #fff);
|
||||
}
|
||||
|
||||
.admin-input-row .input,
|
||||
.admin-input-row .admin-btn {
|
||||
height: 36px;
|
||||
|
||||
@@ -100,7 +100,34 @@
|
||||
<span class="traffic-percent">{trafficPercent(subscription)}%</span>
|
||||
</div>
|
||||
</Card>
|
||||
{#if Number(subscription?.premium_limit_bytes || 0) > 0}
|
||||
{#if subscription?.premium_unlimited_override}
|
||||
<Card class="premium-traffic-card">
|
||||
<div class="traffic-top">
|
||||
<span>{premiumTitle(subscription)}</span>
|
||||
<strong>∞</strong>
|
||||
</div>
|
||||
<div class="traffic-meta premium-traffic-meta">
|
||||
{#if premiumServerLabels(subscription).length}
|
||||
<details class="premium-server-dropdown">
|
||||
<summary>
|
||||
<span>{t("wa_premium_unlimited", {}, "Безлимит на premium-сервера")}</span>
|
||||
<ChevronsUpDown size={13} />
|
||||
</summary>
|
||||
<div class="premium-server-list premium-server-list-dropdown">
|
||||
<small>{t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")}</small>
|
||||
<div>
|
||||
{#each premiumServerLabels(subscription).slice(0, 8) as label}
|
||||
<span>{label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{:else}
|
||||
<span>{t("wa_premium_unlimited", {}, "Безлимит на premium-сервера")}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
{:else if Number(subscription?.premium_limit_bytes || 0) > 0}
|
||||
<Card class={`${canOpenPremiumTopupModal ? "traffic-card-clickable " : ""}premium-traffic-card${subscription?.premium_is_limited ? " premium-traffic-card-limited" : ""}`}>
|
||||
{#if canOpenPremiumTopupModal}
|
||||
<button class="card-click-target" type="button" onclick={openPremiumTopupModal} aria-label={premiumTitle(subscription)}></button>
|
||||
|
||||
@@ -2113,10 +2113,13 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response:
|
||||
sale_mode="premium_topup",
|
||||
title_prefix=f"{tariff.premium_name(lang)} ",
|
||||
) if topup_kind in {"all", "premium"} and tariff.premium_squad_uuids else []
|
||||
premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0)
|
||||
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
|
||||
premium_limit_bytes = (
|
||||
int(sub.premium_baseline_bytes or 0)
|
||||
+ int(sub.premium_topup_balance_bytes or 0)
|
||||
+ int(getattr(sub, "premium_topup_used_bytes", 0) or 0)
|
||||
+ premium_bonus_bytes
|
||||
)
|
||||
premium_access = await request.app["subscription_service"].premium_access_for_tariff(tariff)
|
||||
return web.json_response(
|
||||
@@ -2127,7 +2130,7 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response:
|
||||
"topup_kind": topup_kind,
|
||||
"premium_title": tariff.premium_name(lang),
|
||||
"traffic_percent": _traffic_percent(sub.traffic_used_bytes, sub.traffic_limit_bytes),
|
||||
"premium_traffic_percent": _traffic_percent(
|
||||
"premium_traffic_percent": 0 if premium_unlimited_override else _traffic_percent(
|
||||
sub.premium_used_bytes,
|
||||
premium_limit_bytes,
|
||||
),
|
||||
@@ -2136,6 +2139,8 @@ async def tariff_topup_options_route(request: web.Request) -> web.Response:
|
||||
"premium_baseline_bytes": int(sub.premium_baseline_bytes or 0),
|
||||
"premium_topup_balance_bytes": int(sub.premium_topup_balance_bytes or 0),
|
||||
"premium_topup_used_bytes": int(getattr(sub, "premium_topup_used_bytes", 0) or 0),
|
||||
"premium_bonus_bytes": premium_bonus_bytes,
|
||||
"premium_unlimited_override": premium_unlimited_override,
|
||||
"premium_is_limited": bool(sub.premium_is_limited),
|
||||
"premium_squad_labels": premium_access.get("squad_labels") or [],
|
||||
"premium_node_labels": premium_access.get("node_labels") or [],
|
||||
@@ -3278,6 +3283,10 @@ def _serialize_subscription(
|
||||
"premium_baseline_bytes": _coerce_int_or_none(active.get("premium_baseline_bytes")),
|
||||
"premium_topup_balance_bytes": _coerce_int_or_none(active.get("premium_topup_balance_bytes")),
|
||||
"premium_topup_used_bytes": _coerce_int_or_none(active.get("premium_topup_used_bytes")),
|
||||
"premium_bonus_bytes": _coerce_int_or_none(active.get("premium_bonus_bytes")) or 0,
|
||||
"regular_bonus_bytes": _coerce_int_or_none(active.get("regular_bonus_bytes")) or 0,
|
||||
"regular_unlimited_override": bool(active.get("regular_unlimited_override")),
|
||||
"premium_unlimited_override": bool(active.get("premium_unlimited_override")),
|
||||
"premium_is_limited": bool(active.get("premium_is_limited")),
|
||||
"premium_squad_labels": list(active.get("premium_squad_labels") or []),
|
||||
"premium_node_labels": list(active.get("premium_node_labels") or []),
|
||||
|
||||
Reference in New Issue
Block a user