Files
drivers_bot/web/static/admin.js
2026-05-17 21:16:28 +09:00

372 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = `<div class="tip-card">${escapeHtml(text)}</div>`;
}
function renderError(root, error) {
root.innerHTML = `<div class="tip-card error-state">${escapeHtml(error.message || "Ошибка")}</div>`;
}
function renderTable(root, rows, preferredColumns = []) {
if (!rows?.length) {
renderEmpty(root);
return;
}
const columns = preferredColumns.length ? preferredColumns : Object.keys(rows[0]);
root.innerHTML = `
<table class="admin-table">
<thead>
<tr>${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}</tr>
</thead>
<tbody>
${rows
.map(
(row) => `
<tr>
${columns.map((column) => `<td>${valueOrDash(row[column])}</td>`).join("")}
</tr>
`,
)
.join("")}
</tbody>
</table>
`;
}
function badge(value) {
return `<span class="admin-badge">${escapeHtml(value || "-")}</span>`;
}
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]) => `<div class="stat"><span>${label}</span><strong>${data[key] ?? 0}</strong></div>`)
.join("");
const alerts = qs("#dashboardAlerts");
alerts.innerHTML = data.latest_alerts?.length
? data.latest_alerts
.map(
(item) => `
<article class="stack-item">
<div>
<strong>${escapeHtml(item.title)}</strong>
<small>${badge(item.event_type)} ${formatDateTime(item.created_at)}</small>
</div>
</article>
`,
)
.join("")
: `<div class="tip-card">Критичных событий нет</div>`;
qs("#quickLinks").innerHTML = quickLinks
.map(([section, label]) => `<button type="button" data-admin-tab="${section}">${label}</button>`)
.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) => `
<article class="stack-item">
<div>
<strong>${escapeHtml(item.title)}</strong>
<small>${badge(item.event_type)} ${badge(item.severity)} ${badge(item.status)} ${formatDateTime(item.created_at)}</small>
<p>${escapeHtml(item.body || "")}</p>
</div>
<div class="row-actions">
<button type="button" data-read-notification="${item.id}">Read</button>
<button type="button" class="ghost-btn" data-dismiss-notification="${item.id}">Dismiss</button>
</div>
</article>
`,
)
.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) => `
<article class="stack-item">
<div>
<strong>${escapeHtml(item.display_name || item.legal_name || `СТО #${item.id}`)}</strong>
<small>${badge(item.verification_status)} ${escapeHtml(item.city || "-")} ${formatDateTime(item.created_at)}</small>
</div>
<div class="row-actions">
<button type="button" data-application-action="approve" data-application-id="${item.id}">Approve</button>
<button type="button" class="ghost-btn" data-application-action="request-changes" data-application-id="${item.id}">Правки</button>
<button type="button" class="danger-btn" data-application-action="reject" data-application-id="${item.id}">Reject</button>
</div>
</article>
`,
)
.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();
});
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) => `<option value="${source.name}">${source.name}</option>`)
.join("");
qs("#sortSelect").innerHTML = state.sorts
.map((sort) => `<option value="${sort}">${sort}</option>`)
.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);