From aa6597248369dd231e524b3ebd8e1ee5177aaf23 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 24 May 2026 09:30:58 +0300 Subject: [PATCH] feat: add admin payment detail view --- .../bot/app/web/admin_api_impl/payments.py | 29 ++ backend/bot/app/web/admin_api_impl/routes.py | 1 + backend/bot/app/web/webapp/routes.py | 1 + frontend/src/App.svelte | 2 + frontend/src/admin/AdminPanel.svelte | 39 ++- .../admin/sections/PaymentDetailModal.svelte | 315 ++++++++++++++++++ .../src/admin/sections/PaymentsSection.svelte | 27 +- .../src/lib/admin/stores/paymentsStore.js | 91 ++++- frontend/src/lib/webapp/mockApi.js | 75 ++++- frontend/src/lib/webapp/routes.js | 12 +- frontend/src/styles/admin.css | 168 ++++++++++ locales/en.json | 22 ++ locales/ru.json | 22 ++ 13 files changed, 786 insertions(+), 18 deletions(-) create mode 100644 frontend/src/admin/sections/PaymentDetailModal.svelte diff --git a/backend/bot/app/web/admin_api_impl/payments.py b/backend/bot/app/web/admin_api_impl/payments.py index d554b5c..33dbd54 100644 --- a/backend/bot/app/web/admin_api_impl/payments.py +++ b/backend/bot/app/web/admin_api_impl/payments.py @@ -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"] diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py index a6d9d98..fe57f77 100644 --- a/backend/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -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) diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py index 99db843..c5b9716 100644 --- a/backend/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -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) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 8eeb809..63b5a3b 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -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, diff --git a/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index 995bc53..a0c8e47 100644 --- a/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -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 }); + } +
@@ -711,6 +738,14 @@ + + + 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 }, + ]; + + + + {#if payment} +
+ + +
+ {#if paymentDetailLoading && !openedPayment} +

{at("loading", {}, "Загрузка...")}

+ {:else} +
+
+ +

{at("payment_detail_payment_section", {}, "Платёж")}

+
+
    + {#each paymentRows as row} +
  • + {row.label} + {display(row.value)} + {#if row.copy} + copy(row.copy)} + > + + + {/if} +
  • + {/each} +
+
+ +
+
+ +

{at("payment_detail_provider_section", {}, "Провайдер")}

+
+
    + {#each providerRows as row} +
  • + {row.label} + {display(row.value)} + {#if row.copy} + copy(row.copy)} + > + + + {/if} +
  • + {/each} +
+
+ +
+
+ +

{at("payment_detail_purchase_section", {}, "Покупка")}

+
+
    + {#each purchaseRows as row} +
  • + {row.label} + {display(row.value)} +
  • + {/each} +
+
+ {/if} +
+
+ {/if} +
diff --git a/frontend/src/admin/sections/PaymentsSection.svelte b/frontend/src/admin/sections/PaymentsSection.svelte index c247596..7487c40 100644 --- a/frontend/src/admin/sections/PaymentsSection.svelte +++ b/frontend/src/admin/sections/PaymentsSection.svelte @@ -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 @@ {#each payments as p} - #{p.payment_id} + + paymentsStore.openPayment(p)} + > + + #{p.payment_id} + + diff --git a/frontend/src/lib/admin/stores/paymentsStore.js b/frontend/src/lib/admin/stores/paymentsStore.js index 63f4b93..e1f495e 100644 --- a/frontend/src/lib/admin/stores/paymentsStore.js +++ b/frontend/src/lib/admin/stores/paymentsStore.js @@ -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, }; } diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 34325ba..1a125b4 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -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/")) { diff --git a/frontend/src/lib/webapp/routes.js b/frontend/src/lib/webapp/routes.js index 44155c6..96a97bf 100644 --- a/frontend/src/lib/webapp/routes.js +++ b/frontend/src/lib/webapp/routes.js @@ -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; diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index ca84000..728243d 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -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 { diff --git a/locales/en.json b/locales/en.json index db21e6c..0f6199d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/ru.json b/locales/ru.json index b4790d6..94dc920 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "Сбросить",