372 lines
14 KiB
JavaScript
372 lines
14 KiB
JavaScript
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);
|