add admin data mutations and load check
Some checks failed
ci / test (pull_request) Has been cancelled
Some checks failed
ci / test (pull_request) Has been cancelled
This commit is contained in:
@@ -3,6 +3,7 @@ const AdminPage = (() => {
|
||||
const state = {
|
||||
active: "dashboard",
|
||||
sources: [],
|
||||
sourcesByName: {},
|
||||
sorts: [],
|
||||
lastDataPayload: null,
|
||||
};
|
||||
@@ -61,16 +62,97 @@ const AdminPage = (() => {
|
||||
root.innerHTML = `<div class="tip-card error-state">${escapeHtml(error.message || "Ошибка")}</div>`;
|
||||
}
|
||||
|
||||
function renderTable(root, rows, preferredColumns = []) {
|
||||
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("")}</tr>
|
||||
<tr>
|
||||
${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}
|
||||
${hasActions ? "<th class=\"admin-actions-head\">Действия</th>" : ""}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows
|
||||
@@ -78,6 +160,22 @@ const AdminPage = (() => {
|
||||
(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>
|
||||
`,
|
||||
)
|
||||
@@ -85,6 +183,7 @@ const AdminPage = (() => {
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
if (hasActions) bindTableActions(root, source, rows);
|
||||
}
|
||||
|
||||
function badge(value) {
|
||||
@@ -174,7 +273,7 @@ const AdminPage = (() => {
|
||||
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"]);
|
||||
renderTable(qs("#usersTable"), data.rows, ["id", "telegram_id", "username", "first_name", "platform_role", "created_at"], "users");
|
||||
}
|
||||
|
||||
async function loadSto(filters = {}) {
|
||||
@@ -183,7 +282,7 @@ const AdminPage = (() => {
|
||||
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"]);
|
||||
renderTable(qs("#stoTable"), data.rows, ["id", "display_name", "city", "phone", "verification_status", "owner_user_id", "created_at"], "sto_profiles");
|
||||
}
|
||||
|
||||
async function loadApplications() {
|
||||
@@ -233,7 +332,7 @@ const AdminPage = (() => {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source, limit: 100 }),
|
||||
});
|
||||
renderTable(root, data.rows, columns);
|
||||
renderTable(root, data.rows, columns, source);
|
||||
} catch (error) {
|
||||
renderError(root, error);
|
||||
}
|
||||
@@ -258,6 +357,7 @@ const AdminPage = (() => {
|
||||
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",
|
||||
@@ -271,7 +371,7 @@ const AdminPage = (() => {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
renderTable(qs("#dataResult"), data.rows);
|
||||
renderTable(qs("#dataResult"), data.rows, [], payload.source);
|
||||
}
|
||||
|
||||
async function loadAudit(params = {}) {
|
||||
@@ -280,12 +380,12 @@ const AdminPage = (() => {
|
||||
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"]);
|
||||
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"]);
|
||||
renderTable(qs("#exportsTable"), data.rows, ["id", "source", "export_format", "status", "row_count", "reason", "expires_at", "created_at"], "imports_exports");
|
||||
}
|
||||
|
||||
async function loadActiveSection() {
|
||||
@@ -349,6 +449,7 @@ const AdminPage = (() => {
|
||||
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)
|
||||
@@ -357,6 +458,8 @@ const AdminPage = (() => {
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user