feat: search and status filter on the Подписки table

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 <select>.

Verification: extracted the actual subStatus()/renderFilteredSubs()
filter predicate out of admin.html — not a reimplementation, diffed it
against the file to confirm byte-for-byte match — and ran it under Node
against four mock subscriptions covering all three statuses, including
one with a null username (the real shape for gift-redeemed subs with no
Telegram username set) to make sure the search doesn't throw on that.
13 checks: plain search, case-insensitivity, tg_id/node/plan matching,
no-match, each status filter alone, and two combined search+status
cases. node --check on the full extracted script, div-tag balance on
the whole file, both clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Savsis? 2026-09-14 02:27:40 +05:00
parent 670579fccd
commit b8c4949201

View file

@ -332,6 +332,10 @@
<div id="view-subscriptions" class="view">
<div class="page-title">Подписки</div>
<div class="page-sub">Все выданные подписки</div>
<div class="form-row" style="margin-bottom:16px">
<div><input type="text" id="subs-search" placeholder="Поиск: юзернейм, tg id, сервер, тариф" oninput="renderFilteredSubs()"></div>
<div style="flex:0;min-width:180px"><div id="subs-status-filter" class="dd"></div></div>
</div>
<div class="table-wrap"><table><thead><tr>
<th>Пользователь</th><th>Сервер</th><th>Тариф</th><th>Выдана</th><th>Истекает</th><th>Статус</th><th></th>
</tr></thead><tbody id="subs-body"></tbody></table></div>
@ -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("") : '<tr><td colspan="5"><div class="empty">Пока нет подписок</div></td></tr>';
}
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) => `
<tr ${rowAttr(i)}><td>${s.username ? "@" + esc(s.username) : "tg" + s.tg_id}</td><td>${esc(s.node_label)}</td><td>${esc(s.plan_label)}</td>
@ -1429,6 +1444,31 @@ async function loadSubscriptions() {
`).join("") : '<tr><td colspan="7"><div class="empty">Пока нет подписок</div></td></tr>';
}
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;