- | #{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": "Сбросить",
|