Files
drivers_bot/web/static/admin.js
VPN SaaS Dev 8982299e71
Some checks failed
ci / test (pull_request) Has been cancelled
add admin data mutations and load check
2026-05-18 18:37:19 +09:00

480 lines
19 KiB
JavaScript
Raw Permalink 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: [],
sourcesByName: {},
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 sourceConfig(source) {
return source ? state.sourcesByName[source] : null;
}
function editableValues(row, source) {
const config = sourceConfig(source);
const fields = config?.editable || [];
return fields.reduce((payload, field) => {
if (Object.prototype.hasOwnProperty.call(row, field)) payload[field] = row[field];
return payload;
}, {});
}
function renderSourceHint(source) {
const hint = qs("#sourceHint");
if (!hint) return;
const config = sourceConfig(source);
if (!config) {
hint.textContent = "";
return;
}
const editable = config.editable?.length ? config.editable.join(", ") : "нет";
const deleteMode = config.deletable ? config.delete_mode : "нет";
hint.textContent = `Редактируемые поля: ${editable}. Удаление: ${deleteMode}. Все изменения требуют reason и пишутся в Audit Log.`;
}
async function mutateRow(action, source, row) {
const config = sourceConfig(source);
if (!config || !row?.id) return;
if (action === "edit") {
const draft = editableValues(row, source);
const raw = window.prompt("JSON с изменяемыми полями", JSON.stringify(draft, null, 2));
if (!raw) return;
let values;
try {
values = JSON.parse(raw);
} catch {
toast("Некорректный JSON", "error");
return;
}
const reason = window.prompt("Причина изменения") || "";
if (!reason.trim()) return;
await api(`/admin/data/${source}/${row.id}`, {
method: "PATCH",
body: JSON.stringify({ values, reason }),
});
toast("Запись обновлена");
} else {
const reason = window.prompt(`Причина удаления ${source} #${row.id}`) || "";
if (!reason.trim()) return;
if (!window.confirm(`Удалить ${source} #${row.id}?`)) return;
await api(`/admin/data/${source}/${row.id}`, {
method: "DELETE",
body: JSON.stringify({ reason }),
});
toast(config.delete_mode === "hard" ? "Запись удалена" : "Запись скрыта/отключена");
}
await loadActiveSection();
if (state.active === "data" && state.lastDataPayload) {
await submitDataQuery();
}
}
function bindTableActions(root, source, rows) {
root.querySelectorAll("[data-admin-row-action]").forEach((button) => {
button.addEventListener("click", async () => {
const row = rows[Number(button.dataset.rowIndex)];
try {
await mutateRow(button.dataset.adminRowAction, source, row);
} catch (error) {
toast(error.message || "Ошибка", "error");
}
});
});
}
function renderTable(root, rows, preferredColumns = [], source = null) {
if (!rows?.length) {
renderEmpty(root);
return;
}
const columns = preferredColumns.length ? preferredColumns : Object.keys(rows[0]);
const config = sourceConfig(source);
const hasActions = Boolean(config && rows.some((row) => row.id) && (config.editable?.length || config.deletable));
root.innerHTML = `
<table class="admin-table">
<thead>
<tr>
${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}
${hasActions ? "<th class=\"admin-actions-head\">Действия</th>" : ""}
</tr>
</thead>
<tbody>
${rows
.map(
(row) => `
<tr>
${columns.map((column) => `<td>${valueOrDash(row[column])}</td>`).join("")}
${
hasActions
? `<td class="admin-action-cell">
${
config.editable?.length
? `<button type="button" class="ghost-btn compact-btn" data-admin-row-action="edit" data-row-index="${rows.indexOf(row)}">Edit</button>`
: ""
}
${
config.deletable
? `<button type="button" class="danger-btn compact-btn" data-admin-row-action="delete" data-row-index="${rows.indexOf(row)}">Delete</button>`
: ""
}
</td>`
: ""
}
</tr>
`,
)
.join("")}
</tbody>
</table>
`;
if (hasActions) bindTableActions(root, source, rows);
}
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"], "users");
}
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"], "sto_profiles");
}
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, source);
} 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;
renderSourceHint(payload.source);
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, [], payload.source);
}
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"], "audit_logs");
}
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"], "imports_exports");
}
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.sourcesByName = Object.fromEntries(state.sources.map((source) => [source.name, source]));
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("");
qs("#sourceSelect")?.addEventListener("change", (event) => renderSourceHint(event.target.value));
renderSourceHint(qs("#sourceSelect")?.value);
}
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);