feat: add admin payment detail view
This commit is contained in:
@@ -32,6 +32,35 @@ async def admin_payments_list_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def admin_payment_detail_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
try:
|
||||
payment_id = int(request.match_info["payment_id"])
|
||||
except (TypeError, ValueError):
|
||||
return _error(400, "invalid_payment", "Invalid payment id")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_id)
|
||||
if not payment:
|
||||
return _error(404, "not_found", "Payment not found")
|
||||
|
||||
payload = _serialize_payment(payment)
|
||||
payload.update(
|
||||
{
|
||||
"yookassa_payment_id": payment.yookassa_payment_id,
|
||||
"idempotence_key": payment.idempotence_key,
|
||||
"promo_code": (
|
||||
payment.promo_code_used.code if payment.promo_code_used is not None else None
|
||||
),
|
||||
"updated_at": payment.updated_at.isoformat() if payment.updated_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
return _ok({"payment": payload})
|
||||
|
||||
|
||||
async def admin_payments_export_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
@@ -36,6 +36,7 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route)
|
||||
|
||||
router.add_get("/api/admin/payments", admin_payments_list_route)
|
||||
router.add_get("/api/admin/payments/{payment_id:\\d+}", admin_payment_detail_route)
|
||||
router.add_get("/api/admin/payments/export.csv", admin_payments_export_route)
|
||||
|
||||
router.add_get("/api/admin/promos", admin_promos_list_route)
|
||||
|
||||
@@ -23,6 +23,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
index_route,
|
||||
)
|
||||
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
|
||||
app.router.add_get("/admin/payments/{payment_id:\\d+}", index_route)
|
||||
app.router.add_get("/admin/support/{ticket_id:\\d+}", index_route)
|
||||
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
|
||||
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
import { mockApi as runMockApi } from "./lib/webapp/mockApi.js";
|
||||
import { DEV_MOCK, applyPreviewMock } from "./lib/webapp/previewMock.js";
|
||||
import {
|
||||
adminPaymentIdFromPath,
|
||||
adminSectionFromPath,
|
||||
adminUserIdFromPath,
|
||||
normalizeSection,
|
||||
@@ -788,6 +789,7 @@
|
||||
onClose: closeAdminPanel,
|
||||
onToast: (text) => showToast(text),
|
||||
initialSection: adminSectionFromPath(window.location.pathname),
|
||||
initialPaymentId: adminPaymentIdFromPath(window.location.pathname),
|
||||
initialUserId: adminUserIdFromPath(window.location.pathname),
|
||||
onSectionChange: handleAdminSectionChange,
|
||||
onSettingsSaved: handleAdminPersistedSaved,
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import AdsSection from "./sections/AdsSection.svelte";
|
||||
import BroadcastSection from "./sections/BroadcastSection.svelte";
|
||||
import LogsSection from "./sections/LogsSection.svelte";
|
||||
import PaymentDetailModal from "./sections/PaymentDetailModal.svelte";
|
||||
import PaymentsSection from "./sections/PaymentsSection.svelte";
|
||||
import PromosSection from "./sections/PromosSection.svelte";
|
||||
import SettingsSection from "./sections/SettingsSection.svelte";
|
||||
@@ -75,6 +76,7 @@
|
||||
export let onClose = () => {};
|
||||
export let onToast = () => {};
|
||||
export let initialSection = "stats";
|
||||
export let initialPaymentId = null;
|
||||
export let initialUserId = null;
|
||||
export let onSectionChange = () => {};
|
||||
export let onSettingsSaved = () => {};
|
||||
@@ -213,7 +215,7 @@
|
||||
const adsStore = createAdsStore({ api, onToast: flash, at });
|
||||
const broadcastStore = createBroadcastStore({ api, onToast: flash, at });
|
||||
const logsStore = createLogsStore({ api, at });
|
||||
const paymentsStore = createPaymentsStore({ api, at });
|
||||
const paymentsStore = createPaymentsStore({ api, onToast: flash, at });
|
||||
const promosStore = createPromosStore({ api, onToast: flash, at });
|
||||
const settingsStore = createSettingsStore({ api, onToast: flash, at });
|
||||
const statsStore = createStatsStore({ api, onToast: flash, at });
|
||||
@@ -235,6 +237,7 @@
|
||||
setContext("themesStore", themesStore);
|
||||
|
||||
$: usersStore.setActive(active);
|
||||
$: paymentsStore.setActive(active);
|
||||
$: supportStore.setActive(active);
|
||||
$: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length;
|
||||
$: syncBusy = $statsStore.syncBusy;
|
||||
@@ -251,13 +254,14 @@
|
||||
if (active === next) return;
|
||||
active = next;
|
||||
usersStore.closeUser();
|
||||
paymentsStore.closePayment();
|
||||
supportStore.closeTicketView();
|
||||
onSectionChange(next);
|
||||
}
|
||||
|
||||
function readSectionFromPath() {
|
||||
if (typeof window === "undefined") return "stats";
|
||||
const match = window.location.pathname.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/i);
|
||||
const match = window.location.pathname.match(/^\/admin\/([a-z0-9_-]+)(?:\/.*)?$/i);
|
||||
return normalizeSection(match ? match[1].toLowerCase() : "stats");
|
||||
}
|
||||
|
||||
@@ -273,6 +277,12 @@
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
function readPaymentIdFromPath() {
|
||||
if (typeof window === "undefined") return null;
|
||||
const match = window.location.pathname.match(/^\/admin\/payments\/(\d+)$/);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
function onPopState() {
|
||||
active = readSectionFromPath();
|
||||
sidebarOpen = false;
|
||||
@@ -284,6 +294,14 @@
|
||||
} else if ($usersStore.openedUser) {
|
||||
usersStore.closeUser({ skipPush: true });
|
||||
}
|
||||
const paymentId = readPaymentIdFromPath();
|
||||
if (active === "payments" && paymentId) {
|
||||
if (!$paymentsStore.openedPaymentId || $paymentsStore.openedPaymentId !== paymentId) {
|
||||
paymentsStore.openPayment(paymentId, { skipPush: true });
|
||||
}
|
||||
} else if ($paymentsStore.openedPaymentId) {
|
||||
paymentsStore.closePayment({ skipPush: true });
|
||||
}
|
||||
const ticketId = readSupportTicketIdFromPath();
|
||||
if (active === "support" && ticketId) {
|
||||
if (!$supportStore.openedTicketId || $supportStore.openedTicketId !== ticketId) {
|
||||
@@ -433,6 +451,15 @@
|
||||
) {
|
||||
usersStore.openUser(initialUserId, { skipPush: true });
|
||||
}
|
||||
|
||||
$: if (
|
||||
active === "payments" &&
|
||||
initialPaymentId &&
|
||||
(!$paymentsStore.openedPaymentId || $paymentsStore.openedPaymentId !== initialPaymentId)
|
||||
) {
|
||||
paymentsStore.openPayment(initialPaymentId, { skipPush: true });
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="admin-screen-wrap" class:is-sidebar-open={sidebarOpen}>
|
||||
@@ -711,6 +738,14 @@
|
||||
|
||||
<TariffEditorModal {at} />
|
||||
|
||||
<PaymentDetailModal
|
||||
{at}
|
||||
{fmtDate}
|
||||
{fmtMoney}
|
||||
{paymentStatusVariant}
|
||||
onOpenUserCard={openPaymentUserCard}
|
||||
/>
|
||||
|
||||
<UserDetailModal
|
||||
{at}
|
||||
{fmtDate}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
<script>
|
||||
import { getContext } from "svelte";
|
||||
import {
|
||||
CalendarDays,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Database,
|
||||
Tag,
|
||||
User,
|
||||
WalletCards,
|
||||
} from "$components/ui/icons.js";
|
||||
import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
|
||||
export let at = (key, _params = {}, fallback = "") => fallback || key;
|
||||
export let fmtDate = (value) => value;
|
||||
export let fmtMoney = (amount, currency) => `${amount} ${currency || ""}`.trim();
|
||||
export let paymentStatusVariant = () => "muted";
|
||||
export let onOpenUserCard = () => {};
|
||||
|
||||
const paymentsStore = getContext("paymentsStore");
|
||||
|
||||
$: ({ openedPaymentId, openedPayment, paymentDetailLoading } = $paymentsStore);
|
||||
$: payment = openedPayment || (openedPaymentId ? { payment_id: openedPaymentId } : null);
|
||||
$: title = payment
|
||||
? at("payment_detail_title", { id: payment.payment_id }, `Платёж #${payment.payment_id}`)
|
||||
: "";
|
||||
$: description = payment
|
||||
? [
|
||||
payment.provider,
|
||||
payment.created_at ? fmtDate(payment.created_at) : "",
|
||||
payment.user_label || payment.user_id,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "";
|
||||
|
||||
function present(value) {
|
||||
return value !== null && value !== undefined && value !== "";
|
||||
}
|
||||
|
||||
function display(value) {
|
||||
return present(value) ? String(value) : "—";
|
||||
}
|
||||
|
||||
function money(value, currency) {
|
||||
return present(value) ? fmtMoney(value, currency) : "—";
|
||||
}
|
||||
|
||||
function formatGb(value) {
|
||||
if (!present(value)) return "—";
|
||||
const n = Number(value);
|
||||
if (Number.isNaN(n)) return display(value);
|
||||
const rounded = Math.abs(n - Math.round(n)) < 1e-9 ? Math.round(n) : Math.round(n * 100) / 100;
|
||||
return `${rounded} GB`;
|
||||
}
|
||||
|
||||
function formatTrafficSplit(p) {
|
||||
const parts = [];
|
||||
if (present(p?.traffic_regular_gb)) {
|
||||
parts.push(
|
||||
at(
|
||||
"payment_detail_regular_traffic",
|
||||
{ gb: formatGb(p.traffic_regular_gb) },
|
||||
`Основной: ${formatGb(p.traffic_regular_gb)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (present(p?.traffic_premium_gb)) {
|
||||
parts.push(
|
||||
at(
|
||||
"payment_detail_premium_traffic",
|
||||
{ gb: formatGb(p.traffic_premium_gb) },
|
||||
`Премиум: ${formatGb(p.traffic_premium_gb)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
return parts.join(" · ") || "—";
|
||||
}
|
||||
|
||||
function paymentDescription(p) {
|
||||
const raw = p?.description && String(p.description).trim();
|
||||
if (raw) return raw;
|
||||
return formatTrafficSplit(p);
|
||||
}
|
||||
|
||||
function copy(value) {
|
||||
paymentsStore.copyToClipboard(value, at("payment_detail_copied", {}, "Скопировано"));
|
||||
}
|
||||
|
||||
function openUser() {
|
||||
if (!payment?.user_id) return;
|
||||
paymentsStore.closePayment({ skipPush: true });
|
||||
onOpenUserCard(payment.user_id);
|
||||
}
|
||||
|
||||
$: paymentRows = [
|
||||
{
|
||||
label: "ID",
|
||||
value: payment?.payment_id ? `#${payment.payment_id}` : "",
|
||||
copy: payment?.payment_id,
|
||||
},
|
||||
{
|
||||
label: at("amount", {}, "Сумма"),
|
||||
value: money(payment?.amount, payment?.currency),
|
||||
},
|
||||
{ label: at("status", {}, "Статус"), value: payment?.status },
|
||||
{
|
||||
label: at("date", {}, "Дата"),
|
||||
value: payment?.created_at ? fmtDate(payment.created_at) : "",
|
||||
},
|
||||
{
|
||||
label: at("payment_detail_updated_at", {}, "Обновлён"),
|
||||
value: payment?.updated_at ? fmtDate(payment.updated_at) : "",
|
||||
},
|
||||
{ label: at("description", {}, "Описание"), value: paymentDescription(payment) },
|
||||
];
|
||||
|
||||
$: providerRows = [
|
||||
{ label: at("provider", {}, "Провайдер"), value: payment?.provider },
|
||||
{
|
||||
label: at("payment_detail_provider_payment_id", {}, "ID у провайдера"),
|
||||
value: payment?.provider_payment_id,
|
||||
copy: payment?.provider_payment_id,
|
||||
},
|
||||
{
|
||||
label: "YooKassa ID",
|
||||
value: payment?.yookassa_payment_id,
|
||||
copy: payment?.yookassa_payment_id,
|
||||
},
|
||||
{
|
||||
label: at("payment_detail_idempotence_key", {}, "Ключ идемпотентности"),
|
||||
value: payment?.idempotence_key,
|
||||
copy: payment?.idempotence_key,
|
||||
},
|
||||
];
|
||||
|
||||
$: purchaseRows = [
|
||||
{ label: at("payment_detail_sale_mode", {}, "Тип продажи"), value: payment?.sale_mode },
|
||||
{ label: at("payment_detail_tariff_key", {}, "Тариф"), value: payment?.tariff_key },
|
||||
{
|
||||
label: at("payment_detail_duration_months", {}, "Период"),
|
||||
value: present(payment?.subscription_duration_months)
|
||||
? at(
|
||||
"payment_detail_months_count",
|
||||
{ count: payment.subscription_duration_months },
|
||||
`${payment.subscription_duration_months} мес.`
|
||||
)
|
||||
: "",
|
||||
},
|
||||
{
|
||||
label: at("payment_detail_traffic", {}, "Трафик"),
|
||||
value: formatTrafficSplit(payment),
|
||||
},
|
||||
{
|
||||
label: at("payment_detail_purchased_gb", {}, "Куплено GB"),
|
||||
value: present(payment?.purchased_gb) ? formatGb(payment.purchased_gb) : "",
|
||||
},
|
||||
{
|
||||
label: at("payment_detail_hwid_devices", {}, "HWID-устройства"),
|
||||
value: payment?.purchased_hwid_devices,
|
||||
},
|
||||
{ label: at("payment_detail_promo_code", {}, "Промокод"), value: payment?.promo_code },
|
||||
];
|
||||
|
||||
$: userRows = [
|
||||
{ label: at("user", {}, "Пользователь"), value: payment?.user_label },
|
||||
{ label: "User ID", value: payment?.user_id, copy: payment?.user_id },
|
||||
{ label: "Telegram ID", value: payment?.telegram_id, copy: payment?.telegram_id },
|
||||
];
|
||||
</script>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(openedPaymentId)}
|
||||
{title}
|
||||
{description}
|
||||
closeLabel={at("close", {}, "Закрыть")}
|
||||
onclose={paymentsStore.closePayment}
|
||||
class="admin-dialog admin-payment-dialog"
|
||||
>
|
||||
{#if payment}
|
||||
<div class="admin-payment-dialog-body">
|
||||
<aside class="admin-payment-aside">
|
||||
<div class="admin-payment-summary">
|
||||
<span class="admin-payment-icon" aria-hidden="true">
|
||||
<WalletCards size={24} />
|
||||
</span>
|
||||
<div class="admin-payment-summary-meta">
|
||||
<strong>{money(payment.amount, payment.currency)}</strong>
|
||||
<small>{paymentDescription(payment)}</small>
|
||||
<div class="admin-payment-summary-tags">
|
||||
<AdminBadge variant={paymentStatusVariant(payment.status)}
|
||||
>{display(payment.status)}</AdminBadge
|
||||
>
|
||||
{#if payment.provider}
|
||||
<AdminBadge variant="muted">{payment.provider}</AdminBadge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-payment-stats">
|
||||
<div class="admin-payment-stat">
|
||||
<CreditCard size={15} />
|
||||
<span>{at("payment_detail_provider", {}, "Провайдер")}</span>
|
||||
<strong>{display(payment.provider)}</strong>
|
||||
</div>
|
||||
<div class="admin-payment-stat">
|
||||
<CalendarDays size={15} />
|
||||
<span>{at("date", {}, "Дата")}</span>
|
||||
<strong>{payment.created_at ? fmtDate(payment.created_at) : "—"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-subsection-title">
|
||||
{at("payment_detail_user_section", {}, "Пользователь")}
|
||||
</div>
|
||||
<ul class="admin-meta-list admin-payment-meta-list">
|
||||
{#each userRows as row}
|
||||
<li>
|
||||
<span>{row.label}</span>
|
||||
<strong class:admin-meta-truncate={row.copy}>{display(row.value)}</strong>
|
||||
{#if row.copy}
|
||||
<AdminButton
|
||||
size="icon"
|
||||
variant="icon"
|
||||
title={at("user_copy_tooltip", {}, "Скопировать")}
|
||||
onclick={() => copy(row.copy)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</AdminButton>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<AdminButton variant="ghost" onclick={openUser} disabled={!payment.user_id}>
|
||||
<User size={14} />
|
||||
{at("payments_open_user", {}, "Открыть карточку пользователя")}
|
||||
</AdminButton>
|
||||
</aside>
|
||||
|
||||
<main class="admin-payment-main">
|
||||
{#if paymentDetailLoading && !openedPayment}
|
||||
<p class="admin-muted">{at("loading", {}, "Загрузка...")}</p>
|
||||
{:else}
|
||||
<section class="admin-payment-panel">
|
||||
<div class="admin-payment-panel-head">
|
||||
<CreditCard size={16} />
|
||||
<h3>{at("payment_detail_payment_section", {}, "Платёж")}</h3>
|
||||
</div>
|
||||
<ul class="admin-meta-list admin-payment-meta-list">
|
||||
{#each paymentRows as row}
|
||||
<li>
|
||||
<span>{row.label}</span>
|
||||
<strong class:admin-meta-truncate={row.copy}>{display(row.value)}</strong>
|
||||
{#if row.copy}
|
||||
<AdminButton
|
||||
size="icon"
|
||||
variant="icon"
|
||||
title={at("user_copy_tooltip", {}, "Скопировать")}
|
||||
onclick={() => copy(row.copy)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</AdminButton>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="admin-payment-panel">
|
||||
<div class="admin-payment-panel-head">
|
||||
<Database size={16} />
|
||||
<h3>{at("payment_detail_provider_section", {}, "Провайдер")}</h3>
|
||||
</div>
|
||||
<ul class="admin-meta-list admin-payment-meta-list">
|
||||
{#each providerRows as row}
|
||||
<li>
|
||||
<span>{row.label}</span>
|
||||
<strong class:admin-meta-truncate={row.copy}>{display(row.value)}</strong>
|
||||
{#if row.copy}
|
||||
<AdminButton
|
||||
size="icon"
|
||||
variant="icon"
|
||||
title={at("user_copy_tooltip", {}, "Скопировать")}
|
||||
onclick={() => copy(row.copy)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</AdminButton>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="admin-payment-panel">
|
||||
<div class="admin-payment-panel-head">
|
||||
<Tag size={16} />
|
||||
<h3>{at("payment_detail_purchase_section", {}, "Покупка")}</h3>
|
||||
</div>
|
||||
<ul class="admin-meta-list admin-payment-meta-list">
|
||||
{#each purchaseRows as row}
|
||||
<li>
|
||||
<span>{row.label}</span>
|
||||
<strong>{display(row.value)}</strong>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog>
|
||||
@@ -8,7 +8,7 @@
|
||||
AdminTable,
|
||||
AdminTableSkeleton,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
import { User } from "$components/ui/icons.js";
|
||||
import { FileText, User } from "$components/ui/icons.js";
|
||||
|
||||
export let at = (key) => key;
|
||||
export let fmtDate = (value) => value;
|
||||
@@ -117,7 +117,19 @@
|
||||
<tbody>
|
||||
{#each payments as p}
|
||||
<tr>
|
||||
<td class="admin-cell-id" data-label="ID">#{p.payment_id}</td>
|
||||
<td class="admin-cell-id" data-label="ID">
|
||||
<AdminButton
|
||||
class="admin-payment-id-btn"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={at("payment_detail_open", {}, "Открыть платеж")}
|
||||
aria-label={at("payment_detail_open", {}, "Открыть платеж")}
|
||||
onclick={() => paymentsStore.openPayment(p)}
|
||||
>
|
||||
<FileText size={14} />
|
||||
#{p.payment_id}
|
||||
</AdminButton>
|
||||
</td>
|
||||
<td class="admin-cell-user-with-action" data-label={at("user", {}, "Пользователь")}>
|
||||
<span class="admin-payments-user-cell">
|
||||
<AdminButton
|
||||
@@ -213,4 +225,15 @@
|
||||
white-space: nowrap;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.admin-cell-id :global(.admin-payment-id-btn.admin-btn) {
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
gap: 6px;
|
||||
border-radius: 7px;
|
||||
color: var(--admin-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createPaymentsStore({ api }) {
|
||||
export function createPaymentsStore({
|
||||
api,
|
||||
onToast = () => {},
|
||||
at = (key, _params, fallback) => fallback || key,
|
||||
}) {
|
||||
const state = writable({
|
||||
payments: [],
|
||||
paymentsTotal: 0,
|
||||
paymentsPage: 0,
|
||||
paymentsLoading: false,
|
||||
openedPaymentId: null,
|
||||
openedPayment: null,
|
||||
paymentDetailLoading: false,
|
||||
});
|
||||
|
||||
const PAYMENTS_PAGE_SIZE = 25;
|
||||
let active = "stats";
|
||||
|
||||
function setActive(section) {
|
||||
active = section;
|
||||
}
|
||||
|
||||
function pushPaymentPath(paymentId) {
|
||||
if (typeof window === "undefined" || window.location.protocol === "file:") return;
|
||||
if (active !== "payments") return;
|
||||
const target = paymentId ? `/admin/payments/${paymentId}` : "/admin/payments";
|
||||
if (window.location.pathname === target) return;
|
||||
window.history.pushState(null, "", `${target}${window.location.search}${window.location.hash}`);
|
||||
}
|
||||
|
||||
async function loadPayments() {
|
||||
state.update((s) => ({ ...s, paymentsLoading: true }));
|
||||
@@ -37,11 +57,80 @@ export function createPaymentsStore({ api }) {
|
||||
loadPayments();
|
||||
}
|
||||
|
||||
async function openPayment(paymentOrId, opts = {}) {
|
||||
const paymentId =
|
||||
typeof paymentOrId === "object" && paymentOrId !== null
|
||||
? Number(paymentOrId.payment_id)
|
||||
: Number(paymentOrId);
|
||||
if (!Number.isFinite(paymentId) || paymentId <= 0) return;
|
||||
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedPaymentId: paymentId,
|
||||
openedPayment:
|
||||
typeof paymentOrId === "object" && paymentOrId !== null
|
||||
? { ...paymentOrId }
|
||||
: s.openedPayment?.payment_id === paymentId
|
||||
? s.openedPayment
|
||||
: null,
|
||||
paymentDetailLoading: true,
|
||||
}));
|
||||
if (!opts.skipPush) pushPaymentPath(paymentId);
|
||||
|
||||
try {
|
||||
const res = await api(`/admin/payments/${paymentId}`);
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedPayment: res.payment || s.openedPayment,
|
||||
}));
|
||||
} else {
|
||||
onToast(
|
||||
res?.message || res?.error || at("payment_load_failed", {}, "Не удалось загрузить платеж")
|
||||
);
|
||||
state.update((s) => ({ ...s, openedPaymentId: null, openedPayment: null }));
|
||||
if (!opts.skipPush) pushPaymentPath(null);
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, paymentDetailLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function closePayment(opts = {}) {
|
||||
let wasOpen = false;
|
||||
state.update((s) => {
|
||||
wasOpen = Boolean(s.openedPaymentId);
|
||||
return {
|
||||
...s,
|
||||
openedPaymentId: null,
|
||||
openedPayment: null,
|
||||
paymentDetailLoading: false,
|
||||
};
|
||||
});
|
||||
if (wasOpen && !opts.skipPush) pushPaymentPath(null);
|
||||
}
|
||||
|
||||
function copyToClipboard(text, successMessage = at("copied", {}, "Скопировано")) {
|
||||
if (!text) return;
|
||||
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(String(text)).then(
|
||||
() => onToast(successMessage),
|
||||
() => onToast(String(text))
|
||||
);
|
||||
} else {
|
||||
onToast(String(text));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
setActive,
|
||||
loadPayments,
|
||||
setPage,
|
||||
openPayment,
|
||||
closePayment,
|
||||
copyToClipboard,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,6 +108,53 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
user: adminUsers[0],
|
||||
},
|
||||
];
|
||||
const adminPayments = [
|
||||
{
|
||||
payment_id: 12,
|
||||
user_id: 100200300,
|
||||
user_label: "anna_ops",
|
||||
telegram_id: 100200300,
|
||||
traffic_regular_gb: null,
|
||||
traffic_premium_gb: null,
|
||||
provider: "yookassa",
|
||||
provider_payment_id: "2f3a7c9e-yk-preview",
|
||||
yookassa_payment_id: "2f3a7c9e-yk-preview",
|
||||
idempotence_key: "admin-preview-payment-12",
|
||||
amount: 790,
|
||||
currency: "RUB",
|
||||
status: "succeeded",
|
||||
description: "Standard · 1 месяц",
|
||||
subscription_duration_months: 1,
|
||||
sale_mode: "subscription",
|
||||
tariff_key: "standard",
|
||||
purchased_gb: null,
|
||||
purchased_hwid_devices: null,
|
||||
promo_code: "SPRING",
|
||||
created_at: "2026-05-01T14:15:00Z",
|
||||
updated_at: "2026-05-01T14:17:00Z",
|
||||
},
|
||||
{
|
||||
payment_id: 13,
|
||||
user_id: 100200301,
|
||||
user_label: "client_pro",
|
||||
telegram_id: 87543123,
|
||||
traffic_regular_gb: 25,
|
||||
traffic_premium_gb: null,
|
||||
provider: "platega",
|
||||
provider_payment_id: "platega-demo-13",
|
||||
amount: 199,
|
||||
currency: "RUB",
|
||||
status: "pending_platega",
|
||||
description: "",
|
||||
subscription_duration_months: null,
|
||||
sale_mode: "traffic_package",
|
||||
tariff_key: "standard",
|
||||
purchased_gb: 25,
|
||||
purchased_hwid_devices: null,
|
||||
created_at: new Date(Date.now() - 3 * 3600000).toISOString(),
|
||||
updated_at: null,
|
||||
},
|
||||
];
|
||||
function supportCounts(items = supportTickets) {
|
||||
const byStatus = { open: 0, awaiting_admin: 0, awaiting_user: 0, resolved: 0 };
|
||||
for (const item of items) {
|
||||
@@ -242,20 +289,24 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
users_processed: 172,
|
||||
subscriptions_synced: 168,
|
||||
},
|
||||
recent_payments: [
|
||||
{
|
||||
payment_id: 1,
|
||||
user_id: 100200300,
|
||||
user_label: "anna_ops",
|
||||
amount: 790,
|
||||
currency: "RUB",
|
||||
provider: "yookassa",
|
||||
status: "succeeded",
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
recent_payments: adminPayments.slice(0, 1),
|
||||
};
|
||||
}
|
||||
if (cleanPath === "/admin/payments") {
|
||||
return {
|
||||
ok: true,
|
||||
payments: clone(adminPayments),
|
||||
total: adminPayments.length,
|
||||
page: 0,
|
||||
page_size: 25,
|
||||
};
|
||||
}
|
||||
if (cleanPath.startsWith("/admin/payments/")) {
|
||||
const id = Number(cleanPath.split("/")[3]);
|
||||
if (!Number.isFinite(id)) return { ok: false, error: "not_found" };
|
||||
const payment = adminPayments.find((item) => item.payment_id === id) || adminPayments[0];
|
||||
return { ok: true, payment: clone(payment) };
|
||||
}
|
||||
if (cleanPath === "/admin/users")
|
||||
return { ok: true, users: adminUsers, total: adminUsers.length, page: 0, page_size: 25 };
|
||||
if (cleanPath.startsWith("/admin/users/")) {
|
||||
|
||||
@@ -41,7 +41,7 @@ export function adminSectionFromPath(pathname) {
|
||||
const normalized = String(pathname || "")
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, "");
|
||||
const m = normalized.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/);
|
||||
const m = normalized.match(/^\/admin\/([a-z0-9_-]+)(?:\/.*)?$/);
|
||||
if (m && ADMIN_SECTIONS.has(m[1])) return m[1];
|
||||
return "stats";
|
||||
}
|
||||
@@ -54,6 +54,14 @@ export function adminUserIdFromPath(pathname) {
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
export function adminPaymentIdFromPath(pathname) {
|
||||
const normalized = String(pathname || "")
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, "");
|
||||
const m = normalized.match(/^\/admin\/payments\/(\d+)$/);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
export function supportTicketIdFromPath(pathname) {
|
||||
const normalized = String(pathname || "")
|
||||
.toLowerCase()
|
||||
@@ -80,8 +88,10 @@ export function syncSectionPath(section, replace = false, adminSection = null, a
|
||||
adminUserId ?? (adm === "users" ? adminUserIdFromPath(window.location.pathname) : null);
|
||||
const supportTicketId =
|
||||
adm === "support" ? adminSupportTicketIdFromPath(window.location.pathname) : null;
|
||||
const paymentId = adm === "payments" ? adminPaymentIdFromPath(window.location.pathname) : null;
|
||||
if (adm === "users" && uid) targetPath = `/admin/users/${uid}`;
|
||||
else if (adm === "support" && supportTicketId) targetPath = `/admin/support/${supportTicketId}`;
|
||||
else if (adm === "payments" && paymentId) targetPath = `/admin/payments/${paymentId}`;
|
||||
else targetPath = `/admin/${adm}`;
|
||||
}
|
||||
if (window.location.pathname === targetPath) return;
|
||||
|
||||
@@ -2368,6 +2368,163 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-payment-dialog {
|
||||
width: min(100%, 980px);
|
||||
max-height: min(100%, 760px);
|
||||
}
|
||||
|
||||
.admin-payment-dialog-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-payment-aside,
|
||||
.admin-payment-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-payment-summary {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--admin-surface-2);
|
||||
border: 1px solid var(--admin-border);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-payment-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--admin-surface));
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-payment-summary-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-payment-summary-meta strong {
|
||||
color: var(--admin-text);
|
||||
font-size: 17px;
|
||||
font-weight: 750;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.admin-payment-summary-meta small {
|
||||
color: var(--admin-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.admin-payment-summary-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.admin-payment-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-payment-stat {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
gap: 2px 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--admin-border);
|
||||
background: var(--admin-surface);
|
||||
}
|
||||
|
||||
.admin-payment-stat > svg {
|
||||
grid-row: span 2;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.admin-payment-stat span {
|
||||
color: var(--admin-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-payment-stat strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--admin-text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-payment-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--admin-surface-2) 55%, var(--admin-bg));
|
||||
}
|
||||
|
||||
.admin-payment-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.admin-payment-panel-head h3 {
|
||||
margin: 0;
|
||||
color: var(--admin-text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-payment-meta-list li {
|
||||
grid-template-columns: 138px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.admin-payment-meta-list li > .admin-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.admin-payment-meta-list li {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
||||
.admin-payment-meta-list li > span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-user-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2552,10 +2709,21 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-payment-dialog-body {
|
||||
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-user-aside {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.admin-payment-aside {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-tariff-grid {
|
||||
|
||||
@@ -935,6 +935,28 @@
|
||||
"admin_payments_open_user": "Open user card",
|
||||
"admin_payments_desc_traffic_package_regular": "Traffic package {gb} GB (standard)",
|
||||
"admin_payments_desc_traffic_package_premium": "Traffic package {gb} GB (premium)",
|
||||
"admin_payment_detail_open": "Open payment",
|
||||
"admin_payment_detail_title": "Payment #{id}",
|
||||
"admin_payment_detail_copied": "Copied",
|
||||
"admin_payment_load_failed": "Failed to load payment",
|
||||
"admin_payment_detail_updated_at": "Updated",
|
||||
"admin_payment_detail_provider_payment_id": "Provider ID",
|
||||
"admin_payment_detail_idempotence_key": "Idempotence key",
|
||||
"admin_payment_detail_sale_mode": "Sale type",
|
||||
"admin_payment_detail_tariff_key": "Tariff",
|
||||
"admin_payment_detail_duration_months": "Period",
|
||||
"admin_payment_detail_months_count": "{count} mo.",
|
||||
"admin_payment_detail_traffic": "Traffic",
|
||||
"admin_payment_detail_regular_traffic": "Regular: {gb}",
|
||||
"admin_payment_detail_premium_traffic": "Premium: {gb}",
|
||||
"admin_payment_detail_purchased_gb": "Purchased GB",
|
||||
"admin_payment_detail_hwid_devices": "HWID devices",
|
||||
"admin_payment_detail_promo_code": "Promo code",
|
||||
"admin_payment_detail_provider": "Provider",
|
||||
"admin_payment_detail_user_section": "User",
|
||||
"admin_payment_detail_payment_section": "Payment",
|
||||
"admin_payment_detail_provider_section": "Provider",
|
||||
"admin_payment_detail_purchase_section": "Purchase",
|
||||
"admin_logs_user_filter_placeholder": "Filter by user ID",
|
||||
"admin_apply": "Apply",
|
||||
"admin_reset": "Reset",
|
||||
|
||||
@@ -935,6 +935,28 @@
|
||||
"admin_payments_open_user": "Открыть карточку пользователя",
|
||||
"admin_payments_desc_traffic_package_regular": "Пакет трафика {gb} ГБ (обычный)",
|
||||
"admin_payments_desc_traffic_package_premium": "Пакет трафика {gb} ГБ (премиум)",
|
||||
"admin_payment_detail_open": "Открыть платёж",
|
||||
"admin_payment_detail_title": "Платёж #{id}",
|
||||
"admin_payment_detail_copied": "Скопировано",
|
||||
"admin_payment_load_failed": "Не удалось загрузить платёж",
|
||||
"admin_payment_detail_updated_at": "Обновлён",
|
||||
"admin_payment_detail_provider_payment_id": "ID у провайдера",
|
||||
"admin_payment_detail_idempotence_key": "Ключ идемпотентности",
|
||||
"admin_payment_detail_sale_mode": "Тип продажи",
|
||||
"admin_payment_detail_tariff_key": "Тариф",
|
||||
"admin_payment_detail_duration_months": "Период",
|
||||
"admin_payment_detail_months_count": "{count} мес.",
|
||||
"admin_payment_detail_traffic": "Трафик",
|
||||
"admin_payment_detail_regular_traffic": "Основной: {gb}",
|
||||
"admin_payment_detail_premium_traffic": "Премиум: {gb}",
|
||||
"admin_payment_detail_purchased_gb": "Куплено GB",
|
||||
"admin_payment_detail_hwid_devices": "HWID-устройства",
|
||||
"admin_payment_detail_promo_code": "Промокод",
|
||||
"admin_payment_detail_provider": "Провайдер",
|
||||
"admin_payment_detail_user_section": "Пользователь",
|
||||
"admin_payment_detail_payment_section": "Платёж",
|
||||
"admin_payment_detail_provider_section": "Провайдер",
|
||||
"admin_payment_detail_purchase_section": "Покупка",
|
||||
"admin_logs_user_filter_placeholder": "Фильтр по ID пользователя",
|
||||
"admin_apply": "Применить",
|
||||
"admin_reset": "Сбросить",
|
||||
|
||||
Reference in New Issue
Block a user