const AdminPage = (() => { const { api, boot, toast, escapeHtml, formData, formatDateTime } = CarPassPage; const state = { active: "dashboard", sources: [], sorts: [], lastDataPayload: null, }; const panels = { dashboard: "#panel-dashboard", notifications: "#panel-notifications", users: "#panel-users", sto: "#panel-sto", "sto-applications": "#panel-sto-applications", vehicles: "#panel-vehicles", appointments: "#panel-appointments", "work-orders": "#panel-work-orders", data: "#panel-data", audit: "#panel-audit", exports: "#panel-exports", }; const quickLinks = [ ["notifications", "Notifications"], ["users", "Users"], ["sto-applications", "Заявки СТО"], ["vehicles", "Авто"], ["data", "Data Explorer"], ["audit", "Audit Log"], ]; function qs(selector) { return document.querySelector(selector); } function valueOrDash(value) { if (value === null || value === undefined || value === "") return "-"; if (typeof value === "string" && value.includes("T")) return formatDateTime(value); return escapeHtml(value); } function setActive(section) { state.active = panels[section] ? section : "dashboard"; Object.entries(panels).forEach(([name, selector]) => { qs(selector)?.classList.toggle("hidden", name !== state.active); }); document.querySelectorAll("[data-admin-tab]").forEach((button) => { button.classList.toggle("active", button.dataset.adminTab === state.active); }); const url = new URL(window.location.href); url.searchParams.set("section", state.active); window.history.replaceState({}, "", url); } function renderEmpty(root, text = "Нет данных") { root.innerHTML = `
${escapeHtml(text)}
`; } function renderError(root, error) { root.innerHTML = `
${escapeHtml(error.message || "Ошибка")}
`; } function renderTable(root, rows, preferredColumns = []) { if (!rows?.length) { renderEmpty(root); return; } const columns = preferredColumns.length ? preferredColumns : Object.keys(rows[0]); root.innerHTML = ` ${columns.map((column) => ``).join("")} ${rows .map( (row) => ` ${columns.map((column) => ``).join("")} `, ) .join("")}
${escapeHtml(column)}
${valueOrDash(row[column])}
`; } function badge(value) { return `${escapeHtml(value || "-")}`; } async function loadDashboard() { const data = await api("/admin/dashboard"); const statLabels = [ ["users_today", "Новые сегодня"], ["users_7d", "Новые 7 дней"], ["users_total", "Всего пользователей"], ["active_users", "Активные"], ["vehicles_total", "Авто"], ["pending_sto_applications", "Pending СТО"], ["approved_sto", "Approved СТО"], ["appointments_today", "Записи сегодня"], ["active_work_orders", "Активные ЗН"], ["completed_work_orders", "Завершенные ЗН"], ["system_errors", "Ошибки"], ["security_events", "Security"], ]; qs("#dashboardStats").innerHTML = statLabels .map(([key, label]) => `
${label}${data[key] ?? 0}
`) .join(""); const alerts = qs("#dashboardAlerts"); alerts.innerHTML = data.latest_alerts?.length ? data.latest_alerts .map( (item) => `
${escapeHtml(item.title)} ${badge(item.event_type)} ${formatDateTime(item.created_at)}
`, ) .join("") : `
Критичных событий нет
`; qs("#quickLinks").innerHTML = quickLinks .map(([section, label]) => ``) .join(""); bindTabButtons(); } async function loadNotifications() { const root = qs("#notificationsList"); try { const data = await api("/admin/notifications?limit=100"); if (!data.rows.length) return renderEmpty(root); root.innerHTML = data.rows .map( (item) => `
${escapeHtml(item.title)} ${badge(item.event_type)} ${badge(item.severity)} ${badge(item.status)} ${formatDateTime(item.created_at)}

${escapeHtml(item.body || "")}

`, ) .join(""); root.querySelectorAll("[data-read-notification]").forEach((button) => { button.addEventListener("click", async () => { await api(`/admin/notifications/${button.dataset.readNotification}/read`, { method: "POST" }); await loadNotifications(); }); }); root.querySelectorAll("[data-dismiss-notification]").forEach((button) => { button.addEventListener("click", async () => { await api(`/admin/notifications/${button.dataset.dismissNotification}/dismiss`, { method: "POST" }); await loadNotifications(); }); }); } catch (error) { renderError(root, error); } } async function loadUsers(search = "") { const query = new URLSearchParams(); if (search) query.set("search", search); const data = await api(`/admin/users?${query.toString()}`); renderTable(qs("#usersTable"), data.rows, ["id", "telegram_id", "username", "first_name", "platform_role", "created_at"]); } async function loadSto(filters = {}) { const query = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value) query.set(key, value); }); const data = await api(`/admin/sto?${query.toString()}`); renderTable(qs("#stoTable"), data.rows, ["id", "display_name", "city", "phone", "verification_status", "owner_user_id", "created_at"]); } async function loadApplications() { const root = qs("#applicationsList"); try { const data = await api("/admin/sto-applications"); if (!data.rows.length) return renderEmpty(root, "Очередь модерации пуста"); root.innerHTML = data.rows .map( (item) => `
${escapeHtml(item.display_name || item.legal_name || `СТО #${item.id}`)} ${badge(item.verification_status)} ${escapeHtml(item.city || "-")} ${formatDateTime(item.created_at)}
`, ) .join(""); root.querySelectorAll("[data-application-action]").forEach((button) => { button.addEventListener("click", async () => { const action = button.dataset.applicationAction; const reason = action === "approve" ? "Approved in admin panel" : window.prompt("Причина") || ""; if (action !== "approve" && !reason) return; await api(`/admin/sto-applications/${button.dataset.applicationId}/${action}`, { method: "POST", body: JSON.stringify({ reason, comment: reason }), }); toast("Статус заявки обновлен"); await loadApplications(); }); }); } catch (error) { renderError(root, error); } } async function loadSourceTable(source, rootSelector, columns) { const root = qs(rootSelector); try { const data = await api("/admin/data/query", { method: "POST", body: JSON.stringify({ source, limit: 100 }), }); renderTable(root, data.rows, columns); } catch (error) { renderError(root, error); } } function cleanPayload(payload) { const cleaned = {}; Object.entries(payload).forEach(([key, value]) => { if (value === "" || value === null || value === undefined) return; if (["user_id", "telegram_id", "vehicle_id", "sto_id", "limit"].includes(key)) { cleaned[key] = Number(value); } else if (key === "include_sensitive") { cleaned[key] = value === "on"; } else { cleaned[key] = value; } }); if (!("include_sensitive" in cleaned)) cleaned.include_sensitive = false; return cleaned; } async function submitDataQuery(format = null) { const payload = cleanPayload(formData(qs("#dataForm"))); state.lastDataPayload = payload; if (format) { const result = await api("/admin/data/export", { method: "POST", body: JSON.stringify({ ...payload, export_format: format }), }); toast(`Export #${result.id} готов`); await loadExports(); return; } const data = await api("/admin/data/query", { method: "POST", body: JSON.stringify(payload), }); renderTable(qs("#dataResult"), data.rows); } async function loadAudit(params = {}) { const query = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value) query.set(key, value); }); const rows = await api(`/admin/audit-log?${query.toString()}`); renderTable(qs("#auditTable"), rows, ["id", "actor_user_id", "actor_role", "action", "target_type", "target_id", "created_at"]); } async function loadExports() { const data = await api("/admin/exports"); renderTable(qs("#exportsTable"), data.rows, ["id", "source", "export_format", "status", "row_count", "reason", "expires_at", "created_at"]); } async function loadActiveSection() { if (state.active === "dashboard") return loadDashboard(); if (state.active === "notifications") return loadNotifications(); if (state.active === "users") return loadUsers(); if (state.active === "sto") return loadSto(); if (state.active === "sto-applications") return loadApplications(); if (state.active === "vehicles") return loadSourceTable("vehicles", "#vehiclesTable", ["id", "owner_id", "name", "make", "model", "year", "vin", "plate_number", "current_odometer", "created_at"]); if (state.active === "appointments") return loadSourceTable("appointments", "#appointmentsTable", ["id", "service_center_id", "vehicle_id", "owner_user_id", "service_type", "status", "requested_start_at", "created_at"]); if (state.active === "work-orders") return loadSourceTable("work_orders", "#workOrdersTable", ["id", "service_center_id", "vehicle_id", "owner_user_id", "status", "final_total", "currency", "completed_at"]); if (state.active === "audit") return loadAudit(); if (state.active === "exports") return loadExports(); return null; } function bindTabButtons() { document.querySelectorAll("[data-admin-tab]").forEach((button) => { button.addEventListener("click", async () => { setActive(button.dataset.adminTab); try { await loadActiveSection(); } catch (error) { toast(error.message || "Ошибка", "error"); } }); }); } function bindForms() { qs("#refreshBtn")?.addEventListener("click", () => loadActiveSection().catch((error) => toast(error.message, "error"))); qs("#readAllBtn")?.addEventListener("click", async () => { await api("/admin/notifications/read-all", { method: "POST" }); await loadNotifications(); }); qs("#retryNotificationsBtn")?.addEventListener("click", async () => { const result = await api("/admin/notifications/retry", { method: "POST" }); toast(`Retry: service ${result.service_delivered}, admin ${result.admin_delivered}`); await loadNotifications(); }); document.querySelector("[data-list-filter='users']")?.addEventListener("submit", async (event) => { event.preventDefault(); await loadUsers(formData(event.currentTarget).search || ""); }); document.querySelector("[data-list-filter='sto']")?.addEventListener("submit", async (event) => { event.preventDefault(); await loadSto(formData(event.currentTarget)); }); qs("#dataForm")?.addEventListener("submit", async (event) => { event.preventDefault(); await submitDataQuery().catch((error) => toast(error.message, "error")); }); qs("#exportJsonBtn")?.addEventListener("click", () => submitDataQuery("json").catch((error) => toast(error.message, "error"))); qs("#exportCsvBtn")?.addEventListener("click", () => submitDataQuery("csv").catch((error) => toast(error.message, "error"))); qs("#auditForm")?.addEventListener("submit", async (event) => { event.preventDefault(); await loadAudit(cleanPayload(formData(event.currentTarget))); }); } async function initSources() { const data = await api("/admin/data/sources"); state.sources = data.sources || []; state.sorts = data.sorts || []; qs("#sourceSelect").innerHTML = state.sources .filter((source) => source.available && source.allowed) .map((source) => ``) .join(""); qs("#sortSelect").innerHTML = state.sorts .map((sort) => ``) .join(""); } async function init() { qs("#adminRoleBadge").textContent = CarPassPage.state.user?.platform_role || "admin"; qs("#adminMeta").textContent = `User #${CarPassPage.state.user?.id || "-"} · Telegram ${CarPassPage.state.user?.telegram_id || "-"}`; await initSources(); bindTabButtons(); bindForms(); const urlSection = new URLSearchParams(window.location.search).get("section"); setActive(urlSection || "dashboard"); await loadActiveSection(); } return { init }; })(); CarPassPage.boot(AdminPage.init);