(function () {
const { useCallback, useEffect, useMemo, useRef, useState } = React;
const LS_TOKEN = "admin_access_token";
const PAGE_SIZE = 50;
const DEFAULT_FORM_FIELD_TYPES = ["string", "text", "number", "boolean", "date"];
const ALL_OPERATORS = ["=", "!=", ">", "<", ">=", "<=", "~"];
const OPERATOR_LABELS = {
"=": "=",
"!=": "!=",
">": ">",
"<": "<",
">=": ">=",
"<=": "<=",
"~": "~",
};
const ROLE_LABELS = {
ADMIN: "Администратор",
LAWYER: "Юрист",
};
const STATUS_LABELS = {
NEW: "Новая",
IN_PROGRESS: "В работе",
WAITING_CLIENT: "Ожидание клиента",
WAITING_COURT: "Ожидание суда",
RESOLVED: "Решена",
CLOSED: "Закрыта",
REJECTED: "Отклонена",
};
const TABLE_SERVER_CONFIG = {
requests: {
endpoint: "/api/admin/requests/query",
sort: [{ field: "created_at", dir: "desc" }],
},
quotes: {
endpoint: "/api/admin/quotes/query",
sort: [{ field: "sort_order", dir: "asc" }],
},
topics: {
endpoint: "/api/admin/config/topics/query",
sort: [{ field: "sort_order", dir: "asc" }],
},
statuses: {
endpoint: "/api/admin/config/statuses/query",
sort: [{ field: "sort_order", dir: "asc" }],
},
formFields: {
endpoint: "/api/admin/config/form-fields/query",
sort: [{ field: "sort_order", dir: "asc" }],
},
};
function createTableState() {
return {
filters: [],
sort: null,
offset: 0,
total: 0,
showAll: false,
rows: [],
};
}
function decodeJwtPayload(token) {
try {
const payload = token.split(".")[1] || "";
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const json = decodeURIComponent(
atob(base64)
.split("")
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join("")
);
return JSON.parse(json);
} catch (_) {
return null;
}
}
function sortByName(items) {
return [...items].sort((a, b) => String(a.name || a.code || "").localeCompare(String(b.name || b.code || ""), "ru"));
}
function roleLabel(role) {
return ROLE_LABELS[role] || role || "-";
}
function statusLabel(code) {
return STATUS_LABELS[code] || code || "-";
}
function boolLabel(value) {
return value ? "Да" : "Нет";
}
function boolFilterLabel(value) {
return value ? "True" : "False";
}
function fmtDate(value) {
if (!value) return "-";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU");
}
function buildUniversalQuery(filters, sort, limit, offset) {
return {
filters: filters || [],
sort: sort || [],
page: { limit: limit ?? PAGE_SIZE, offset: offset ?? 0 },
};
}
function canAccessSection(role, section) {
if (section === "quotes" || section === "config") return role === "ADMIN";
return true;
}
function translateApiError(message) {
const direct = {
"Missing auth token": "Отсутствует токен авторизации",
"Missing bearer token": "Отсутствует токен авторизации",
"Invalid token": "Некорректный токен",
Forbidden: "Недостаточно прав",
"Invalid credentials": "Неверный логин или пароль",
"Request not found": "Заявка не найдена",
"Quote not found": "Цитата не найдена",
not_found: "Запись не найдена",
};
if (direct[message]) return direct[message];
if (String(message).startsWith("HTTP ")) return "Ошибка сервера (" + message + ")";
return message;
}
function getOperatorsForType(type) {
if (type === "number" || type === "date" || type === "datetime") return ["=", "!=", ">", "<", ">=", "<="];
if (type === "boolean" || type === "reference" || type === "enum") return ["=", "!="];
return [...ALL_OPERATORS];
}
function localizeRequestDetails(row) {
return {
ID: row.id || null,
"Номер заявки": row.track_number || null,
Клиент: row.client_name || null,
Телефон: row.client_phone || null,
"Тема (код)": row.topic_code || null,
Статус: statusLabel(row.status_code),
Описание: row.description || null,
"Дополнительные поля": row.extra_fields || {},
"Назначенный юрист (ID)": row.assigned_lawyer_id || null,
"Общий размер вложений (байт)": row.total_attachments_bytes ?? 0,
Создано: fmtDate(row.created_at),
Обновлено: fmtDate(row.updated_at),
};
}
function localizeMeta(data) {
const fieldTypeMap = {
string: "строка",
text: "текст",
boolean: "булево",
number: "число",
date: "дата",
};
return {
Сущность: data.entity,
Поля: (data.fields || []).map((field) => ({
"Код поля": field.field_name,
Название: field.label,
Тип: fieldTypeMap[field.type] || field.type,
Обязательное: boolLabel(field.required),
"Только чтение": boolLabel(field.read_only),
"Редактируемые роли": (field.editable_roles || []).map(roleLabel),
})),
};
}
function StatusLine({ status }) {
return
{status?.message || ""}
;
}
function Section({ active, children, id }) {
return (
);
}
function DataTable({ headers, rows, emptyColspan, renderRow, onSort, sortClause }) {
return (
{headers.map((header) => {
const h = typeof header === "string" ? { key: header, label: header } : header;
const sortable = Boolean(h.sortable && h.field && onSort);
const active = Boolean(sortable && sortClause && sortClause.field === h.field);
const direction = active ? sortClause.dir : "";
return (
| onSort(h.field) : undefined}
title={sortable ? "Нажмите для сортировки" : undefined}
>
{h.label}
{sortable ? {direction === "desc" ? "↓" : "↑"} : null}
|
);
})}
{rows.length ? (
rows.map((row, index) => renderRow(row, index))
) : (
| Нет данных |
)}
);
}
function TablePager({ tableState, onPrev, onNext, onLoadAll }) {
return (
{tableState.showAll
? "Всего: " + tableState.total + " • показаны все записи"
: "Всего: " + tableState.total + " • смещение: " + tableState.offset}
);
}
function FilterToolbar({ filters, onOpen, onRemove, onEdit, getChipLabel }) {
return (
{filters.length ? (
filters.map((filter, index) => (
onEdit(index)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onEdit(index);
}
}}
title="Редактировать фильтр"
>
{getChipLabel(filter)}
))
) : (
Фильтры не заданы
)}
);
}
function Overlay({ open, onClose, children, id }) {
return (
{children}
);
}
function IconButton({ icon, tooltip, onClick, tone }) {
return (
);
}
function LoginScreen({ onSubmit, status }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const submit = (event) => {
event.preventDefault();
onSubmit(email, password);
};
return (
Вход в админ-панель
Используйте учетную запись администратора или юриста.
);
}
function FilterModal({
open,
tableLabel,
fields,
draft,
status,
onClose,
onFieldChange,
onOpChange,
onValueChange,
onSubmit,
onClear,
getOperators,
getFieldOptions,
}) {
if (!open) return null;
const selectedField = fields.find((field) => field.field === draft.field) || fields[0] || null;
const operators = getOperators(selectedField?.type || "text");
const options = selectedField ? getFieldOptions(selectedField) : [];
return (
event.target.id === "filter-overlay" && onClose()}>
event.stopPropagation()}>
Фильтр таблицы
{tableLabel
? (draft.editIndex !== null ? "Редактирование фильтра • " : "Новый фильтр • ") + "Таблица: " + tableLabel
: "Выберите поле, оператор и значение."}
);
}
function QuoteModal({ open, editing, form, status, onClose, onChange, onSubmit }) {
if (!open) return null;
return (
event.target.id === "quote-overlay" && onClose()}>
event.stopPropagation()}>
{editing ? "Редактирование цитаты" : "Новая цитата"}
Создание и редактирование цитат.
);
}
function RequestModal({ open, jsonText, onClose }) {
if (!open) return null;
return (
event.target.id === "request-overlay" && onClose()}>
event.stopPropagation()}>
Детали заявки
Подробная карточка заявки.
{jsonText}
);
}
function App() {
const [token, setToken] = useState("");
const [role, setRole] = useState("");
const [email, setEmail] = useState("");
const [activeSection, setActiveSection] = useState("dashboard");
const [dashboardData, setDashboardData] = useState({ cards: [], byStatus: {} });
const [tables, setTables] = useState({
requests: createTableState(),
quotes: createTableState(),
topics: createTableState(),
statuses: createTableState(),
formFields: createTableState(),
});
const [dictionaries, setDictionaries] = useState({
topics: [],
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })),
formFieldTypes: [...DEFAULT_FORM_FIELD_TYPES],
});
const [statusMap, setStatusMap] = useState({});
const [requestModal, setRequestModal] = useState({ open: false, jsonText: "" });
const [quoteModalOpen, setQuoteModalOpen] = useState(false);
const [editingQuoteId, setEditingQuoteId] = useState(null);
const [quoteForm, setQuoteForm] = useState({
author: "",
text: "",
source: "",
sort_order: 0,
is_active: true,
});
const [configActiveKey, setConfigActiveKey] = useState("topics");
const [metaEntity, setMetaEntity] = useState("quotes");
const [metaJson, setMetaJson] = useState("");
const [filterModal, setFilterModal] = useState({
open: false,
tableKey: null,
field: "",
op: "=",
rawValue: "",
editIndex: null,
});
const tablesRef = useRef(tables);
useEffect(() => {
tablesRef.current = tables;
}, [tables]);
const setStatus = useCallback((key, message, kind) => {
setStatusMap((prev) => ({ ...prev, [key]: { message: message || "", kind: kind || "" } }));
}, []);
const getStatus = useCallback((key) => statusMap[key] || { message: "", kind: "" }, [statusMap]);
const api = useCallback(
async (path, options, tokenOverride) => {
const opts = options || {};
const authToken = tokenOverride !== undefined ? tokenOverride : token;
const headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
if (opts.auth !== false) {
if (!authToken) throw new Error("Отсутствует токен авторизации");
headers.Authorization = "Bearer " + authToken;
}
const response = await fetch(path, {
method: opts.method || "GET",
headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
const text = await response.text();
let payload;
try {
payload = text ? JSON.parse(text) : {};
} catch (_) {
payload = { raw: text };
}
if (!response.ok) {
const message = (payload && (payload.detail || payload.error || payload.raw)) || "HTTP " + response.status;
throw new Error(translateApiError(String(message)));
}
return payload;
},
[token]
);
const getStatusOptions = useCallback(() => {
return (dictionaries.statuses || [])
.filter((item) => item && item.code)
.map((item) => ({ value: item.code, label: (item.name || statusLabel(item.code)) + " (" + item.code + ")" }));
}, [dictionaries.statuses]);
const getTopicOptions = useCallback(() => {
return (dictionaries.topics || [])
.filter((item) => item && item.code)
.map((item) => ({ value: item.code, label: (item.name || item.code) + " (" + item.code + ")" }));
}, [dictionaries.topics]);
const getFormFieldTypeOptions = useCallback(() => {
return (dictionaries.formFieldTypes || []).filter(Boolean).map((item) => ({ value: item, label: item }));
}, [dictionaries.formFieldTypes]);
const getFilterFields = useCallback(
(tableKey) => {
if (tableKey === "requests") {
return [
{ field: "track_number", label: "Номер заявки", type: "text" },
{ field: "client_name", label: "Клиент", type: "text" },
{ field: "client_phone", label: "Телефон", type: "text" },
{ field: "status_code", label: "Статус", type: "reference", options: getStatusOptions },
{ field: "topic_code", label: "Тема", type: "reference", options: getTopicOptions },
{ field: "created_at", label: "Дата создания", type: "date" },
];
}
if (tableKey === "quotes") {
return [
{ field: "author", label: "Автор", type: "text" },
{ field: "text", label: "Текст", type: "text" },
{ field: "source", label: "Источник", type: "text" },
{ field: "is_active", label: "Активна", type: "boolean" },
{ field: "sort_order", label: "Порядок", type: "number" },
{ field: "created_at", label: "Дата создания", type: "date" },
];
}
if (tableKey === "topics") {
return [
{ field: "code", label: "Код", type: "text" },
{ field: "name", label: "Название", type: "text" },
{ field: "enabled", label: "Активна", type: "boolean" },
{ field: "sort_order", label: "Порядок", type: "number" },
];
}
if (tableKey === "statuses") {
return [
{ field: "code", label: "Код", type: "text" },
{ field: "name", label: "Название", type: "text" },
{ field: "enabled", label: "Активен", type: "boolean" },
{ field: "sort_order", label: "Порядок", type: "number" },
{ field: "is_terminal", label: "Терминальный", type: "boolean" },
];
}
if (tableKey === "formFields") {
return [
{ field: "key", label: "Ключ", type: "text" },
{ field: "label", label: "Метка", type: "text" },
{ field: "type", label: "Тип", type: "enum", options: getFormFieldTypeOptions },
{ field: "required", label: "Обязательное", type: "boolean" },
{ field: "enabled", label: "Активно", type: "boolean" },
{ field: "sort_order", label: "Порядок", type: "number" },
];
}
return [];
},
[getFormFieldTypeOptions, getStatusOptions, getTopicOptions]
);
const getFieldDef = useCallback(
(tableKey, fieldName) => {
return getFilterFields(tableKey).find((field) => field.field === fieldName) || null;
},
[getFilterFields]
);
const getFieldOptions = useCallback((fieldDef) => {
if (!fieldDef) return [];
if (typeof fieldDef.options === "function") return fieldDef.options() || [];
return [];
}, []);
const getFilterValuePreview = useCallback(
(tableKey, clause) => {
const fieldDef = getFieldDef(tableKey, clause.field);
if (!fieldDef) return String(clause.value ?? "");
if (fieldDef.type === "boolean") return boolFilterLabel(Boolean(clause.value));
if (fieldDef.type === "reference" || fieldDef.type === "enum") {
const options = getFieldOptions(fieldDef);
const found = options.find((option) => String(option.value) === String(clause.value));
return found ? found.label : String(clause.value ?? "");
}
return String(clause.value ?? "");
},
[getFieldDef, getFieldOptions]
);
const setTableState = useCallback((tableKey, next) => {
setTables((prev) => ({ ...prev, [tableKey]: next }));
}, []);
const loadTable = useCallback(
async (tableKey, options, tokenOverride) => {
const opts = options || {};
const config = TABLE_SERVER_CONFIG[tableKey];
if (!config) return false;
const current = tablesRef.current[tableKey] || createTableState();
const next = {
...current,
filters: Array.isArray(opts.filtersOverride) ? [...opts.filtersOverride] : [...(current.filters || [])],
sort: Array.isArray(opts.sortOverride) ? [...opts.sortOverride] : Array.isArray(current.sort) ? [...current.sort] : null,
rows: [...(current.rows || [])],
};
if (opts.resetOffset) {
next.offset = 0;
next.showAll = false;
}
if (opts.loadAll) {
next.offset = 0;
next.showAll = true;
}
const statusKey = tableKey;
setStatus(statusKey, "Загрузка...", "");
try {
const activeSort = next.sort && next.sort.length ? next.sort : config.sort;
let limit = next.showAll ? Math.max(next.total || PAGE_SIZE, PAGE_SIZE) : PAGE_SIZE;
const offset = next.showAll ? 0 : next.offset;
let data = await api(
config.endpoint,
{
method: "POST",
body: buildUniversalQuery(next.filters, activeSort, limit, offset),
},
tokenOverride
);
next.total = Number(data.total || 0);
next.rows = data.rows || [];
if (next.showAll && next.total > next.rows.length) {
limit = next.total;
data = await api(
config.endpoint,
{
method: "POST",
body: buildUniversalQuery(next.filters, activeSort, limit, 0),
},
tokenOverride
);
next.total = Number(data.total || next.total);
next.rows = data.rows || [];
}
if (!next.showAll && next.total > 0 && next.offset >= next.total) {
next.offset = Math.floor((next.total - 1) / PAGE_SIZE) * PAGE_SIZE;
setTableState(tableKey, next);
return loadTable(tableKey, {}, tokenOverride);
}
setTableState(tableKey, next);
if (tableKey === "requests") {
setDictionaries((prev) => {
const map = new Map((prev.topics || []).map((topic) => [topic.code, topic]));
(next.rows || []).forEach((row) => {
if (!row.topic_code || map.has(row.topic_code)) return;
map.set(row.topic_code, { code: row.topic_code, name: row.topic_code });
});
return { ...prev, topics: sortByName(Array.from(map.values())) };
});
}
if (tableKey === "topics") {
setDictionaries((prev) => ({
...prev,
topics: sortByName((next.rows || []).map((row) => ({ code: row.code, name: row.name || row.code }))),
}));
}
if (tableKey === "statuses") {
setDictionaries((prev) => {
const map = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }]));
(next.rows || []).forEach((row) => {
if (!row.code) return;
map.set(row.code, { code: row.code, name: row.name || statusLabel(row.code) });
});
return { ...prev, statuses: sortByName(Array.from(map.values())) };
});
}
if (tableKey === "formFields") {
setDictionaries((prev) => {
const set = new Set(DEFAULT_FORM_FIELD_TYPES);
(next.rows || []).forEach((row) => {
if (row?.type) set.add(row.type);
});
return {
...prev,
formFieldTypes: Array.from(set.values()).sort((a, b) => String(a).localeCompare(String(b), "ru")),
};
});
}
setStatus(statusKey, "Список обновлен", "ok");
return true;
} catch (error) {
setStatus(statusKey, "Ошибка: " + error.message, "error");
return false;
}
},
[api, setStatus, setTableState]
);
const loadCurrentConfigTable = useCallback(
async (resetOffset, tokenOverride, keyOverride) => {
const currentKey = keyOverride || configActiveKey;
setStatus("config", "Загрузка...", "");
const ok = await loadTable(currentKey, { resetOffset: Boolean(resetOffset) }, tokenOverride);
if (ok) {
setStatus("config", "Справочник обновлен", "ok");
} else {
setStatus("config", "Не удалось обновить справочник", "error");
}
},
[configActiveKey, loadTable, setStatus]
);
const loadDashboard = useCallback(
async (tokenOverride) => {
setStatus("dashboard", "Загрузка...", "");
try {
const data = await api("/api/admin/metrics/overview", {}, tokenOverride);
const cards = [
{ label: "Новые", value: data.new ?? 0 },
{ label: "Просрочено SLA", value: data.sla_overdue ?? 0 },
{ label: "Средний FRT (мин)", value: data.frt_avg_minutes ?? "-" },
{ label: "Групп по статусам", value: Object.keys(data.by_status || {}).length },
];
const localized = {};
Object.entries(data.by_status || {}).forEach(([code, count]) => {
localized[statusLabel(code)] = count;
});
setDashboardData({ cards, byStatus: localized });
setStatus("dashboard", "Данные обновлены", "ok");
} catch (error) {
setStatus("dashboard", "Ошибка: " + error.message, "error");
}
},
[api, setStatus]
);
const loadMeta = useCallback(
async (tokenOverride) => {
const entity = (metaEntity || "quotes").trim() || "quotes";
setStatus("meta", "Загрузка...", "");
try {
const data = await api("/api/admin/meta/" + encodeURIComponent(entity), {}, tokenOverride);
setMetaJson(JSON.stringify(localizeMeta(data), null, 2));
setStatus("meta", "Метаданные получены", "ok");
} catch (error) {
setStatus("meta", "Ошибка: " + error.message, "error");
}
},
[api, metaEntity, setStatus]
);
const refreshSection = useCallback(
async (section, tokenOverride) => {
if (!(tokenOverride !== undefined ? tokenOverride : token)) return;
if (section === "dashboard") return loadDashboard(tokenOverride);
if (section === "requests") return loadTable("requests", {}, tokenOverride);
if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride);
if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride);
if (section === "meta") return loadMeta(tokenOverride);
},
[loadCurrentConfigTable, loadDashboard, loadMeta, loadTable, role, token]
);
const bootstrapReferenceData = useCallback(
async (tokenOverride, roleOverride) => {
setDictionaries((prev) => ({
...prev,
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })),
}));
if (roleOverride !== "ADMIN") return;
try {
const body = buildUniversalQuery([], [{ field: "sort_order", dir: "asc" }], 500, 0);
const [topicsData, statusesData, fieldsData] = await Promise.all([
api("/api/admin/config/topics/query", { method: "POST", body }, tokenOverride),
api("/api/admin/config/statuses/query", { method: "POST", body }, tokenOverride),
api("/api/admin/config/form-fields/query", { method: "POST", body }, tokenOverride),
]);
const statusesMap = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }]));
(statusesData.rows || []).forEach((row) => {
if (!row.code) return;
statusesMap.set(row.code, { code: row.code, name: row.name || statusLabel(row.code) });
});
const typeSet = new Set(DEFAULT_FORM_FIELD_TYPES);
(fieldsData.rows || []).forEach((row) => {
if (row?.type) typeSet.add(row.type);
});
setDictionaries((prev) => ({
...prev,
topics: sortByName((topicsData.rows || []).map((row) => ({ code: row.code, name: row.name || row.code }))),
statuses: sortByName(Array.from(statusesMap.values())),
formFieldTypes: Array.from(typeSet.values()).sort((a, b) => String(a).localeCompare(String(b), "ru")),
}));
} catch (_) {
// Keep defaults when dictionary endpoints are unavailable.
}
},
[api]
);
const openRequestDetails = useCallback(
async (requestId) => {
setRequestModal({ open: true, jsonText: "Загрузка..." });
try {
const row = await api("/api/admin/requests/" + requestId);
setRequestModal({ open: true, jsonText: JSON.stringify(localizeRequestDetails(row), null, 2) });
} catch (error) {
setRequestModal({ open: true, jsonText: "Ошибка: " + error.message });
}
},
[api]
);
const openQuoteCreate = useCallback(() => {
setEditingQuoteId(null);
setQuoteForm({ author: "", text: "", source: "", sort_order: 0, is_active: true });
setStatus("quoteForm", "", "");
setQuoteModalOpen(true);
}, [setStatus]);
const openQuoteEdit = useCallback(
(row) => {
setEditingQuoteId(row.id);
setQuoteForm({
author: row.author || "",
text: row.text || "",
source: row.source || "",
sort_order: row.sort_order ?? 0,
is_active: Boolean(row.is_active),
});
setStatus("quoteForm", "", "");
setQuoteModalOpen(true);
},
[setStatus]
);
const saveQuote = useCallback(
async (event) => {
event.preventDefault();
try {
setStatus("quoteForm", "Сохранение...", "");
const payload = {
author: String(quoteForm.author || "").trim(),
text: String(quoteForm.text || "").trim(),
source: String(quoteForm.source || "").trim() || null,
sort_order: Number(quoteForm.sort_order || 0),
is_active: Boolean(quoteForm.is_active),
};
if (!payload.author || !payload.text) throw new Error("Заполните автора и текст цитаты");
if (editingQuoteId) {
await api("/api/admin/quotes/" + editingQuoteId, { method: "PATCH", body: payload });
} else {
await api("/api/admin/quotes", { method: "POST", body: payload });
}
setStatus("quoteForm", "Сохранено", "ok");
await loadTable("quotes", { resetOffset: true });
setTimeout(() => setQuoteModalOpen(false), 300);
} catch (error) {
setStatus("quoteForm", "Ошибка: " + error.message, "error");
}
},
[api, editingQuoteId, loadTable, quoteForm, setStatus]
);
const removeQuote = useCallback(
async (id) => {
if (!confirm("Удалить цитату?")) return;
try {
await api("/api/admin/quotes/" + id, { method: "DELETE" });
setStatus("quotes", "Цитата удалена", "ok");
await loadTable("quotes", { resetOffset: true });
} catch (error) {
setStatus("quotes", "Ошибка удаления: " + error.message, "error");
}
},
[api, loadTable, setStatus]
);
const defaultFilterValue = useCallback(
(fieldDef) => {
if (!fieldDef) return "";
if (fieldDef.type === "boolean") return "true";
if (fieldDef.type === "reference" || fieldDef.type === "enum") {
const options = getFieldOptions(fieldDef);
return options.length ? String(options[0].value) : "";
}
return "";
},
[getFieldOptions]
);
const openFilterModal = useCallback(
(tableKey) => {
const fields = getFilterFields(tableKey);
if (!fields.length) {
setStatus("filter", "Для таблицы нет доступных полей фильтрации", "error");
return;
}
const firstField = fields[0];
const firstOp = getOperatorsForType(firstField.type)[0] || "=";
setFilterModal({
open: true,
tableKey,
field: firstField.field,
op: firstOp,
rawValue: defaultFilterValue(firstField),
editIndex: null,
});
setStatus("filter", "", "");
},
[defaultFilterValue, getFilterFields, setStatus]
);
const openFilterEditModal = useCallback(
(tableKey, index) => {
const tableState = tablesRef.current[tableKey] || createTableState();
const target = (tableState.filters || [])[index];
if (!target) return;
const fieldDef = getFieldDef(tableKey, target.field);
if (!fieldDef) return;
const allowedOps = getOperatorsForType(fieldDef.type);
const safeOp = allowedOps.includes(target.op) ? target.op : allowedOps[0] || "=";
const rawValue = fieldDef.type === "boolean" ? (target.value ? "true" : "false") : String(target.value ?? "");
setFilterModal({
open: true,
tableKey,
field: fieldDef.field,
op: safeOp,
rawValue,
editIndex: index,
});
setStatus("filter", "", "");
},
[getFieldDef, setStatus]
);
const closeFilterModal = useCallback(() => {
setFilterModal((prev) => ({ ...prev, open: false, editIndex: null }));
setStatus("filter", "", "");
}, [setStatus]);
const updateFilterField = useCallback(
(event) => {
const fieldName = event.target.value;
const fields = getFilterFields(filterModal.tableKey);
const fieldDef = fields.find((field) => field.field === fieldName) || null;
if (!fieldDef) return;
const defaultOp = getOperatorsForType(fieldDef.type)[0] || "=";
setFilterModal((prev) => ({
...prev,
field: fieldName,
op: defaultOp,
rawValue: defaultFilterValue(fieldDef),
}));
},
[defaultFilterValue, filterModal.tableKey, getFilterFields]
);
const updateFilterOp = useCallback((event) => {
const op = event.target.value;
setFilterModal((prev) => ({ ...prev, op }));
}, []);
const updateFilterValue = useCallback((event) => {
setFilterModal((prev) => ({ ...prev, rawValue: event.target.value }));
}, []);
const applyFilterModal = useCallback(
async (event) => {
event.preventDefault();
if (!filterModal.tableKey) return;
const fieldDef = getFieldDef(filterModal.tableKey, filterModal.field);
if (!fieldDef) {
setStatus("filter", "Поле фильтра не выбрано", "error");
return;
}
let value;
if (fieldDef.type === "boolean") {
value = filterModal.rawValue === "true";
} else if (fieldDef.type === "number") {
if (String(filterModal.rawValue || "").trim() === "") {
setStatus("filter", "Введите число", "error");
return;
}
value = Number(filterModal.rawValue);
if (Number.isNaN(value)) {
setStatus("filter", "Некорректное число", "error");
return;
}
} else {
value = String(filterModal.rawValue || "").trim();
if (!value) {
setStatus("filter", "Введите значение фильтра", "error");
return;
}
}
const tableState = tablesRef.current[filterModal.tableKey] || createTableState();
const nextFilters = [...(tableState.filters || [])];
const nextClause = { field: fieldDef.field, op: filterModal.op, value };
if (Number.isInteger(filterModal.editIndex) && filterModal.editIndex >= 0 && filterModal.editIndex < nextFilters.length) {
nextFilters[filterModal.editIndex] = nextClause;
} else {
const existingIndex = nextFilters.findIndex((item) => item.field === nextClause.field && item.op === nextClause.op);
if (existingIndex >= 0) nextFilters[existingIndex] = nextClause;
else nextFilters.push(nextClause);
}
setTableState(filterModal.tableKey, {
...tableState,
filters: nextFilters,
offset: 0,
showAll: false,
});
closeFilterModal();
await loadTable(filterModal.tableKey, { resetOffset: true, filtersOverride: nextFilters });
},
[closeFilterModal, filterModal, getFieldDef, loadTable, setStatus, setTableState]
);
const clearFiltersFromModal = useCallback(async () => {
if (!filterModal.tableKey) return;
const tableState = tablesRef.current[filterModal.tableKey] || createTableState();
setTableState(filterModal.tableKey, {
...tableState,
filters: [],
offset: 0,
showAll: false,
});
closeFilterModal();
await loadTable(filterModal.tableKey, { resetOffset: true, filtersOverride: [] });
}, [closeFilterModal, filterModal.tableKey, loadTable, setTableState]);
const removeFilterChip = useCallback(
async (tableKey, index) => {
const tableState = tablesRef.current[tableKey] || createTableState();
const nextFilters = [...(tableState.filters || [])];
nextFilters.splice(index, 1);
setTableState(tableKey, {
...tableState,
filters: nextFilters,
offset: 0,
showAll: false,
});
await loadTable(tableKey, { resetOffset: true, filtersOverride: nextFilters });
},
[loadTable, setTableState]
);
const loadPrevPage = useCallback(
(tableKey) => {
const tableState = tablesRef.current[tableKey] || createTableState();
const next = { ...tableState, offset: Math.max(0, tableState.offset - PAGE_SIZE), showAll: false };
setTableState(tableKey, next);
loadTable(tableKey, {});
},
[loadTable, setTableState]
);
const loadNextPage = useCallback(
(tableKey) => {
const tableState = tablesRef.current[tableKey] || createTableState();
if (tableState.offset + PAGE_SIZE >= tableState.total) return;
const next = { ...tableState, offset: tableState.offset + PAGE_SIZE, showAll: false };
setTableState(tableKey, next);
loadTable(tableKey, {});
},
[loadTable, setTableState]
);
const loadAllRows = useCallback(
(tableKey) => {
const tableState = tablesRef.current[tableKey] || createTableState();
if (!tableState.total) return;
const next = { ...tableState, offset: 0, showAll: true };
setTableState(tableKey, next);
loadTable(tableKey, { loadAll: true });
},
[loadTable, setTableState]
);
const toggleTableSort = useCallback(
(tableKey, field) => {
const tableState = tablesRef.current[tableKey] || createTableState();
const currentSort = Array.isArray(tableState.sort) ? tableState.sort[0] : null;
const dir = currentSort && currentSort.field === field ? (currentSort.dir === "asc" ? "desc" : "asc") : "asc";
const sortOverride = [{ field, dir }];
const next = { ...tableState, sort: sortOverride, offset: 0, showAll: false };
setTableState(tableKey, next);
loadTable(tableKey, { resetOffset: true, sortOverride });
},
[loadTable, setTableState]
);
const selectConfigNode = useCallback(
(tableKey) => {
setConfigActiveKey(tableKey);
if (activeSection === "config") {
loadCurrentConfigTable(false, undefined, tableKey);
}
},
[activeSection, loadCurrentConfigTable]
);
const refreshAll = useCallback(() => {
refreshSection(activeSection);
}, [activeSection, refreshSection]);
const activateSection = useCallback(
(section) => {
const nextSection = canAccessSection(role, section) ? section : "dashboard";
setActiveSection(nextSection);
refreshSection(nextSection);
},
[refreshSection, role]
);
const logout = useCallback(() => {
localStorage.removeItem(LS_TOKEN);
setToken("");
setRole("");
setEmail("");
setEditingQuoteId(null);
setQuoteModalOpen(false);
setRequestModal({ open: false, jsonText: "" });
setFilterModal({ open: false, tableKey: null, field: "", op: "=", rawValue: "", editIndex: null });
setDashboardData({ cards: [], byStatus: {} });
setMetaJson("");
setConfigActiveKey("topics");
setTables({
requests: createTableState(),
quotes: createTableState(),
topics: createTableState(),
statuses: createTableState(),
formFields: createTableState(),
});
setDictionaries({
topics: [],
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })),
formFieldTypes: [...DEFAULT_FORM_FIELD_TYPES],
});
setStatusMap({});
setActiveSection("dashboard");
}, []);
const login = useCallback(
async (emailInput, passwordInput) => {
try {
setStatus("login", "Выполняем вход...", "");
const data = await api(
"/api/admin/auth/login",
{
method: "POST",
auth: false,
body: { email: String(emailInput || "").trim(), password: passwordInput || "" },
},
""
);
const nextToken = data.access_token;
const payload = decodeJwtPayload(nextToken || "");
if (!payload || !payload.role || !payload.email) throw new Error("Не удалось прочитать данные токена");
localStorage.setItem(LS_TOKEN, nextToken);
setToken(nextToken);
setRole(payload.role);
setEmail(payload.email);
await bootstrapReferenceData(nextToken, payload.role);
setActiveSection("dashboard");
await loadDashboard(nextToken);
setStatus("login", "Успешный вход", "ok");
} catch (error) {
setStatus("login", "Ошибка входа: " + error.message, "error");
}
},
[api, bootstrapReferenceData, loadDashboard, setStatus]
);
useEffect(() => {
const saved = localStorage.getItem(LS_TOKEN) || "";
if (!saved) return;
const payload = decodeJwtPayload(saved);
if (!payload || !payload.role || !payload.email) {
localStorage.removeItem(LS_TOKEN);
return;
}
setToken(saved);
setRole(payload.role);
setEmail(payload.email);
}, []);
useEffect(() => {
if (!token || !role) return;
let cancelled = false;
(async () => {
await bootstrapReferenceData(token, role);
if (!cancelled) await refreshSection(activeSection, token);
})();
return () => {
cancelled = true;
};
}, [bootstrapReferenceData, refreshSection, role, token]);
const anyOverlayOpen = requestModal.open || quoteModalOpen || filterModal.open;
useEffect(() => {
document.body.classList.toggle("modal-open", anyOverlayOpen);
return () => document.body.classList.remove("modal-open");
}, [anyOverlayOpen]);
useEffect(() => {
const onEsc = (event) => {
if (event.key !== "Escape") return;
setRequestModal((prev) => ({ ...prev, open: false }));
setQuoteModalOpen(false);
setFilterModal((prev) => ({ ...prev, open: false }));
};
document.addEventListener("keydown", onEsc);
return () => document.removeEventListener("keydown", onEsc);
}, []);
const menuItems = useMemo(() => {
return [
{ key: "dashboard", label: "Обзор", visible: true },
{ key: "requests", label: "Заявки", visible: true },
{ key: "quotes", label: "Цитаты", visible: role === "ADMIN" },
{ key: "config", label: "Справочники", visible: role === "ADMIN" },
{ key: "meta", label: "Метаданные", visible: true },
].filter((item) => item.visible);
}, [role]);
const activeFilterFields = useMemo(() => {
if (!filterModal.tableKey) return [];
return getFilterFields(filterModal.tableKey);
}, [filterModal.tableKey, getFilterFields]);
const filterTableLabel = useMemo(() => {
if (filterModal.tableKey === "requests") return "Заявки";
if (filterModal.tableKey === "quotes") return "Цитаты";
if (filterModal.tableKey === "topics") return "Темы";
if (filterModal.tableKey === "statuses") return "Статусы";
if (filterModal.tableKey === "formFields") return "Поля формы";
return "";
}, [filterModal.tableKey]);
return (
<>
Панель администратора
UniversalQuery, RBAC и аудит действий по ключевым сущностям системы.
роль: {roleLabel(role)}
Обзор метрик
Состояние заявок и SLA-мониторинг.
{dashboardData.cards.map((card) => (
{card.label}
{card.value}
))}
{JSON.stringify(dashboardData.byStatus || {}, null, 2)}
Заявки
Серверная фильтрация и просмотр клиентских заявок.
openFilterModal("requests")}
onRemove={(index) => removeFilterChip("requests", index)}
onEdit={(index) => openFilterEditModal("requests", index)}
getChipLabel={(clause) => {
const fieldDef = getFieldDef("requests", clause.field);
return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("requests", clause);
}}
/>
toggleTableSort("requests", field)}
sortClause={(tables.requests.sort && tables.requests.sort[0]) || TABLE_SERVER_CONFIG.requests.sort[0]}
renderRow={(row) => (
{row.track_number || "-"}
|
{row.client_name || "-"} |
{row.client_phone || "-"} |
{statusLabel(row.status_code)} |
{row.topic_code || "-"} |
{fmtDate(row.created_at)} |
openRequestDetails(row.id)} />
|
)}
/>
loadPrevPage("requests")}
onNext={() => loadNextPage("requests")}
onLoadAll={() => loadAllRows("requests")}
/>
Цитаты
Управление публичной лентой цитат с серверными фильтрами.
openFilterModal("quotes")}
onRemove={(index) => removeFilterChip("quotes", index)}
onEdit={(index) => openFilterEditModal("quotes", index)}
getChipLabel={(clause) => {
const fieldDef = getFieldDef("quotes", clause.field);
return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("quotes", clause);
}}
/>
toggleTableSort("quotes", field)}
sortClause={(tables.quotes.sort && tables.quotes.sort[0]) || TABLE_SERVER_CONFIG.quotes.sort[0]}
renderRow={(row) => (
| {row.author || "-"} |
{row.text || "-"} |
{row.source || "-"} |
{boolLabel(row.is_active)} |
{String(row.sort_order ?? 0)} |
{fmtDate(row.created_at)} |
openQuoteEdit(row)} />
removeQuote(row.id)} tone="danger" />
|
)}
/>
loadPrevPage("quotes")}
onNext={() => loadNextPage("quotes")}
onLoadAll={() => loadAllRows("quotes")}
/>
Справочники
Выберите справочник слева, таблица откроется справа.
Дерево справочников
{configActiveKey === "topics" ? "Темы" : configActiveKey === "statuses" ? "Статусы" : "Поля формы"}
openFilterModal(configActiveKey)}
onRemove={(index) => removeFilterChip(configActiveKey, index)}
onEdit={(index) => openFilterEditModal(configActiveKey, index)}
getChipLabel={(clause) => {
const fieldDef = getFieldDef(configActiveKey, clause.field);
return (
(fieldDef ? fieldDef.label : clause.field) +
" " +
OPERATOR_LABELS[clause.op] +
" " +
getFilterValuePreview(configActiveKey, clause)
);
}}
/>
{configActiveKey === "topics" ? (
toggleTableSort("topics", field)}
sortClause={(tables.topics.sort && tables.topics.sort[0]) || TABLE_SERVER_CONFIG.topics.sort[0]}
renderRow={(row) => (
{row.code || "-"}
|
{row.name || "-"} |
{boolLabel(row.enabled)} |
{String(row.sort_order ?? 0)} |
)}
/>
) : null}
{configActiveKey === "statuses" ? (
toggleTableSort("statuses", field)}
sortClause={(tables.statuses.sort && tables.statuses.sort[0]) || TABLE_SERVER_CONFIG.statuses.sort[0]}
renderRow={(row) => (
{row.code || "-"}
|
{row.name || "-"} |
{boolLabel(row.enabled)} |
{String(row.sort_order ?? 0)} |
{boolLabel(row.is_terminal)} |
)}
/>
) : null}
{configActiveKey === "formFields" ? (
toggleTableSort("formFields", field)}
sortClause={(tables.formFields.sort && tables.formFields.sort[0]) || TABLE_SERVER_CONFIG.formFields.sort[0]}
renderRow={(row) => (
{row.key || "-"}
|
{row.label || "-"} |
{row.type || "-"} |
{boolLabel(row.required)} |
{boolLabel(row.enabled)} |
{String(row.sort_order ?? 0)} |
)}
/>
) : null}
loadPrevPage(configActiveKey)}
onNext={() => loadNextPage(configActiveKey)}
onLoadAll={() => loadAllRows(configActiveKey)}
/>
setRequestModal((prev) => ({ ...prev, open: false }))} />
setQuoteModalOpen(false)}
onChange={(field, value) => setQuoteForm((prev) => ({ ...prev, [field]: value }))}
onSubmit={saveQuote}
/>
{!token || !role ? : null}
>
);
}
const root = ReactDOM.createRoot(document.getElementById("admin-root"));
root.render();
})();