(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 INVOICE_STATUS_LABELS = { WAITING_PAYMENT: "Ожидает оплату", PAID: "Оплачен", CANCELED: "Отменен", }; const STATUS_KIND_LABELS = { DEFAULT: "Обычный", INVOICE: "Выставление счета", PAID: "Оплачено", }; const REQUEST_UPDATE_EVENT_LABELS = { MESSAGE: "сообщение", ATTACHMENT: "файл", STATUS: "статус", }; const KANBAN_GROUPS = [ { key: "NEW", label: "Новые" }, { key: "IN_PROGRESS", label: "В работе" }, { key: "WAITING", label: "Ожидание" }, { key: "DONE", label: "Завершены" }, ]; const TABLE_SERVER_CONFIG = { requests: { table: "requests", endpoint: "/api/admin/crud/requests/query", sort: [{ field: "created_at", dir: "desc" }], }, invoices: { table: "invoices", endpoint: "/api/admin/invoices/query", sort: [{ field: "issued_at", dir: "desc" }], }, quotes: { table: "quotes", endpoint: "/api/admin/crud/quotes/query", sort: [{ field: "sort_order", dir: "asc" }], }, topics: { table: "topics", endpoint: "/api/admin/crud/topics/query", sort: [{ field: "sort_order", dir: "asc" }], }, statuses: { table: "statuses", endpoint: "/api/admin/crud/statuses/query", sort: [{ field: "sort_order", dir: "asc" }], }, formFields: { table: "form_fields", endpoint: "/api/admin/crud/form_fields/query", sort: [{ field: "sort_order", dir: "asc" }], }, topicRequiredFields: { table: "topic_required_fields", endpoint: "/api/admin/crud/topic_required_fields/query", sort: [{ field: "sort_order", dir: "asc" }], }, topicDataTemplates: { table: "topic_data_templates", endpoint: "/api/admin/crud/topic_data_templates/query", sort: [{ field: "sort_order", dir: "asc" }], }, statusTransitions: { table: "topic_status_transitions", endpoint: "/api/admin/crud/topic_status_transitions/query", sort: [{ field: "sort_order", dir: "asc" }], }, users: { table: "admin_users", endpoint: "/api/admin/crud/admin_users/query", sort: [{ field: "created_at", dir: "desc" }], }, userTopics: { table: "admin_user_topics", endpoint: "/api/admin/crud/admin_user_topics/query", sort: [{ field: "created_at", dir: "desc" }], }, }; const TABLE_MUTATION_CONFIG = Object.fromEntries( Object.entries(TABLE_SERVER_CONFIG).map(([tableKey, config]) => [ tableKey, { create: "/api/admin/crud/" + config.table, update: (id) => "/api/admin/crud/" + config.table + "/" + id, delete: (id) => "/api/admin/crud/" + config.table + "/" + id, }, ]) ); TABLE_MUTATION_CONFIG.invoices = { create: "/api/admin/invoices", update: (id) => "/api/admin/invoices/" + id, delete: (id) => "/api/admin/invoices/" + id, }; const TABLE_KEY_ALIASES = { form_fields: "formFields", topic_required_fields: "topicRequiredFields", topic_data_templates: "topicDataTemplates", topic_status_transitions: "statusTransitions", admin_users: "users", admin_user_topics: "userTopics", }; const TABLE_UNALIASES = Object.fromEntries(Object.entries(TABLE_KEY_ALIASES).map(([table, alias]) => [alias, table])); const KNOWN_CONFIG_TABLE_KEYS = new Set([ "quotes", "topics", "statuses", "formFields", "topicRequiredFields", "topicDataTemplates", "statusTransitions", "users", "userTopics", ]); function createTableState() { return { filters: [], sort: null, offset: 0, total: 0, showAll: false, rows: [], }; } function createRequestModalState() { return { loading: false, requestId: null, trackNumber: "", requestData: null, statusRouteNodes: [], messages: [], attachments: [], messageDraft: "", selectedFiles: [], fileUploading: false, }; } function resolveAdminRoute(search) { const params = new URLSearchParams(String(search || "")); const section = String(params.get("section") || "").trim(); const view = String(params.get("view") || "").trim(); const requestId = String(params.get("requestId") || "").trim(); return { section, view, requestId }; } function humanizeKey(value) { const text = String(value || "") .replace(/[_-]+/g, " ") .replace(/\s+/g, " ") .trim(); if (!text) return "-"; return text.charAt(0).toUpperCase() + text.slice(1); } function metaKindToFilterType(kind) { if (kind === "boolean") return "boolean"; if (kind === "number") return "number"; if (kind === "date" || kind === "datetime") return "date"; return "text"; } function metaKindToRecordType(kind) { if (kind === "boolean") return "boolean"; if (kind === "number") return "number"; if (kind === "json") return "json"; return "text"; } 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 invoiceStatusLabel(code) { return INVOICE_STATUS_LABELS[code] || code || "-"; } function statusKindLabel(code) { return STATUS_KIND_LABELS[code] || code || "-"; } function fallbackStatusGroup(statusCode) { const code = String(statusCode || "").toUpperCase(); if (!code) return "NEW"; if (code.startsWith("NEW")) return "NEW"; if (code.includes("WAIT") || code.includes("PEND") || code.includes("HOLD")) return "WAITING"; if (code.includes("CLOSE") || code.includes("RESOLV") || code.includes("REJECT") || code.includes("DONE") || code.includes("PAID")) return "DONE"; return "IN_PROGRESS"; } 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 fmtDateOnly(value) { if (!value) return "-"; const date = new Date(value); return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" }); } function fmtTimeOnly(value) { if (!value) return "-"; const date = new Date(value); return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); } function fmtAmount(value) { if (value === null || value === undefined || value === "") return "-"; const numeric = Number(value); if (!Number.isFinite(numeric)) return String(value); return numeric.toLocaleString("ru-RU"); } function isPastDeadline(value) { if (!value) return false; const time = new Date(value).getTime(); if (!Number.isFinite(time)) return false; return time < Date.now(); } function fmtBytes(value) { const size = Number(value || 0); if (!Number.isFinite(size) || size <= 0) return "0 Б"; const units = ["Б", "КБ", "МБ", "ГБ"]; let index = 0; let normalized = size; while (normalized >= 1024 && index < units.length - 1) { normalized /= 1024; index += 1; } return normalized.toLocaleString("ru-RU", { maximumFractionDigits: index === 0 ? 0 : 1 }) + " " + units[index]; } function normalizeStringList(value) { if (!Array.isArray(value)) return []; const out = []; const seen = new Set(); value.forEach((item) => { const text = String(item || "").trim(); if (!text) return; const key = text.toLowerCase(); if (seen.has(key)) return; seen.add(key); out.push(text); }); return out; } function listPreview(value, emptyLabel) { const items = normalizeStringList(value); return items.length ? items.join(", ") : emptyLabel; } function userInitials(name, email) { const source = String(name || "").trim(); if (source) { const parts = source.split(/\s+/).filter(Boolean); if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); return source.slice(0, 2).toUpperCase(); } const mail = String(email || "").trim(); return (mail.slice(0, 2) || "U").toUpperCase(); } function avatarColor(seed) { const palette = ["#6f8fa9", "#568f7d", "#a07a5c", "#7d6ea9", "#8f6f8f", "#7f8c5a"]; const text = String(seed || ""); let hash = 0; for (let i = 0; i < text.length; i += 1) hash = (hash * 31 + text.charCodeAt(i)) >>> 0; return palette[hash % palette.length]; } function resolveAvatarSrc(avatarUrl, accessToken) { const raw = String(avatarUrl || "").trim(); if (!raw) return ""; if (raw.startsWith("s3://")) { const key = raw.slice("s3://".length); if (!key || !accessToken) return ""; return "/api/admin/uploads/object/" + encodeURIComponent(key) + "?token=" + encodeURIComponent(accessToken); } return raw; } function resolveAdminObjectSrc(s3Key, accessToken) { const key = String(s3Key || "").trim(); if (!key || !accessToken) return ""; return "/api/admin/uploads/object/" + encodeURIComponent(key) + "?token=" + encodeURIComponent(accessToken); } function detectAttachmentPreviewKind(fileName, mimeType) { const name = String(fileName || "").toLowerCase(); const mime = String(mimeType || "").toLowerCase(); if (mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name)) return "image"; if (mime.startsWith("video/") || /\.(mp4|webm|ogg|mov|m4v)$/.test(name)) return "video"; if (mime === "application/pdf" || /\.pdf$/.test(name)) return "pdf"; return "none"; } function buildUniversalQuery(filters, sort, limit, offset) { return { filters: filters || [], sort: sort || [], page: { limit: limit ?? PAGE_SIZE, offset: offset ?? 0 }, }; } function canAccessSection(role, section) { const allowed = new Set(["dashboard", "kanban", "requests", "requestWorkspace", "invoices", "meta", "quotes", "config", "availableTables"]); if (!allowed.has(section)) return false; if (section === "quotes" || section === "config" || section === "availableTables") 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.effective_rate ?? null, "Сумма счета": row.invoice_amount ?? null, "Оплачено": row.paid_at ? fmtDate(row.paid_at) : null, "Оплату подтвердил (ID)": row.paid_by_admin_id || null, "Непрочитано клиентом": boolLabel(Boolean(row.client_has_unread_updates)), "Тип обновления для клиента": row.client_unread_event_type ? (REQUEST_UPDATE_EVENT_LABELS[row.client_unread_event_type] || row.client_unread_event_type) : null, "Непрочитано юристом": boolLabel(Boolean(row.lawyer_has_unread_updates)), "Тип обновления для юриста": row.lawyer_unread_event_type ? (REQUEST_UPDATE_EVENT_LABELS[row.lawyer_unread_event_type] || row.lawyer_unread_event_type) : null, "Общий размер вложений (байт)": row.total_attachments_bytes ?? 0, Создано: fmtDate(row.created_at), Обновлено: fmtDate(row.updated_at), }; } function renderRequestUpdatesCell(row, role) { if (role === "LAWYER") { const has = Boolean(row.lawyer_has_unread_updates); const eventType = String(row.lawyer_unread_event_type || "").toUpperCase(); return has ? ( {REQUEST_UPDATE_EVENT_LABELS[eventType] || "обновление"} ) : ( нет ); } const clientHas = Boolean(row.client_has_unread_updates); const clientType = String(row.client_unread_event_type || "").toUpperCase(); const lawyerHas = Boolean(row.lawyer_has_unread_updates); const lawyerType = String(row.lawyer_unread_event_type || "").toUpperCase(); if (!clientHas && !lawyerHas) return нет; return ( {clientHas ? ( {"Клиент: " + (REQUEST_UPDATE_EVENT_LABELS[clientType] || "обновление")} ) : null} {lawyerHas ? ( {"Юрист: " + (REQUEST_UPDATE_EVENT_LABELS[lawyerType] || "обновление")} ) : null} ); } 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 (
{children}
); } 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 ( ); })} {rows.length ? ( rows.map((row, index) => renderRow(row, index)) ) : ( )}
onSort(h.field) : undefined} title={sortable ? "Нажмите для сортировки" : undefined} > {h.label} {sortable ? {direction === "desc" ? "↓" : "↑"} : null}
Нет данных
); } 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 }) { const handleClick = (event) => { event.preventDefault(); event.stopPropagation(); if (event.nativeEvent && typeof event.nativeEvent.stopImmediatePropagation === "function") { event.nativeEvent.stopImmediatePropagation(); } if (typeof onClick === "function") onClick(event); }; const handleAuxClick = (event) => { event.preventDefault(); event.stopPropagation(); if (event.nativeEvent && typeof event.nativeEvent.stopImmediatePropagation === "function") { event.nativeEvent.stopImmediatePropagation(); } }; return ( ); } function UserAvatar({ name, email, avatarUrl, accessToken, size = 32 }) { const [broken, setBroken] = useState(false); useEffect(() => setBroken(false), [avatarUrl]); const initials = userInitials(name, email); const bg = avatarColor(name || email || initials); const src = resolveAvatarSrc(avatarUrl, accessToken); const canShowImage = Boolean(src && !broken); return ( {canShowImage ? ( {name setBroken(true)} /> ) : ( {initials} )} ); } function LoginScreen({ onSubmit, status }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const submit = (event) => { event.preventDefault(); onSubmit(email, password); }; return (

Вход в админ-панель

Используйте учетную запись администратора или юриста.

setEmail(event.target.value)} />
setPassword(event.target.value)} />
); } 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 : "Выберите поле, оператор и значение."}

{!selectedField || selectedField.type === "text" ? ( ) : selectedField.type === "number" ? ( ) : selectedField.type === "date" ? ( ) : selectedField.type === "boolean" ? ( ) : selectedField.type === "reference" || selectedField.type === "enum" ? ( ) : ( )}
); } function ReassignModal({ open, status, options, value, onChange, onClose, onSubmit, trackNumber }) { if (!open) return null; return ( event.target.id === "reassign-overlay" && onClose()}>
event.stopPropagation()}>

Переназначение заявки

{trackNumber ? "Заявка: " + trackNumber : "Выберите нового юриста"}

); } function AttachmentPreviewModal({ open, title, url, fileName, mimeType, onClose }) { if (!open || !url) return null; const kind = detectAttachmentPreviewKind(fileName, mimeType); return ( event.target.id === "request-file-preview-overlay" && onClose()}>
event.stopPropagation()}>

{title || fileName || "Предпросмотр файла"}

{kind === "image" ? {fileName : null} {kind === "video" ?