From b8c4949201c88f8ebd2ba9872716462fe4c0a106 Mon Sep 17 00:00:00 2001 From: savsis Date: Mon, 14 Sep 2026 02:27:40 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20search=20and=20status=20filter=20on=20t?= =?UTF-8?q?he=20=D0=9F=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Another line off the fresh docs.rw comparison from last commit: "User Management Filters — Extended selection (Remnawave) vs Minimal options (Marzban)". The subscriptions table had none at all — no search, no status filter, just the raw list with a server-side limit=200. Fine with a handful of test subscriptions, useless once a real business has a few hundred customers and support needs to find one person's row. Pure client-side: the full list was already fetched in one call (/admin/api/subscriptions), so filtering it in the browser needs no new backend route and can't regress anything server-side. Refactored loadSubscriptions() to keep the fetched list in allSubs and render through a separate renderFilteredSubs(), which the existing revoke/hold/resume refresh calls now go through too — so the search box and status filter stay applied after an action instead of resetting the view. Search matches username, tg_id, node label, and plan label as one lowercased substring check. Status filter (active / on hold / expired- revoked / all) reuses the exact three-way split statusBadge() already draws, via a new subStatus() helper — same custom .dd dropdown as everywhere else in the panel, not a native +
+
ПользовательСерверТарифВыданаИстекаетСтатус
@@ -969,6 +973,16 @@ function initDropdowns() { const countryOptions = COUNTRIES.map(([code, name]) => ({ value: code, label: `${flagEmoji(code)} ${name}`, })); + DD.subsStatusFilter = createDropdown("subs-status-filter", { + options: [ + { value: "all", label: "Все статусы" }, + { value: "active", label: "Активные" }, + { value: "held", label: "На паузе" }, + { value: "expired", label: "Истекшие/отозванные" }, + ], + value: "all", placeholder: "Статус", + onChange: () => renderFilteredSubs(), + }); DD.ngCountry = createDropdown("ng-country", { options: countryOptions, placeholder: "Страна", searchable: true, onChange: (code) => { @@ -1413,8 +1427,9 @@ async function loadDashboard() { `).join("") : '
Пока нет подписок
'; } -async function loadSubscriptions() { - const subs = await api("/admin/api/subscriptions"); +let allSubs = []; + +function renderSubsTable(subs) { const body = document.getElementById("subs-body"); body.innerHTML = subs.length ? subs.map((s, i) => ` ${s.username ? "@" + esc(s.username) : "tg" + s.tg_id}${esc(s.node_label)}${esc(s.plan_label)} @@ -1429,6 +1444,31 @@ async function loadSubscriptions() { `).join("") : '
Пока нет подписок
'; } +function subStatus(s) { + if (s.held_at) return "held"; + if (!s.active) return "expired"; + return "active"; +} + +function renderFilteredSubs() { + const q = document.getElementById("subs-search").value.trim().toLowerCase(); + const statusFilter = DD.subsStatusFilter ? DD.subsStatusFilter.getValue() : "all"; + const filtered = allSubs.filter((s) => { + if (statusFilter !== "all" && subStatus(s) !== statusFilter) return false; + if (!q) return true; + const haystack = [ + s.username || "", String(s.tg_id), s.node_label || "", s.plan_label || "", + ].join(" ").toLowerCase(); + return haystack.includes(q); + }); + renderSubsTable(filtered); +} + +async function loadSubscriptions() { + allSubs = await api("/admin/api/subscriptions"); + renderFilteredSubs(); +} + let devicesTgId = null; async function openUserCard(tgId, label) { devicesTgId = tgId;