(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 REQUEST_UPDATE_EVENT_LABELS = { MESSAGE: "сообщение", ATTACHMENT: "файл", STATUS: "статус", }; const TABLE_SERVER_CONFIG = { requests: { table: "requests", endpoint: "/api/admin/crud/requests/query", sort: [{ field: "created_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, }, ]) ); 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 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 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.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 }) { 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 RequestModal({ open, jsonText, onClose }) { if (!open) return null; return ( event.target.id === "request-overlay" && onClose()}>
event.stopPropagation()}>

Детали заявки

Подробная карточка заявки.

{jsonText}
); } function RecordModal({ open, title, fields, form, status, onClose, onChange, onSubmit, onUploadField }) { if (!open) return null; const renderField = (field) => { const value = form[field.key] ?? ""; const options = typeof field.options === "function" ? field.options() : []; const id = "record-field-" + field.key; if (field.type === "textarea" || field.type === "json") { return (