feat: add admin payment detail view
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user