feat: add admin payment detail view

This commit is contained in:
3252a8
2026-05-24 09:30:58 +03:00
parent ad275a7c83
commit aa65972483
13 changed files with 786 additions and 18 deletions
@@ -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>