678 lines
24 KiB
Svelte
678 lines
24 KiB
Svelte
<script>
|
|
import {
|
|
ArrowLeft,
|
|
Check,
|
|
ChevronsUpDown,
|
|
Coins,
|
|
CreditCard,
|
|
Download,
|
|
FileText,
|
|
Globe2,
|
|
LayoutDashboard,
|
|
Megaphone,
|
|
Menu,
|
|
Paintbrush,
|
|
Plus,
|
|
RefreshCw,
|
|
Save,
|
|
Sliders,
|
|
Sparkles,
|
|
Tag,
|
|
UsersRound,
|
|
} from "$components/ui/icons.js";
|
|
import { onMount, setContext } from "svelte";
|
|
import { fade } from "svelte/transition";
|
|
import { Select } from "$components/ui/primitives.js";
|
|
import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js";
|
|
|
|
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
|
import AdsSection from "./sections/AdsSection.svelte";
|
|
import BroadcastSection from "./sections/BroadcastSection.svelte";
|
|
import LogsSection from "./sections/LogsSection.svelte";
|
|
import PaymentsSection from "./sections/PaymentsSection.svelte";
|
|
import PromosSection from "./sections/PromosSection.svelte";
|
|
import SettingsSection from "./sections/SettingsSection.svelte";
|
|
import StatsSection from "./sections/StatsSection.svelte";
|
|
import TariffEditorModal from "./sections/TariffEditorModal.svelte";
|
|
import TariffsSection from "./sections/TariffsSection.svelte";
|
|
import AppearanceSection from "./sections/AppearanceSection.svelte";
|
|
import UserDetailModal from "./sections/UserDetailModal.svelte";
|
|
import UsersSection from "./sections/UsersSection.svelte";
|
|
import { createAdsStore } from "../lib/admin/stores/adsStore.js";
|
|
import { createBroadcastStore } from "../lib/admin/stores/broadcastStore.js";
|
|
import { createLogsStore } from "../lib/admin/stores/logsStore.js";
|
|
import { createPaymentsStore } from "../lib/admin/stores/paymentsStore.js";
|
|
import { createPromosStore } from "../lib/admin/stores/promosStore.js";
|
|
import { createSettingsStore } from "../lib/admin/stores/settingsStore.js";
|
|
import { createStatsStore } from "../lib/admin/stores/statsStore.js";
|
|
import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js";
|
|
import { createThemesStore } from "../lib/admin/stores/themesStore.js";
|
|
import { createUsersStore } from "../lib/admin/stores/usersStore.js";
|
|
import {
|
|
fmtDate,
|
|
fmtDateShort,
|
|
fmtMoney,
|
|
paymentStatusVariant,
|
|
trafficLeftLabel,
|
|
trafficOfLabel,
|
|
trafficPercentValue,
|
|
} from "../lib/admin/format.js";
|
|
import {
|
|
createGravatarCache,
|
|
userAvatarUrl,
|
|
userDisplayName,
|
|
userInitials,
|
|
userSecondaryName,
|
|
} from "../lib/admin/users.js";
|
|
|
|
export let api;
|
|
export let onClose = () => {};
|
|
export let onToast = () => {};
|
|
export let initialSection = "stats";
|
|
export let initialUserId = null;
|
|
export let onSectionChange = () => {};
|
|
export let onSettingsSaved = () => {};
|
|
export let onTariffsSaved = () => {};
|
|
export let onThemesSaved = () => {};
|
|
export let brand = {};
|
|
export let brandTitle = "/minishop";
|
|
export let appVersion = "dev+local";
|
|
export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop";
|
|
export let currentLang = "ru";
|
|
export let languageOptions = [];
|
|
export let languageBusy = false;
|
|
export let onLanguageChange = () => {};
|
|
export let t = (key, _params = {}, fallback = "") => fallback || key;
|
|
|
|
const at = (key, params = {}, fallback = "") => t(`admin_${key}`, params, fallback || key);
|
|
|
|
$: NAV_GROUPS = [
|
|
{
|
|
id: "overview",
|
|
label: at("nav_overview", {}, "Обзор"),
|
|
items: [{ id: "stats", label: at("nav_dashboard", {}, "Дашборд"), icon: LayoutDashboard }],
|
|
},
|
|
{
|
|
id: "operations",
|
|
label: at("nav_operations", {}, "Управление"),
|
|
items: [
|
|
{ id: "users", label: at("nav_users", {}, "Пользователи"), icon: UsersRound },
|
|
{ id: "payments", label: at("nav_payments", {}, "Платежи"), icon: CreditCard },
|
|
{ id: "promos", label: at("nav_promos", {}, "Промокоды"), icon: Tag },
|
|
{ id: "ads", label: at("nav_ads", {}, "Реклама"), icon: Sparkles },
|
|
],
|
|
},
|
|
{
|
|
id: "communication",
|
|
label: at("nav_communication", {}, "Коммуникации"),
|
|
items: [
|
|
{ id: "broadcast", label: at("nav_broadcast", {}, "Рассылка"), icon: Megaphone },
|
|
{ id: "logs", label: at("nav_logs", {}, "Логи"), icon: FileText },
|
|
],
|
|
},
|
|
{
|
|
id: "system",
|
|
label: at("nav_system", {}, "Система"),
|
|
items: [
|
|
{ id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
|
|
{ id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush },
|
|
{ id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
|
|
],
|
|
},
|
|
];
|
|
|
|
$: SECTION_META = {
|
|
stats: {
|
|
title: at("section_stats_title", {}, "Дашборд"),
|
|
subtitle: at(
|
|
"section_stats_subtitle",
|
|
{},
|
|
"Аудитория, доходы, панель Remnawave и последние платежи"
|
|
),
|
|
},
|
|
users: {
|
|
title: at("section_users_title", {}, "Пользователи"),
|
|
subtitle: at("section_users_subtitle", {}, "Поиск, баны и действия над аккаунтами"),
|
|
},
|
|
payments: {
|
|
title: at("section_payments_title", {}, "Платежи"),
|
|
subtitle: at("section_payments_subtitle", {}, "История транзакций и экспорт"),
|
|
},
|
|
promos: {
|
|
title: at("section_promos_title", {}, "Промокоды"),
|
|
subtitle: at("section_promos_subtitle", {}, "Создание и управление кодами"),
|
|
},
|
|
ads: {
|
|
title: at("section_ads_title", {}, "Рекламные кампании"),
|
|
subtitle: at("section_ads_subtitle", {}, "UTM-источники и атрибуция"),
|
|
},
|
|
broadcast: {
|
|
title: at("section_broadcast_title", {}, "Рассылка"),
|
|
subtitle: at("section_broadcast_subtitle", {}, "Массовая отправка сообщений в Telegram"),
|
|
},
|
|
logs: {
|
|
title: at("section_logs_title", {}, "Логи активности"),
|
|
subtitle: at("section_logs_subtitle", {}, "События пользователей и админ-действия"),
|
|
},
|
|
tariffs: {
|
|
title: at("section_tariffs_title", {}, "Тарифы"),
|
|
subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"),
|
|
},
|
|
appearance: {
|
|
title: at("section_appearance_title", {}, "Внешний вид"),
|
|
subtitle: at("section_appearance_subtitle", {}, "Логотип, темы и акцентные цвета Mini App"),
|
|
},
|
|
settings: {
|
|
title: at("section_settings_title", {}, "Настройки приложения"),
|
|
subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"),
|
|
},
|
|
};
|
|
|
|
$: VALID_SECTIONS = (NAV_GROUPS || []).flatMap((group) =>
|
|
(group.items || []).map((item) => item.id)
|
|
);
|
|
const normalizeSection = (value) => ((VALID_SECTIONS || []).includes(value) ? value : "stats");
|
|
|
|
let active = normalizeSection(initialSection);
|
|
$: if (initialSection) {
|
|
active = normalizeSection(initialSection);
|
|
}
|
|
let sidebarOpen = false;
|
|
let isCompact = false;
|
|
let adminLanguageMenuOpen = false;
|
|
let adminLanguageClickGuard = false;
|
|
let adminLanguageClickGuardArmed = false;
|
|
let adminLanguageClickGuardTimer = null;
|
|
let adminLanguageClickGuardArmTimer = null;
|
|
|
|
function readReduceMotion() {
|
|
return (
|
|
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
);
|
|
}
|
|
|
|
let reduceMotion = readReduceMotion();
|
|
|
|
function flash(text) {
|
|
onToast(text);
|
|
}
|
|
|
|
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 promosStore = createPromosStore({ api, onToast: flash, at });
|
|
const settingsStore = createSettingsStore({ api, onToast: flash, at });
|
|
const statsStore = createStatsStore({ api, onToast: flash, at });
|
|
const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at });
|
|
const themesStore = createThemesStore({ api, onThemesSaved, flash, at });
|
|
const usersStore = createUsersStore({ api, onToast: flash, at });
|
|
|
|
setContext("promosStore", promosStore);
|
|
setContext("adsStore", adsStore);
|
|
setContext("broadcastStore", broadcastStore);
|
|
setContext("logsStore", logsStore);
|
|
setContext("paymentsStore", paymentsStore);
|
|
setContext("statsStore", statsStore);
|
|
setContext("settingsStore", settingsStore);
|
|
setContext("usersStore", usersStore);
|
|
setContext("tariffsStore", tariffsStore);
|
|
setContext("themesStore", themesStore);
|
|
|
|
$: usersStore.setActive(active);
|
|
$: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length;
|
|
$: syncBusy = $statsStore.syncBusy;
|
|
$: settingsSaving = $settingsStore.settingsSaving;
|
|
$: meta = SECTION_META[active] || { title: active, subtitle: "" };
|
|
$: currentLanguageOption =
|
|
languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
|
|
|
|
const gravatarCache = createGravatarCache(() => usersStore.updateState({}));
|
|
|
|
function setActive(id) {
|
|
const next = normalizeSection(id);
|
|
sidebarOpen = false;
|
|
if (active === next) return;
|
|
active = next;
|
|
usersStore.closeUser();
|
|
onSectionChange(next);
|
|
}
|
|
|
|
function readSectionFromPath() {
|
|
if (typeof window === "undefined") return "stats";
|
|
const match = window.location.pathname.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/i);
|
|
return normalizeSection(match ? match[1].toLowerCase() : "stats");
|
|
}
|
|
|
|
function readUserIdFromPath() {
|
|
if (typeof window === "undefined") return null;
|
|
const match = window.location.pathname.match(/^\/admin\/users\/(-?\d+)$/);
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
|
|
function onPopState() {
|
|
active = readSectionFromPath();
|
|
sidebarOpen = false;
|
|
const userId = readUserIdFromPath();
|
|
if (userId) {
|
|
if (!$usersStore.openedUser || $usersStore.openedUser.user_id !== userId) {
|
|
usersStore.openUser(userId, { skipPush: true });
|
|
}
|
|
} else if ($usersStore.openedUser) {
|
|
usersStore.closeUser({ skipPush: true });
|
|
}
|
|
}
|
|
|
|
function exportPayments() {
|
|
if (typeof window === "undefined") return;
|
|
window.open("/api/admin/payments/export.csv", "_blank", "noopener");
|
|
}
|
|
|
|
function openPaymentUserCard(userId) {
|
|
const uid = Number(userId);
|
|
// Synthetic email-only users use negative user_id; still a valid admin target.
|
|
if (!Number.isFinite(uid) || uid === 0) return;
|
|
const next = normalizeSection("users");
|
|
sidebarOpen = false;
|
|
if (active !== next) {
|
|
active = next;
|
|
usersStore.closeUser();
|
|
onSectionChange(next);
|
|
}
|
|
usersStore.openUser(uid);
|
|
}
|
|
|
|
function resolvedAvatarUrl(user) {
|
|
return (
|
|
userAvatarUrl(user) ||
|
|
(!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : "")
|
|
);
|
|
}
|
|
|
|
function panelStatusBadge(user) {
|
|
const status = String(user?.panel_status || "").toLowerCase();
|
|
if (user?.is_banned) return { label: at("status_banned", {}, "Бан"), variant: "danger" };
|
|
switch (status) {
|
|
case "active":
|
|
return { label: at("status_active", {}, "Active"), variant: "success" };
|
|
case "expired":
|
|
return {
|
|
label: user?.panel_status_expired_at
|
|
? at(
|
|
"expired_badge",
|
|
{ date: fmtDateShort(user.panel_status_expired_at) },
|
|
`Expired ${fmtDateShort(user.panel_status_expired_at)}`
|
|
)
|
|
: at("status_expired", {}, "Expired"),
|
|
variant: "warning",
|
|
};
|
|
case "limited":
|
|
return { label: at("status_limited", {}, "Limited"), variant: "warning" };
|
|
case "disabled":
|
|
return { label: at("status_disabled", {}, "Disabled"), variant: "muted" };
|
|
case "bot_only":
|
|
return { label: at("status_bot_only", {}, "Только бот"), variant: "muted" };
|
|
default:
|
|
return { label: status || "—", variant: "muted" };
|
|
}
|
|
}
|
|
|
|
let compactMql = null;
|
|
function onCompactChange(event) {
|
|
isCompact = Boolean(event?.matches);
|
|
}
|
|
|
|
function clearAdminLanguageClickGuard() {
|
|
if (adminLanguageClickGuardTimer) {
|
|
window.clearTimeout(adminLanguageClickGuardTimer);
|
|
adminLanguageClickGuardTimer = null;
|
|
}
|
|
if (adminLanguageClickGuardArmTimer) {
|
|
window.clearTimeout(adminLanguageClickGuardArmTimer);
|
|
adminLanguageClickGuardArmTimer = null;
|
|
}
|
|
adminLanguageClickGuard = false;
|
|
adminLanguageClickGuardArmed = false;
|
|
}
|
|
|
|
function setAdminLanguageMenuOpen(open) {
|
|
adminLanguageMenuOpen = Boolean(open);
|
|
clearAdminLanguageClickGuard();
|
|
// Desktop doesn't need the click-guard overlay and it can block
|
|
// option clicks in portaled select content.
|
|
if (!isCompact) return;
|
|
if (adminLanguageMenuOpen) {
|
|
adminLanguageClickGuard = true;
|
|
adminLanguageClickGuardArmTimer = window.setTimeout(() => {
|
|
adminLanguageClickGuardArmed = true;
|
|
adminLanguageClickGuardArmTimer = null;
|
|
}, 220);
|
|
return;
|
|
}
|
|
adminLanguageClickGuard = true;
|
|
adminLanguageClickGuardArmed = false;
|
|
adminLanguageClickGuardTimer = window.setTimeout(() => {
|
|
adminLanguageClickGuard = false;
|
|
adminLanguageClickGuardTimer = null;
|
|
}, 260);
|
|
}
|
|
|
|
function closeAdminLanguageFromGuard(event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (adminLanguageClickGuardArmed) setAdminLanguageMenuOpen(false);
|
|
}
|
|
|
|
onMount(() => {
|
|
reduceMotion = readReduceMotion();
|
|
let motionMql = null;
|
|
const onMotionChange = () => {
|
|
reduceMotion = readReduceMotion();
|
|
};
|
|
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
|
motionMql = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
reduceMotion = motionMql.matches;
|
|
motionMql.addEventListener("change", onMotionChange);
|
|
}
|
|
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
|
compactMql = window.matchMedia("(max-width: 720px)");
|
|
isCompact = compactMql.matches;
|
|
if (compactMql.addEventListener) compactMql.addEventListener("change", onCompactChange);
|
|
else if (compactMql.addListener) compactMql.addListener(onCompactChange);
|
|
}
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("popstate", onPopState);
|
|
}
|
|
return () => {
|
|
if (motionMql) motionMql.removeEventListener("change", onMotionChange);
|
|
if (compactMql) {
|
|
if (compactMql.removeEventListener)
|
|
compactMql.removeEventListener("change", onCompactChange);
|
|
else if (compactMql.removeListener) compactMql.removeListener(onCompactChange);
|
|
}
|
|
if (typeof window !== "undefined") window.removeEventListener("popstate", onPopState);
|
|
clearAdminLanguageClickGuard();
|
|
};
|
|
});
|
|
|
|
$: sectionFade = reduceMotion ? { duration: 0 } : { duration: 200 };
|
|
$: sidebarBackdropFade = reduceMotion ? { duration: 0 } : { duration: 180 };
|
|
|
|
$: if (
|
|
active === "users" &&
|
|
initialUserId &&
|
|
(!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialUserId)
|
|
) {
|
|
usersStore.openUser(initialUserId, { skipPush: true });
|
|
}
|
|
</script>
|
|
|
|
<div class="admin-screen-wrap" class:is-sidebar-open={sidebarOpen}>
|
|
{#if sidebarOpen}
|
|
<button
|
|
type="button"
|
|
class="admin-sidebar-backdrop"
|
|
aria-label={at("close_menu", {}, "Закрыть меню")}
|
|
in:fade={sidebarBackdropFade}
|
|
out:fade={sidebarBackdropFade}
|
|
on:click={() => (sidebarOpen = false)}
|
|
></button>
|
|
{/if}
|
|
{#if isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard)}
|
|
<button
|
|
class="language-select-guard"
|
|
class:language-select-guard--armed={adminLanguageClickGuardArmed}
|
|
type="button"
|
|
aria-label={t("wa_close", {}, at("close", {}, "Закрыть"))}
|
|
on:pointerdown={closeAdminLanguageFromGuard}
|
|
on:click={closeAdminLanguageFromGuard}
|
|
></button>
|
|
{/if}
|
|
|
|
<aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}>
|
|
<div class="admin-sidebar-brand">
|
|
<BrandMark class="admin-brand-mark" {brand} />
|
|
<div>
|
|
<strong class="admin-brand-title">{brandTitle}</strong>
|
|
<small>{at("panel_title", {}, "Админ-панель")}</small>
|
|
</div>
|
|
<AdminButton
|
|
variant="ghost"
|
|
size="icon"
|
|
onclick={onClose}
|
|
aria-label={at("exit", {}, "Выйти")}
|
|
>
|
|
<ArrowLeft size={16} />
|
|
</AdminButton>
|
|
</div>
|
|
|
|
{#each NAV_GROUPS as group}
|
|
<div class="admin-sidebar-section-label">{group.label}</div>
|
|
<nav class="admin-nav" aria-label={group.label}>
|
|
{#each group.items as item}
|
|
<button
|
|
type="button"
|
|
class="admin-nav-item"
|
|
class:active={active === item.id}
|
|
on:click={() => setActive(item.id)}
|
|
>
|
|
<svelte:component this={item.icon} size={16} />
|
|
<span>{item.label}</span>
|
|
<span></span>
|
|
</button>
|
|
{/each}
|
|
</nav>
|
|
{/each}
|
|
|
|
<div class="admin-sidebar-footer">
|
|
{#if languageOptions.length}
|
|
<div class="admin-language-switch">
|
|
<Globe2 size={16} />
|
|
<Select.Root
|
|
type="single"
|
|
bind:open={adminLanguageMenuOpen}
|
|
value={currentLang}
|
|
items={languageOptions}
|
|
disabled={languageBusy}
|
|
onOpenChange={setAdminLanguageMenuOpen}
|
|
onValueChange={onLanguageChange}
|
|
>
|
|
<Select.Trigger
|
|
class="admin-language-trigger"
|
|
aria-label={t("wa_settings_language", {}, at("language", {}, "Язык"))}
|
|
>
|
|
<span>
|
|
<strong>{t("wa_settings_language", {}, at("language", {}, "Язык"))}</strong>
|
|
<small>
|
|
<span class="emoji-flag" aria-hidden="true"
|
|
>{currentLanguageOption?.flag || "🏳️"}</span
|
|
>
|
|
{currentLanguageOption?.label || currentLang}
|
|
</small>
|
|
</span>
|
|
<ChevronsUpDown size={14} />
|
|
</Select.Trigger>
|
|
<Select.Content class="language-select-content" side="top" align="start" sideOffset={8}>
|
|
<Select.Viewport class="language-select-viewport">
|
|
{#each languageOptions as option (option.value)}
|
|
<Select.Item
|
|
value={option.value}
|
|
label={option.label}
|
|
class="language-select-item"
|
|
>
|
|
<span class="language-select-item-main">
|
|
<span class="emoji-flag" aria-hidden="true">{option.flag}</span>
|
|
<span>{option.label}</span>
|
|
</span>
|
|
<Check size={15} class="language-select-item-check" />
|
|
</Select.Item>
|
|
{/each}
|
|
</Select.Viewport>
|
|
</Select.Content>
|
|
</Select.Root>
|
|
</div>
|
|
{/if}
|
|
<a
|
|
class="admin-version-link"
|
|
href={appRepositoryUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title="GitHub"
|
|
>
|
|
<span>remnawave-minishop</span>
|
|
<span>{appVersion || "dev+local"}</span>
|
|
</a>
|
|
</div>
|
|
</aside>
|
|
|
|
<section class="admin-content">
|
|
<header class="admin-header">
|
|
<div style="display:flex; align-items:center; gap:12px; min-width:0;">
|
|
<button
|
|
type="button"
|
|
class="admin-mobile-toggle"
|
|
on:click={() => (sidebarOpen = !sidebarOpen)}
|
|
aria-label={at("menu", {}, "Меню")}
|
|
>
|
|
<Menu size={18} />
|
|
</button>
|
|
<div class="admin-header-title">
|
|
<h2>{meta.title}</h2>
|
|
{#if meta.subtitle}<small>{meta.subtitle}</small>{/if}
|
|
</div>
|
|
</div>
|
|
<div class="admin-header-actions">
|
|
{#if active === "stats"}
|
|
<AdminButton onclick={statsStore.triggerSync} disabled={syncBusy}>
|
|
<RefreshCw size={14} />
|
|
{syncBusy
|
|
? at("btn_syncing", {}, "Синхронизация...")
|
|
: at("btn_sync", {}, "Синхронизировать")}
|
|
</AdminButton>
|
|
{/if}
|
|
{#if active === "payments"}
|
|
<AdminButton onclick={exportPayments}>
|
|
<Download size={14} /> CSV
|
|
</AdminButton>
|
|
{/if}
|
|
{#if active === "promos"}
|
|
<AdminButton variant="primary" onclick={() => promosStore.setCreateOpen(true)}>
|
|
<Plus size={14} />
|
|
{at("btn_create", {}, "Создать")}
|
|
</AdminButton>
|
|
{/if}
|
|
{#if active === "ads"}
|
|
<AdminButton variant="primary" onclick={() => adsStore.setCreateOpen(true)}>
|
|
<Plus size={14} />
|
|
{at("btn_campaign", {}, "Кампания")}
|
|
</AdminButton>
|
|
{/if}
|
|
{#if active === "tariffs"}
|
|
<AdminButton variant="primary" onclick={tariffsStore.openCreateTariff}>
|
|
<Plus size={14} />
|
|
{at("btn_tariff", {}, "Тариф")}
|
|
</AdminButton>
|
|
{/if}
|
|
{#if active === "settings"}
|
|
{#if dirtyCount}
|
|
<AdminBadge variant="warning"
|
|
>{at(
|
|
"settings_dirty_count",
|
|
{ count: dirtyCount },
|
|
"Изменений: " + dirtyCount
|
|
)}</AdminBadge
|
|
>
|
|
{/if}
|
|
<AdminButton
|
|
variant="primary"
|
|
onclick={() => settingsStore.saveSettings(onSettingsSaved)}
|
|
disabled={!dirtyCount || settingsSaving}
|
|
>
|
|
<Save size={14} />
|
|
{settingsSaving
|
|
? at("btn_saving", {}, "Сохранение...")
|
|
: at("btn_save", {}, "Сохранить")}
|
|
</AdminButton>
|
|
{/if}
|
|
</div>
|
|
</header>
|
|
|
|
<main class="admin-main">
|
|
{#key active}
|
|
<div class="admin-section-stage" in:fade={sectionFade} out:fade={sectionFade}>
|
|
{#if active === "stats"}
|
|
<StatsSection {at} {fmtDate} {fmtDateShort} {fmtMoney} {paymentStatusVariant} />
|
|
{/if}
|
|
|
|
{#if active === "users"}
|
|
<UsersSection
|
|
{at}
|
|
{fmtDateShort}
|
|
{panelStatusBadge}
|
|
{resolvedAvatarUrl}
|
|
{userDisplayName}
|
|
{userInitials}
|
|
{userSecondaryName}
|
|
/>
|
|
{/if}
|
|
|
|
{#if active === "payments"}
|
|
<PaymentsSection
|
|
{at}
|
|
{fmtDate}
|
|
{fmtMoney}
|
|
{paymentStatusVariant}
|
|
onOpenUserCard={openPaymentUserCard}
|
|
/>
|
|
{/if}
|
|
|
|
{#if active === "promos"}
|
|
<PromosSection {at} {fmtDateShort} />
|
|
{/if}
|
|
|
|
{#if active === "ads"}
|
|
<AdsSection {at} {fmtMoney} />
|
|
{/if}
|
|
|
|
{#if active === "broadcast"}
|
|
<BroadcastSection {at} />
|
|
{/if}
|
|
|
|
{#if active === "logs"}
|
|
<LogsSection {at} {fmtDate} />
|
|
{/if}
|
|
|
|
{#if active === "tariffs"}
|
|
<TariffsSection {at} {fmtMoney} />
|
|
{/if}
|
|
|
|
{#if active === "appearance"}
|
|
<AppearanceSection {at} {currentLang} {onSettingsSaved} {brand} />
|
|
{/if}
|
|
|
|
{#if active === "settings"}
|
|
<SettingsSection {at} {isCompact} {onSettingsSaved} {currentLang} />
|
|
{/if}
|
|
</div>
|
|
{/key}
|
|
</main>
|
|
</section>
|
|
</div>
|
|
|
|
<TariffEditorModal {at} />
|
|
|
|
<UserDetailModal
|
|
{at}
|
|
{fmtDate}
|
|
{fmtDateShort}
|
|
{fmtMoney}
|
|
{resolvedAvatarUrl}
|
|
{userDisplayName}
|
|
{userSecondaryName}
|
|
{userInitials}
|
|
{paymentStatusVariant}
|
|
{trafficPercentValue}
|
|
{trafficLeftLabel}
|
|
{trafficOfLabel}
|
|
/>
|