mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
4004 lines
178 KiB
JavaScript
4004 lines
178 KiB
JavaScript
(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 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: "",
|
||
selectedFile: null,
|
||
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 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 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 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) {
|
||
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 ? (
|
||
<span className="request-update-chip" title={"Есть непрочитанное обновление: " + (REQUEST_UPDATE_EVENT_LABELS[eventType] || eventType.toLowerCase())}>
|
||
<span className="request-update-dot" />
|
||
{REQUEST_UPDATE_EVENT_LABELS[eventType] || "обновление"}
|
||
</span>
|
||
) : (
|
||
<span className="request-update-empty">нет</span>
|
||
);
|
||
}
|
||
|
||
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 <span className="request-update-empty">нет</span>;
|
||
return (
|
||
<span className="request-updates-stack">
|
||
{clientHas ? (
|
||
<span className="request-update-chip" title={"Клиенту: " + (REQUEST_UPDATE_EVENT_LABELS[clientType] || clientType.toLowerCase())}>
|
||
<span className="request-update-dot" />
|
||
{"Клиент: " + (REQUEST_UPDATE_EVENT_LABELS[clientType] || "обновление")}
|
||
</span>
|
||
) : null}
|
||
{lawyerHas ? (
|
||
<span className="request-update-chip" title={"Юристу: " + (REQUEST_UPDATE_EVENT_LABELS[lawyerType] || lawyerType.toLowerCase())}>
|
||
<span className="request-update-dot" />
|
||
{"Юрист: " + (REQUEST_UPDATE_EVENT_LABELS[lawyerType] || "обновление")}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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 <p className={"status" + (status?.kind ? " " + status.kind : "")}>{status?.message || ""}</p>;
|
||
}
|
||
|
||
function Section({ active, children, id }) {
|
||
return (
|
||
<section className={"section" + (active ? " active" : "")} id={id}>
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function DataTable({ headers, rows, emptyColspan, renderRow, onSort, sortClause }) {
|
||
return (
|
||
<div className="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
{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 (
|
||
<th
|
||
key={h.key || h.label}
|
||
className={sortable ? "sortable-th" : ""}
|
||
onClick={sortable ? () => onSort(h.field) : undefined}
|
||
title={sortable ? "Нажмите для сортировки" : undefined}
|
||
>
|
||
<span className={sortable ? "sortable-head" : ""}>
|
||
{h.label}
|
||
{sortable ? <span className={"sort-indicator" + (active ? " active" : "")}>{direction === "desc" ? "↓" : "↑"}</span> : null}
|
||
</span>
|
||
</th>
|
||
);
|
||
})}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.length ? (
|
||
rows.map((row, index) => renderRow(row, index))
|
||
) : (
|
||
<tr>
|
||
<td colSpan={emptyColspan}>Нет данных</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TablePager({ tableState, onPrev, onNext, onLoadAll }) {
|
||
return (
|
||
<div className="pager">
|
||
<div>
|
||
{tableState.showAll
|
||
? "Всего: " + tableState.total + " • показаны все записи"
|
||
: "Всего: " + tableState.total + " • смещение: " + tableState.offset}
|
||
</div>
|
||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||
<button
|
||
className="btn secondary"
|
||
type="button"
|
||
onClick={onLoadAll}
|
||
disabled={tableState.total === 0 || tableState.showAll || tableState.rows.length >= tableState.total}
|
||
>
|
||
{"Загрузить все " + tableState.total}
|
||
</button>
|
||
<button className="btn secondary" type="button" onClick={onPrev} disabled={tableState.showAll || tableState.offset <= 0}>
|
||
Назад
|
||
</button>
|
||
<button
|
||
className="btn secondary"
|
||
type="button"
|
||
onClick={onNext}
|
||
disabled={tableState.showAll || tableState.offset + PAGE_SIZE >= tableState.total}
|
||
>
|
||
Вперед
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FilterToolbar({ filters, onOpen, onRemove, onEdit, getChipLabel }) {
|
||
return (
|
||
<div className="filter-toolbar">
|
||
<div className="filter-chips">
|
||
{filters.length ? (
|
||
filters.map((filter, index) => (
|
||
<div
|
||
className="filter-chip"
|
||
key={filter.field + filter.op + index}
|
||
onClick={() => onEdit(index)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
onEdit(index);
|
||
}
|
||
}}
|
||
title="Редактировать фильтр"
|
||
>
|
||
<span>{getChipLabel(filter)}</span>
|
||
<button
|
||
type="button"
|
||
aria-label="Удалить фильтр"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onRemove(index);
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))
|
||
) : (
|
||
<span className="chip-placeholder">Фильтры не заданы</span>
|
||
)}
|
||
</div>
|
||
<div className="filter-action">
|
||
<button className="btn secondary" type="button" onClick={onOpen}>
|
||
Фильтр
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Overlay({ open, onClose, children, id }) {
|
||
return (
|
||
<div className={"overlay" + (open ? " open" : "")} id={id} onClick={onClose}>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function IconButton({ icon, tooltip, onClick, tone }) {
|
||
const handleClick = (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (typeof onClick === "function") onClick(event);
|
||
};
|
||
return (
|
||
<button className={"icon-btn" + (tone ? " " + tone : "")} type="button" data-tooltip={tooltip} onClick={handleClick} aria-label={tooltip}>
|
||
{icon}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<span className="avatar" style={{ width: size + "px", height: size + "px", backgroundColor: bg }}>
|
||
{canShowImage ? (
|
||
<img src={src} alt={name || email || "avatar"} onError={() => setBroken(true)} />
|
||
) : (
|
||
<span>{initials}</span>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function LoginScreen({ onSubmit, status }) {
|
||
const [email, setEmail] = useState("");
|
||
const [password, setPassword] = useState("");
|
||
|
||
const submit = (event) => {
|
||
event.preventDefault();
|
||
onSubmit(email, password);
|
||
};
|
||
|
||
return (
|
||
<div className="login-screen">
|
||
<div className="login-card">
|
||
<h2>Вход в админ-панель</h2>
|
||
<p className="muted">Используйте учетную запись администратора или юриста.</p>
|
||
<form className="stack" style={{ marginTop: "0.7rem" }} onSubmit={submit}>
|
||
<div className="field">
|
||
<label htmlFor="login-email">Эл. почта</label>
|
||
<input
|
||
id="login-email"
|
||
type="email"
|
||
required
|
||
placeholder="admin@example.com"
|
||
value={email}
|
||
onChange={(event) => setEmail(event.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="login-password">Пароль</label>
|
||
<input
|
||
id="login-password"
|
||
type="password"
|
||
required
|
||
placeholder="••••••••"
|
||
value={password}
|
||
onChange={(event) => setPassword(event.target.value)}
|
||
/>
|
||
</div>
|
||
<button className="btn" type="submit">
|
||
Войти
|
||
</button>
|
||
<StatusLine status={status} />
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Overlay open={open} id="filter-overlay" onClose={(event) => event.target.id === "filter-overlay" && onClose()}>
|
||
<div className="modal" style={{ width: "min(560px, 100%)" }} onClick={(event) => event.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<h3>Фильтр таблицы</h3>
|
||
<p className="muted" style={{ marginTop: "0.35rem" }}>
|
||
{tableLabel
|
||
? (draft.editIndex !== null ? "Редактирование фильтра • " : "Новый фильтр • ") + "Таблица: " + tableLabel
|
||
: "Выберите поле, оператор и значение."}
|
||
</p>
|
||
</div>
|
||
<button className="close" type="button" onClick={onClose}>
|
||
×
|
||
</button>
|
||
</div>
|
||
<form className="stack" onSubmit={onSubmit}>
|
||
<div className="field">
|
||
<label htmlFor="filter-field">Поле</label>
|
||
<select id="filter-field" value={draft.field} onChange={onFieldChange}>
|
||
{fields.map((field) => (
|
||
<option value={field.field} key={field.field}>
|
||
{field.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="filter-op">Оператор</label>
|
||
<select id="filter-op" value={draft.op} onChange={onOpChange}>
|
||
{operators.map((op) => (
|
||
<option value={op} key={op}>
|
||
{OPERATOR_LABELS[op]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label htmlFor="filter-value">{selectedField ? "Значение: " + selectedField.label : "Значение"}</label>
|
||
{!selectedField || selectedField.type === "text" ? (
|
||
<input id="filter-value" type="text" value={draft.rawValue} onChange={onValueChange} placeholder="Введите значение" />
|
||
) : selectedField.type === "number" ? (
|
||
<input id="filter-value" type="number" step="any" value={draft.rawValue} onChange={onValueChange} placeholder="Число" />
|
||
) : selectedField.type === "date" ? (
|
||
<input id="filter-value" type="date" value={draft.rawValue} onChange={onValueChange} />
|
||
) : selectedField.type === "boolean" ? (
|
||
<select id="filter-value" value={draft.rawValue} onChange={onValueChange}>
|
||
<option value="true">True</option>
|
||
<option value="false">False</option>
|
||
</select>
|
||
) : selectedField.type === "reference" || selectedField.type === "enum" ? (
|
||
<select id="filter-value" value={draft.rawValue} onChange={onValueChange} disabled={!options.length}>
|
||
{!options.length ? (
|
||
<option value="">Нет доступных значений</option>
|
||
) : (
|
||
options.map((option) => (
|
||
<option value={String(option.value)} key={String(option.value)}>
|
||
{option.label}
|
||
</option>
|
||
))
|
||
)}
|
||
</select>
|
||
) : (
|
||
<input id="filter-value" type="text" value={draft.rawValue} onChange={onValueChange} placeholder="Введите значение" />
|
||
)}
|
||
</div>
|
||
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
||
<button className="btn" type="submit">
|
||
Добавить/Сохранить
|
||
</button>
|
||
<button className="btn secondary" type="button" onClick={onClear}>
|
||
Очистить все
|
||
</button>
|
||
<button className="btn secondary" type="button" onClick={onClose}>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
<StatusLine status={status} />
|
||
</form>
|
||
</div>
|
||
</Overlay>
|
||
);
|
||
}
|
||
|
||
function ReassignModal({ open, status, options, value, onChange, onClose, onSubmit, trackNumber }) {
|
||
if (!open) return null;
|
||
return (
|
||
<Overlay open={open} id="reassign-overlay" onClose={(event) => event.target.id === "reassign-overlay" && onClose()}>
|
||
<div className="modal" style={{ width: "min(520px, 100%)" }} onClick={(event) => event.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<h3>Переназначение заявки</h3>
|
||
<p className="muted" style={{ marginTop: "0.35rem" }}>
|
||
{trackNumber ? "Заявка: " + trackNumber : "Выберите нового юриста"}
|
||
</p>
|
||
</div>
|
||
<button className="close" type="button" onClick={onClose}>
|
||
×
|
||
</button>
|
||
</div>
|
||
<form className="stack" onSubmit={onSubmit}>
|
||
<div className="field">
|
||
<label htmlFor="reassign-lawyer">Новый юрист</label>
|
||
<select id="reassign-lawyer" value={value} onChange={onChange} disabled={!options.length}>
|
||
{!options.length ? (
|
||
<option value="">Нет доступных юристов</option>
|
||
) : (
|
||
options.map((option) => (
|
||
<option value={String(option.value)} key={String(option.value)}>
|
||
{option.label}
|
||
</option>
|
||
))
|
||
)}
|
||
</select>
|
||
</div>
|
||
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
||
<button className="btn" type="submit" disabled={!value}>
|
||
Сохранить
|
||
</button>
|
||
<button className="btn secondary" type="button" onClick={onClose}>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
<StatusLine status={status} />
|
||
</form>
|
||
</div>
|
||
</Overlay>
|
||
);
|
||
}
|
||
|
||
function AttachmentPreviewModal({ open, title, url, fileName, mimeType, onClose }) {
|
||
if (!open || !url) return null;
|
||
const kind = detectAttachmentPreviewKind(fileName, mimeType);
|
||
return (
|
||
<Overlay open={open} id="request-file-preview-overlay" onClose={(event) => event.target.id === "request-file-preview-overlay" && onClose()}>
|
||
<div className="modal request-preview-modal" onClick={(event) => event.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<h3>{title || fileName || "Предпросмотр файла"}</h3>
|
||
<button className="close" type="button" onClick={onClose}>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="request-preview-body">
|
||
{kind === "image" ? <img className="request-preview-image" src={url} alt={fileName || "attachment"} /> : null}
|
||
{kind === "video" ? <video className="request-preview-video" src={url} controls preload="metadata" /> : null}
|
||
{kind === "pdf" ? <iframe className="request-preview-frame" src={url} title={fileName || "preview"} /> : null}
|
||
{kind === "none" ? <p className="request-preview-note">Для этого типа файла доступно только открытие или скачивание.</p> : null}
|
||
<a className="btn secondary btn-sm request-preview-download" href={url} target="_blank" rel="noreferrer">
|
||
Открыть / скачать
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</Overlay>
|
||
);
|
||
}
|
||
|
||
function RequestWorkspace({
|
||
loading,
|
||
trackNumber,
|
||
requestData,
|
||
statusRouteNodes,
|
||
messages,
|
||
attachments,
|
||
messageDraft,
|
||
selectedFile,
|
||
fileUploading,
|
||
status,
|
||
onBack,
|
||
onRefresh,
|
||
onMessageChange,
|
||
onSendMessage,
|
||
onFileSelect,
|
||
onUploadFile,
|
||
}) {
|
||
const [preview, setPreview] = useState({ open: false, url: "", fileName: "", mimeType: "" });
|
||
const fileInputRef = useRef(null);
|
||
|
||
const openPreview = (item) => {
|
||
if (!item?.download_url) return;
|
||
setPreview({
|
||
open: true,
|
||
url: String(item.download_url),
|
||
fileName: String(item.file_name || ""),
|
||
mimeType: String(item.mime_type || ""),
|
||
});
|
||
};
|
||
|
||
const closePreview = () => setPreview({ open: false, url: "", fileName: "", mimeType: "" });
|
||
|
||
const row = requestData && typeof requestData === "object" ? requestData : null;
|
||
const summaryFields = [
|
||
{ key: "track", label: "Номер заявки", value: row?.track_number || trackNumber || "-", code: true },
|
||
{ key: "status", label: "Статус", value: row ? statusLabel(row.status_code) : "-" },
|
||
{ key: "topic", label: "Тема", value: row?.topic_code || "-" },
|
||
{ key: "client", label: "Клиент", value: row?.client_name || "-" },
|
||
{ key: "phone", label: "Телефон", value: row?.client_phone || "-" },
|
||
{ key: "lawyer", label: "Назначенный юрист", value: row?.assigned_lawyer_id || "-" },
|
||
{ key: "rate", label: "Ставка (фикс.)", value: fmtAmount(row?.effective_rate) },
|
||
{ key: "invoice", label: "Сумма счета", value: fmtAmount(row?.invoice_amount) },
|
||
{ key: "paid", label: "Дата оплаты", value: fmtDate(row?.paid_at) },
|
||
{ key: "size", label: "Размер вложений", value: fmtBytes(row?.total_attachments_bytes) },
|
||
{ key: "created", label: "Создана", value: fmtDate(row?.created_at) },
|
||
{ key: "updated", label: "Обновлена", value: fmtDate(row?.updated_at) },
|
||
];
|
||
|
||
const extraFields = row?.extra_fields && typeof row.extra_fields === "object" && !Array.isArray(row.extra_fields) ? Object.entries(row.extra_fields) : [];
|
||
const chatTimelineItems = [];
|
||
let previousDate = "";
|
||
(messages || []).forEach((item, index) => {
|
||
const dateLabel = fmtDateOnly(item?.created_at);
|
||
const normalizedDate = dateLabel && dateLabel !== "-" ? dateLabel : "Без даты";
|
||
if (normalizedDate !== previousDate) {
|
||
chatTimelineItems.push({ type: "date", key: "date-" + normalizedDate + "-" + index, label: normalizedDate });
|
||
previousDate = normalizedDate;
|
||
}
|
||
chatTimelineItems.push({ type: "message", key: "msg-" + String(item?.id || index), payload: item });
|
||
});
|
||
|
||
const routeNodes =
|
||
Array.isArray(statusRouteNodes) && statusRouteNodes.length
|
||
? statusRouteNodes
|
||
: row?.status_code
|
||
? [{ code: row.status_code, name: statusLabel(row.status_code), state: "current", note: "Текущий этап обработки заявки" }]
|
||
: [];
|
||
|
||
return (
|
||
<div className="block">
|
||
<div className="request-workspace-head">
|
||
<div>
|
||
<h3>{trackNumber ? "Работа с заявкой " + trackNumber : "Работа с заявкой"}</h3>
|
||
<p className="breadcrumbs">
|
||
<b>Заявки</b> {" -> "} <b>{trackNumber ? "Заявка " + trackNumber : "Карточка заявки"}</b>
|
||
</p>
|
||
</div>
|
||
<div style={{ display: "flex", gap: "0.45rem", flexWrap: "wrap" }}>
|
||
<button className="btn secondary btn-sm" type="button" onClick={onBack}>
|
||
Назад к заявкам
|
||
</button>
|
||
<button className="btn secondary btn-sm" type="button" onClick={onRefresh} disabled={loading || fileUploading}>
|
||
Обновить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="request-workspace-layout">
|
||
<div className="request-main-column">
|
||
<div className="block">
|
||
<h3>Карточка</h3>
|
||
{loading ? (
|
||
<p className="muted">Загрузка...</p>
|
||
) : row ? (
|
||
<>
|
||
<div className="request-card-grid">
|
||
{summaryFields.map((field) => (
|
||
<div className="request-field" key={field.key}>
|
||
<span className="request-field-label">{field.label}</span>
|
||
<span className="request-field-value">{field.code ? <code>{field.value}</code> : field.value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="request-description-block">
|
||
<span className="request-field-label">Описание проблемы</span>
|
||
<p>{row.description ? String(row.description) : "Описание не заполнено"}</p>
|
||
</div>
|
||
<div className="request-extra-block">
|
||
<span className="request-field-label">Дополнительные данные</span>
|
||
{extraFields.length ? (
|
||
<ul className="simple-list request-extra-list">
|
||
{extraFields.map(([key, value]) => (
|
||
<li key={key}>
|
||
<b>{humanizeKey(key)}:</b> {typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="muted">Дополнительные данные не заполнены</p>
|
||
)}
|
||
</div>
|
||
<div className="request-status-route">
|
||
<h4>Маршрут статусов</h4>
|
||
{routeNodes.length ? (
|
||
<ol className="request-route-list" id="request-status-route">
|
||
{routeNodes.map((node, index) => {
|
||
const state = String(node?.state || "pending");
|
||
const name = String(node?.name || statusLabel(node?.code));
|
||
const note = String(node?.note || "").trim();
|
||
const changedAt = node?.changed_at ? fmtDate(node.changed_at) : "";
|
||
const className = "route-item " + (state === "current" ? "current" : state === "completed" ? "completed" : "pending");
|
||
return (
|
||
<li className={className} key={(node?.code || "node") + "-" + index}>
|
||
<span className="route-dot" />
|
||
<div className="route-body">
|
||
<b>{name}</b>
|
||
{note ? <p>{note}</p> : null}
|
||
{changedAt && state !== "pending" ? <div className="muted route-time">Изменен: {changedAt}</div> : null}
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
) : (
|
||
<p className="muted">Маршрут статусов для темы не настроен</p>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="muted">Нет данных по заявке</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="block">
|
||
<div className="request-attachments-head">
|
||
<h3>Вложения</h3>
|
||
<button className="btn secondary btn-sm" type="button" onClick={() => fileInputRef.current?.click()} disabled={loading || fileUploading}>
|
||
Добавить
|
||
</button>
|
||
</div>
|
||
<ul className="simple-list request-modal-list" id="request-modal-files">
|
||
{attachments.length ? (
|
||
attachments.map((item) => (
|
||
<li key={String(item.id)}>
|
||
<div>{item.file_name || "Файл"}</div>
|
||
<div className="muted request-modal-item-meta">
|
||
{String(item.mime_type || "application/octet-stream") + " • " + fmtBytes(item.size_bytes) + " • " + fmtDate(item.created_at)}
|
||
</div>
|
||
<div className="request-file-actions">
|
||
{item.download_url && detectAttachmentPreviewKind(item.file_name, item.mime_type) !== "none" ? (
|
||
<button
|
||
className="btn secondary btn-sm"
|
||
type="button"
|
||
onClick={() => openPreview(item)}
|
||
aria-label={"Предпросмотр: " + String(item.file_name || "файл")}
|
||
>
|
||
Предпросмотр
|
||
</button>
|
||
) : null}
|
||
{item.download_url ? (
|
||
<a className="btn secondary btn-sm request-file-link" href={item.download_url} target="_blank" rel="noreferrer">
|
||
Открыть / скачать
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
</li>
|
||
))
|
||
) : (
|
||
<li className="muted">Вложений нет</li>
|
||
)}
|
||
</ul>
|
||
<div className="request-upload-row">
|
||
<input
|
||
id="request-modal-file-input"
|
||
ref={fileInputRef}
|
||
type="file"
|
||
onChange={onFileSelect}
|
||
disabled={loading || fileUploading}
|
||
style={{ position: "absolute", width: "1px", height: "1px", opacity: 0, pointerEvents: "none" }}
|
||
/>
|
||
<button
|
||
className="btn secondary"
|
||
id="request-modal-file-upload"
|
||
type="button"
|
||
onClick={onUploadFile}
|
||
disabled={loading || fileUploading || !selectedFile}
|
||
>
|
||
{fileUploading ? "Загрузка..." : "Загрузить файл"}
|
||
</button>
|
||
{selectedFile ? <span className="muted">{selectedFile.name}</span> : <span className="muted">Файл не выбран</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="block request-chat-block">
|
||
<h3>Сообщения</h3>
|
||
<ul className="simple-list request-modal-list request-chat-list" id="request-modal-messages">
|
||
{chatTimelineItems.length ? (
|
||
chatTimelineItems.map((entry) =>
|
||
entry.type === "date" ? (
|
||
<li key={entry.key} className="chat-date-divider">
|
||
<span>{entry.label}</span>
|
||
</li>
|
||
) : (
|
||
<li
|
||
key={entry.key}
|
||
className={
|
||
"chat-message " +
|
||
(String(entry.payload?.author_type || "").toUpperCase() === "CLIENT" ? "incoming" : "outgoing")
|
||
}
|
||
>
|
||
<div className="chat-message-author">{String(entry.payload?.author_name || entry.payload?.author_type || "Система")}</div>
|
||
<div className="chat-message-bubble">
|
||
<p className="chat-message-text">{String(entry.payload?.body || "")}</p>
|
||
<div className="chat-message-time">{fmtTimeOnly(entry.payload?.created_at)}</div>
|
||
</div>
|
||
</li>
|
||
)
|
||
)
|
||
) : (
|
||
<li className="muted">Сообщений нет</li>
|
||
)}
|
||
</ul>
|
||
<form className="stack" onSubmit={onSendMessage}>
|
||
<div className="field">
|
||
<label htmlFor="request-modal-message-body">Новое сообщение</label>
|
||
<textarea
|
||
id="request-modal-message-body"
|
||
placeholder="Введите сообщение для клиента"
|
||
value={messageDraft}
|
||
onChange={onMessageChange}
|
||
disabled={loading || fileUploading}
|
||
/>
|
||
</div>
|
||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||
<button
|
||
className="btn"
|
||
id="request-modal-message-send"
|
||
type="submit"
|
||
disabled={loading || fileUploading || !String(messageDraft || "").trim()}
|
||
>
|
||
Отправить
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<StatusLine status={status} />
|
||
<AttachmentPreviewModal
|
||
open={preview.open}
|
||
title="Предпросмотр файла"
|
||
url={preview.url}
|
||
fileName={preview.fileName}
|
||
mimeType={preview.mimeType}
|
||
onClose={closePreview}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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;
|
||
const disabled = Boolean(field.readOnly);
|
||
|
||
if (field.type === "textarea" || field.type === "json") {
|
||
return (
|
||
<textarea
|
||
id={id}
|
||
value={value}
|
||
onChange={(event) => onChange(field.key, event.target.value)}
|
||
placeholder={field.placeholder || ""}
|
||
required={Boolean(field.required)}
|
||
disabled={disabled}
|
||
/>
|
||
);
|
||
}
|
||
if (field.type === "boolean") {
|
||
return (
|
||
<select id={id} value={value} onChange={(event) => onChange(field.key, event.target.value)} disabled={disabled}>
|
||
<option value="true">Да</option>
|
||
<option value="false">Нет</option>
|
||
</select>
|
||
);
|
||
}
|
||
if (field.type === "reference" || field.type === "enum") {
|
||
return (
|
||
<select id={id} value={value} onChange={(event) => onChange(field.key, event.target.value)} disabled={disabled}>
|
||
{field.optional ? <option value="">-</option> : null}
|
||
{options.map((option) => (
|
||
<option value={String(option.value)} key={String(option.value)}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
);
|
||
}
|
||
if (field.uploadScope) {
|
||
return (
|
||
<div className="field-inline">
|
||
<input
|
||
id={id}
|
||
type="text"
|
||
value={value}
|
||
onChange={(event) => onChange(field.key, event.target.value)}
|
||
placeholder={field.placeholder || ""}
|
||
required={Boolean(field.required)}
|
||
disabled={disabled}
|
||
/>
|
||
<label className="btn secondary btn-sm" style={{ whiteSpace: "nowrap", opacity: disabled ? 0.6 : 1, pointerEvents: disabled ? "none" : "auto" }}>
|
||
Загрузить
|
||
<input
|
||
type="file"
|
||
accept={field.accept || "*/*"}
|
||
style={{ display: "none" }}
|
||
onChange={(event) => {
|
||
const file = event.target.files && event.target.files[0];
|
||
if (file && onUploadField) onUploadField(field, file);
|
||
event.target.value = "";
|
||
}}
|
||
disabled={disabled}
|
||
/>
|
||
</label>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<input
|
||
id={id}
|
||
type={field.type === "number" ? "number" : field.type === "password" ? "password" : "text"}
|
||
step={field.type === "number" ? "any" : undefined}
|
||
value={value}
|
||
onChange={(event) => onChange(field.key, event.target.value)}
|
||
placeholder={field.placeholder || ""}
|
||
required={Boolean(field.required)}
|
||
disabled={disabled}
|
||
/>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Overlay open={open} id="record-overlay" onClose={(event) => event.target.id === "record-overlay" && onClose()}>
|
||
<div className="modal" style={{ width: "min(760px, 100%)" }} onClick={(event) => event.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<h3>{title}</h3>
|
||
<p className="muted" style={{ marginTop: "0.35rem" }}>
|
||
Создание и редактирование записи.
|
||
</p>
|
||
</div>
|
||
<button className="close" type="button" onClick={onClose}>
|
||
×
|
||
</button>
|
||
</div>
|
||
<form className="stack" onSubmit={onSubmit}>
|
||
<div className="filters" style={{ gridTemplateColumns: "repeat(2, minmax(0,1fr))" }}>
|
||
{fields.map((field) => (
|
||
<div className="field" key={field.key}>
|
||
<label htmlFor={"record-field-" + field.key}>{field.label}</label>
|
||
{renderField(field)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
|
||
<button className="btn" type="submit">
|
||
Сохранить
|
||
</button>
|
||
<button className="btn secondary" type="button" onClick={onClose}>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
<StatusLine status={status} />
|
||
</form>
|
||
</div>
|
||
</Overlay>
|
||
);
|
||
}
|
||
|
||
function App() {
|
||
const routeInfo = useMemo(() => resolveAdminRoute(window.location.search), []);
|
||
const isRequestWorkspaceRoute = routeInfo.view === "request" && Boolean(routeInfo.requestId);
|
||
const initialSection = isRequestWorkspaceRoute ? "requestWorkspace" : routeInfo.section || "dashboard";
|
||
|
||
const [token, setToken] = useState("");
|
||
const [role, setRole] = useState("");
|
||
const [email, setEmail] = useState("");
|
||
const [activeSection, setActiveSection] = useState(initialSection);
|
||
|
||
const [dashboardData, setDashboardData] = useState({
|
||
scope: "",
|
||
cards: [],
|
||
byStatus: {},
|
||
lawyerLoads: [],
|
||
myUnreadByEvent: {},
|
||
});
|
||
|
||
const [tables, setTables] = useState({
|
||
requests: createTableState(),
|
||
invoices: createTableState(),
|
||
quotes: createTableState(),
|
||
topics: createTableState(),
|
||
statuses: createTableState(),
|
||
formFields: createTableState(),
|
||
topicRequiredFields: createTableState(),
|
||
topicDataTemplates: createTableState(),
|
||
statusTransitions: createTableState(),
|
||
users: createTableState(),
|
||
userTopics: createTableState(),
|
||
availableTables: createTableState(),
|
||
});
|
||
const [tableCatalog, setTableCatalog] = useState([]);
|
||
|
||
const [dictionaries, setDictionaries] = useState({
|
||
topics: [],
|
||
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })),
|
||
formFieldTypes: [...DEFAULT_FORM_FIELD_TYPES],
|
||
formFieldKeys: [],
|
||
users: [],
|
||
});
|
||
|
||
const [statusMap, setStatusMap] = useState({});
|
||
|
||
const [requestModal, setRequestModal] = useState(createRequestModalState());
|
||
const [recordModal, setRecordModal] = useState({
|
||
open: false,
|
||
tableKey: null,
|
||
mode: "create",
|
||
rowId: null,
|
||
form: {},
|
||
});
|
||
|
||
const [configActiveKey, setConfigActiveKey] = useState("");
|
||
const [referencesExpanded, setReferencesExpanded] = useState(true);
|
||
|
||
const [metaEntity, setMetaEntity] = useState("quotes");
|
||
const [metaJson, setMetaJson] = useState("");
|
||
|
||
const [filterModal, setFilterModal] = useState({
|
||
open: false,
|
||
tableKey: null,
|
||
field: "",
|
||
op: "=",
|
||
rawValue: "",
|
||
editIndex: null,
|
||
});
|
||
const [reassignModal, setReassignModal] = useState({
|
||
open: false,
|
||
requestId: null,
|
||
trackNumber: "",
|
||
lawyerId: "",
|
||
});
|
||
|
||
const tablesRef = useRef(tables);
|
||
const requestOpenGuardRef = useRef({ requestId: "", ts: 0 });
|
||
const initialRouteHandledRef = useRef(false);
|
||
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 getInvoiceStatusOptions = useCallback(() => {
|
||
return Object.entries(INVOICE_STATUS_LABELS).map(([code, name]) => ({ value: code, label: name + " (" + code + ")" }));
|
||
}, []);
|
||
|
||
const getStatusKindOptions = useCallback(() => {
|
||
return Object.entries(STATUS_KIND_LABELS).map(([code, name]) => ({ value: code, label: name + " (" + code + ")" }));
|
||
}, []);
|
||
|
||
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 getLawyerOptions = useCallback(() => {
|
||
return (dictionaries.users || [])
|
||
.filter((item) => item && item.id && String(item.role || "").toUpperCase() === "LAWYER")
|
||
.map((item) => ({
|
||
value: item.id,
|
||
label: (item.name || item.email || item.id) + (item.email ? " (" + item.email + ")" : ""),
|
||
}));
|
||
}, [dictionaries.users]);
|
||
|
||
const getFormFieldTypeOptions = useCallback(() => {
|
||
return (dictionaries.formFieldTypes || []).filter(Boolean).map((item) => ({ value: item, label: item }));
|
||
}, [dictionaries.formFieldTypes]);
|
||
|
||
const getFormFieldKeyOptions = useCallback(() => {
|
||
return (dictionaries.formFieldKeys || [])
|
||
.filter((item) => item && item.key)
|
||
.map((item) => ({ value: item.key, label: (item.label || item.key) + " (" + item.key + ")" }));
|
||
}, [dictionaries.formFieldKeys]);
|
||
|
||
const getRoleOptions = useCallback(() => {
|
||
return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label: label + " (" + code + ")" }));
|
||
}, []);
|
||
|
||
const tableCatalogMap = useMemo(() => {
|
||
const map = {};
|
||
(tableCatalog || []).forEach((item) => {
|
||
if (!item || !item.key) return;
|
||
map[item.key] = item;
|
||
});
|
||
return map;
|
||
}, [tableCatalog]);
|
||
|
||
const dictionaryTableItems = useMemo(() => {
|
||
return (tableCatalog || [])
|
||
.filter((item) => item && item.section === "dictionary" && Array.isArray(item.actions) && item.actions.includes("query"))
|
||
.sort((a, b) => String(a.label || a.key).localeCompare(String(b.label || b.key), "ru"));
|
||
}, [tableCatalog]);
|
||
|
||
const resolveTableConfig = useCallback(
|
||
(tableKey) => {
|
||
if (TABLE_SERVER_CONFIG[tableKey]) return TABLE_SERVER_CONFIG[tableKey];
|
||
const meta = tableCatalogMap[tableKey];
|
||
if (!meta || !meta.table) return null;
|
||
const tableName = String(meta.table || tableKey);
|
||
return {
|
||
table: tableName,
|
||
endpoint: String(meta.query_endpoint || ("/api/admin/crud/" + tableName + "/query")),
|
||
sort: Array.isArray(meta.default_sort) && meta.default_sort.length ? meta.default_sort : [{ field: "created_at", dir: "desc" }],
|
||
};
|
||
},
|
||
[tableCatalogMap]
|
||
);
|
||
|
||
const resolveMutationConfig = useCallback(
|
||
(tableKey) => {
|
||
if (TABLE_MUTATION_CONFIG[tableKey]) return TABLE_MUTATION_CONFIG[tableKey];
|
||
const meta = tableCatalogMap[tableKey];
|
||
if (!meta || !meta.table) return null;
|
||
const tableName = String(meta.table || tableKey);
|
||
return {
|
||
create: String(meta.create_endpoint || ("/api/admin/crud/" + tableName)),
|
||
update: (id) => String(meta.update_endpoint_template || ("/api/admin/crud/" + tableName + "/{id}")).replace("{id}", String(id)),
|
||
delete: (id) => String(meta.delete_endpoint_template || ("/api/admin/crud/" + tableName + "/{id}")).replace("{id}", String(id)),
|
||
};
|
||
},
|
||
[tableCatalogMap]
|
||
);
|
||
|
||
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: "invoice_amount", label: "Сумма счета", type: "number" },
|
||
{ field: "effective_rate", label: "Ставка", type: "number" },
|
||
{ field: "paid_at", label: "Оплачено", type: "date" },
|
||
{ field: "created_at", label: "Дата создания", type: "date" },
|
||
];
|
||
}
|
||
if (tableKey === "invoices") {
|
||
return [
|
||
{ field: "invoice_number", label: "Номер счета", type: "text" },
|
||
{ field: "status", label: "Статус", type: "enum", options: getInvoiceStatusOptions },
|
||
{ field: "amount", label: "Сумма", type: "number" },
|
||
{ field: "currency", label: "Валюта", type: "text" },
|
||
{ field: "payer_display_name", label: "Плательщик", type: "text" },
|
||
{ field: "request_id", label: "ID заявки", type: "text" },
|
||
{ field: "issued_by_admin_user_id", label: "ID сотрудника", type: "text" },
|
||
{ field: "issued_at", label: "Дата формирования", type: "date" },
|
||
{ field: "paid_at", label: "Дата оплаты", type: "date" },
|
||
{ 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: "kind", label: "Тип", type: "enum", options: getStatusKindOptions },
|
||
{ 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" },
|
||
];
|
||
}
|
||
if (tableKey === "topicRequiredFields") {
|
||
return [
|
||
{ field: "topic_code", label: "Тема", type: "reference", options: getTopicOptions },
|
||
{ field: "field_key", label: "Поле формы", type: "reference", options: getFormFieldKeyOptions },
|
||
{ field: "required", label: "Обязательное", type: "boolean" },
|
||
{ field: "enabled", label: "Активно", type: "boolean" },
|
||
{ field: "sort_order", label: "Порядок", type: "number" },
|
||
];
|
||
}
|
||
if (tableKey === "topicDataTemplates") {
|
||
return [
|
||
{ field: "topic_code", label: "Тема", type: "reference", options: getTopicOptions },
|
||
{ field: "key", label: "Ключ", type: "text" },
|
||
{ field: "label", label: "Метка", type: "text" },
|
||
{ field: "required", label: "Обязательное", type: "boolean" },
|
||
{ field: "enabled", label: "Активно", type: "boolean" },
|
||
{ field: "sort_order", label: "Порядок", type: "number" },
|
||
{ field: "created_at", label: "Дата создания", type: "date" },
|
||
];
|
||
}
|
||
if (tableKey === "statusTransitions") {
|
||
return [
|
||
{ field: "topic_code", label: "Тема", type: "reference", options: getTopicOptions },
|
||
{ field: "from_status", label: "Из статуса", type: "reference", options: getStatusOptions },
|
||
{ field: "to_status", label: "В статус", type: "reference", options: getStatusOptions },
|
||
{ field: "sla_hours", label: "SLA (часы)", type: "number" },
|
||
{ field: "enabled", label: "Активен", type: "boolean" },
|
||
{ field: "sort_order", label: "Порядок", type: "number" },
|
||
];
|
||
}
|
||
if (tableKey === "users") {
|
||
return [
|
||
{ field: "name", label: "Имя", type: "text" },
|
||
{ field: "email", label: "Email", type: "text" },
|
||
{ field: "role", label: "Роль", type: "enum", options: getRoleOptions },
|
||
{ field: "primary_topic_code", label: "Профиль (тема)", type: "reference", options: getTopicOptions },
|
||
{ field: "default_rate", label: "Ставка по умолчанию", type: "number" },
|
||
{ field: "salary_percent", label: "Процент зарплаты", type: "number" },
|
||
{ field: "is_active", label: "Активен", type: "boolean" },
|
||
{ field: "responsible", label: "Ответственный", type: "text" },
|
||
{ field: "created_at", label: "Дата создания", type: "date" },
|
||
];
|
||
}
|
||
if (tableKey === "userTopics") {
|
||
return [
|
||
{ field: "admin_user_id", label: "Юрист", type: "reference", options: getLawyerOptions },
|
||
{ field: "topic_code", label: "Доп. тема", type: "reference", options: getTopicOptions },
|
||
{ field: "responsible", label: "Ответственный", type: "text" },
|
||
{ field: "created_at", label: "Дата создания", type: "date" },
|
||
];
|
||
}
|
||
const meta = tableCatalogMap[tableKey];
|
||
if (!meta || !Array.isArray(meta.columns)) return [];
|
||
return (meta.columns || [])
|
||
.filter((column) => column && column.name && column.filterable !== false)
|
||
.map((column) => {
|
||
const name = String(column.name);
|
||
const label = String(column.label || humanizeKey(name));
|
||
if (name === "topic_code") return { field: name, label, type: "reference", options: getTopicOptions };
|
||
if (name === "status_code" || name === "from_status" || name === "to_status") {
|
||
return { field: name, label, type: "reference", options: getStatusOptions };
|
||
}
|
||
if (name === "field_key") return { field: name, label, type: "reference", options: getFormFieldKeyOptions };
|
||
return { field: name, label, type: metaKindToFilterType(column.kind) };
|
||
});
|
||
},
|
||
[
|
||
tableCatalogMap,
|
||
getFormFieldKeyOptions,
|
||
getFormFieldTypeOptions,
|
||
getInvoiceStatusOptions,
|
||
getLawyerOptions,
|
||
getRoleOptions,
|
||
getStatusKindOptions,
|
||
getStatusOptions,
|
||
getTopicOptions,
|
||
]
|
||
);
|
||
|
||
const getTableLabel = useCallback((tableKey) => {
|
||
if (tableKey === "requests") return "Заявки";
|
||
if (tableKey === "invoices") return "Счета";
|
||
if (tableKey === "quotes") return "Цитаты";
|
||
if (tableKey === "topics") return "Темы";
|
||
if (tableKey === "statuses") return "Статусы";
|
||
if (tableKey === "formFields") return "Поля формы";
|
||
if (tableKey === "topicRequiredFields") return "Обязательные поля по темам";
|
||
if (tableKey === "topicDataTemplates") return "Шаблоны дозапроса по темам";
|
||
if (tableKey === "statusTransitions") return "Переходы статусов";
|
||
if (tableKey === "users") return "Пользователи";
|
||
if (tableKey === "userTopics") return "Дополнительные темы юристов";
|
||
const meta = tableCatalogMap[tableKey];
|
||
if (meta && meta.label) return String(meta.label);
|
||
const raw = TABLE_UNALIASES[tableKey] || tableKey;
|
||
return humanizeKey(raw);
|
||
}, [tableCatalogMap]);
|
||
|
||
const getRecordFields = useCallback(
|
||
(tableKey) => {
|
||
if (tableKey === "requests") {
|
||
return [
|
||
{ key: "track_number", label: "Номер заявки", type: "text", optional: true, placeholder: "Оставьте пустым для автогенерации" },
|
||
{ key: "client_name", label: "Клиент", type: "text", required: true },
|
||
{ key: "client_phone", label: "Телефон", type: "text", required: true },
|
||
{ key: "topic_code", label: "Тема", type: "reference", optional: true, options: getTopicOptions },
|
||
{ key: "status_code", label: "Статус", type: "reference", required: true, options: getStatusOptions },
|
||
{ key: "description", label: "Описание", type: "textarea", optional: true },
|
||
{ key: "extra_fields", label: "Дополнительные поля (JSON)", type: "json", optional: true, defaultValue: "{}" },
|
||
{ key: "assigned_lawyer_id", label: "Назначенный юрист (ID)", type: "text", optional: true },
|
||
{ key: "effective_rate", label: "Ставка (фикс.)", type: "number", optional: true },
|
||
];
|
||
}
|
||
if (tableKey === "invoices") {
|
||
return [
|
||
{ key: "request_track_number", label: "Номер заявки", type: "text", required: true, createOnly: true },
|
||
{ key: "invoice_number", label: "Номер счета", type: "text", optional: true, placeholder: "Оставьте пустым для автогенерации" },
|
||
{ key: "status", label: "Статус", type: "enum", required: true, options: getInvoiceStatusOptions, defaultValue: "WAITING_PAYMENT" },
|
||
{ key: "amount", label: "Сумма", type: "number", required: true },
|
||
{ key: "currency", label: "Валюта", type: "text", optional: true, defaultValue: "RUB" },
|
||
{ key: "payer_display_name", label: "Плательщик (ФИО / компания)", type: "text", required: true },
|
||
{ key: "payer_details", label: "Реквизиты (JSON, шифруется)", type: "json", optional: true, omitIfEmpty: true, placeholder: "{\"inn\":\"...\"}" },
|
||
];
|
||
}
|
||
if (tableKey === "quotes") {
|
||
return [
|
||
{ key: "author", label: "Автор", type: "text", required: true },
|
||
{ key: "text", label: "Текст", type: "textarea", required: true },
|
||
{ key: "source", label: "Источник", type: "text", optional: true },
|
||
{ key: "is_active", label: "Активна", type: "boolean", defaultValue: "true" },
|
||
{ key: "sort_order", label: "Порядок", type: "number", defaultValue: "0" },
|
||
];
|
||
}
|
||
if (tableKey === "topics") {
|
||
return [
|
||
{ key: "code", label: "Код", type: "text", required: true, autoCreate: true },
|
||
{ key: "name", label: "Название", type: "text", required: true },
|
||
{ key: "enabled", label: "Активна", type: "boolean", defaultValue: "true" },
|
||
{ key: "sort_order", label: "Порядок", type: "number", defaultValue: "0" },
|
||
];
|
||
}
|
||
if (tableKey === "statuses") {
|
||
return [
|
||
{ key: "code", label: "Код", type: "text", required: true },
|
||
{ key: "name", label: "Название", type: "text", required: true },
|
||
{ key: "kind", label: "Тип", type: "enum", required: true, options: getStatusKindOptions, defaultValue: "DEFAULT" },
|
||
{ key: "invoice_template", label: "Шаблон счета", type: "textarea", optional: true, placeholder: "Доступные поля: {track_number}, {client_name}, {topic_code}, {amount}" },
|
||
{ key: "enabled", label: "Активен", type: "boolean", defaultValue: "true" },
|
||
{ key: "sort_order", label: "Порядок", type: "number", defaultValue: "0" },
|
||
{ key: "is_terminal", label: "Терминальный", type: "boolean", defaultValue: "false" },
|
||
];
|
||
}
|
||
if (tableKey === "formFields") {
|
||
return [
|
||
{ key: "key", label: "Ключ", type: "text", required: true },
|
||
{ key: "label", label: "Метка", type: "text", required: true },
|
||
{ key: "type", label: "Тип", type: "enum", required: true, options: getFormFieldTypeOptions },
|
||
{ key: "required", label: "Обязательное", type: "boolean", defaultValue: "false" },
|
||
{ key: "enabled", label: "Активно", type: "boolean", defaultValue: "true" },
|
||
{ key: "sort_order", label: "Порядок", type: "number", defaultValue: "0" },
|
||
{ key: "options", label: "Опции (JSON)", type: "json", optional: true },
|
||
];
|
||
}
|
||
if (tableKey === "topicRequiredFields") {
|
||
return [
|
||
{ key: "topic_code", label: "Тема", type: "reference", required: true, options: getTopicOptions },
|
||
{ key: "field_key", label: "Поле формы", type: "reference", required: true, options: getFormFieldKeyOptions },
|
||
{ key: "required", label: "Обязательное", type: "boolean", defaultValue: "true" },
|
||
{ key: "enabled", label: "Активно", type: "boolean", defaultValue: "true" },
|
||
{ key: "sort_order", label: "Порядок", type: "number", defaultValue: "0" },
|
||
];
|
||
}
|
||
if (tableKey === "topicDataTemplates") {
|
||
return [
|
||
{ key: "topic_code", label: "Тема", type: "reference", required: true, options: getTopicOptions },
|
||
{ key: "key", label: "Ключ", type: "text", required: true },
|
||
{ key: "label", label: "Метка", type: "text", required: true },
|
||
{ key: "description", label: "Описание", type: "textarea", optional: true },
|
||
{ key: "required", label: "Обязательное", type: "boolean", defaultValue: "true" },
|
||
{ key: "enabled", label: "Активно", type: "boolean", defaultValue: "true" },
|
||
{ key: "sort_order", label: "Порядок", type: "number", defaultValue: "0" },
|
||
];
|
||
}
|
||
if (tableKey === "statusTransitions") {
|
||
return [
|
||
{ key: "topic_code", label: "Тема", type: "reference", required: true, options: getTopicOptions },
|
||
{ key: "from_status", label: "Из статуса", type: "reference", required: true, options: getStatusOptions },
|
||
{ key: "to_status", label: "В статус", type: "reference", required: true, options: getStatusOptions },
|
||
{ key: "sla_hours", label: "SLA (часы)", type: "number", optional: true },
|
||
{ key: "enabled", label: "Активен", type: "boolean", defaultValue: "true" },
|
||
{ key: "sort_order", label: "Порядок", type: "number", defaultValue: "0" },
|
||
];
|
||
}
|
||
if (tableKey === "users") {
|
||
return [
|
||
{ key: "name", label: "Имя", type: "text", required: true },
|
||
{ key: "email", label: "Email", type: "text", required: true },
|
||
{ key: "role", label: "Роль", type: "enum", required: true, options: getRoleOptions, defaultValue: "LAWYER" },
|
||
{
|
||
key: "avatar_url",
|
||
label: "URL аватара",
|
||
type: "text",
|
||
optional: true,
|
||
placeholder: "https://... или s3://...",
|
||
uploadScope: "USER_AVATAR",
|
||
accept: "image/*",
|
||
},
|
||
{ key: "primary_topic_code", label: "Профиль (тема)", type: "reference", optional: true, options: getTopicOptions },
|
||
{ key: "default_rate", label: "Ставка по умолчанию", type: "number", optional: true },
|
||
{ key: "salary_percent", label: "Процент зарплаты", type: "number", optional: true },
|
||
{ key: "is_active", label: "Активен", type: "boolean", defaultValue: "true" },
|
||
{ key: "password", label: "Пароль", type: "password", requiredOnCreate: true, optional: true, omitIfEmpty: true, placeholder: "Введите пароль" },
|
||
];
|
||
}
|
||
if (tableKey === "userTopics") {
|
||
return [
|
||
{ key: "admin_user_id", label: "Юрист", type: "reference", required: true, options: getLawyerOptions },
|
||
{ key: "topic_code", label: "Дополнительная тема", type: "reference", required: true, options: getTopicOptions },
|
||
];
|
||
}
|
||
const meta = tableCatalogMap[tableKey];
|
||
if (!meta || !Array.isArray(meta.columns)) return [];
|
||
return (meta.columns || [])
|
||
.filter((column) => column && column.name && column.editable)
|
||
.map((column) => {
|
||
const key = String(column.name || "");
|
||
const requiredOnCreate = Boolean(column.required_on_create);
|
||
return {
|
||
key,
|
||
label: String(column.label || humanizeKey(key)),
|
||
type: metaKindToRecordType(column.kind),
|
||
requiredOnCreate,
|
||
optional: !requiredOnCreate,
|
||
};
|
||
});
|
||
},
|
||
[
|
||
tableCatalogMap,
|
||
getFormFieldKeyOptions,
|
||
getFormFieldTypeOptions,
|
||
getInvoiceStatusOptions,
|
||
getLawyerOptions,
|
||
getRoleOptions,
|
||
getStatusKindOptions,
|
||
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 = resolveTableConfig(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" || tableKey === "form_fields") {
|
||
setDictionaries((prev) => {
|
||
const set = new Set(DEFAULT_FORM_FIELD_TYPES);
|
||
(next.rows || []).forEach((row) => {
|
||
if (row?.type) set.add(row.type);
|
||
});
|
||
const fieldKeys = (next.rows || [])
|
||
.filter((row) => row && row.key)
|
||
.map((row) => ({ key: row.key, label: row.label || row.key }))
|
||
.sort((a, b) => String(a.label || a.key).localeCompare(String(b.label || b.key), "ru"));
|
||
return {
|
||
...prev,
|
||
formFieldTypes: Array.from(set.values()).sort((a, b) => String(a).localeCompare(String(b), "ru")),
|
||
formFieldKeys: fieldKeys,
|
||
};
|
||
});
|
||
}
|
||
|
||
if (tableKey === "users" || tableKey === "admin_users") {
|
||
setDictionaries((prev) => {
|
||
const map = new Map((prev.users || []).map((user) => [user.id, user]));
|
||
(next.rows || []).forEach((row) => {
|
||
map.set(row.id, {
|
||
id: row.id,
|
||
name: row.name || "",
|
||
email: row.email || "",
|
||
role: row.role || "",
|
||
is_active: Boolean(row.is_active),
|
||
});
|
||
});
|
||
return { ...prev, users: Array.from(map.values()) };
|
||
});
|
||
}
|
||
|
||
setStatus(statusKey, "Список обновлен", "ok");
|
||
return true;
|
||
} catch (error) {
|
||
setStatus(statusKey, "Ошибка: " + error.message, "error");
|
||
return false;
|
||
}
|
||
},
|
||
[api, resolveTableConfig, setStatus, setTableState]
|
||
);
|
||
|
||
const loadCurrentConfigTable = useCallback(
|
||
async (resetOffset, tokenOverride, keyOverride) => {
|
||
const currentKey = keyOverride || configActiveKey;
|
||
if (!currentKey) {
|
||
return false;
|
||
}
|
||
return loadTable(currentKey, { resetOffset: Boolean(resetOffset) }, tokenOverride);
|
||
},
|
||
[configActiveKey, loadTable]
|
||
);
|
||
|
||
const loadAvailableTables = useCallback(
|
||
async (tokenOverride) => {
|
||
setStatus("availableTables", "Загрузка...", "");
|
||
try {
|
||
const data = await api("/api/admin/crud/meta/available-tables", {}, tokenOverride);
|
||
const rows = Array.isArray(data.rows) ? data.rows : [];
|
||
setTableState("availableTables", {
|
||
filters: [],
|
||
sort: null,
|
||
offset: 0,
|
||
total: rows.length,
|
||
showAll: true,
|
||
rows,
|
||
});
|
||
setStatus("availableTables", "Список обновлен", "ok");
|
||
return true;
|
||
} catch (error) {
|
||
setStatus("availableTables", "Ошибка: " + error.message, "error");
|
||
return false;
|
||
}
|
||
},
|
||
[api, setStatus, setTableState]
|
||
);
|
||
|
||
const loadDashboard = useCallback(
|
||
async (tokenOverride) => {
|
||
setStatus("dashboard", "Загрузка...", "");
|
||
try {
|
||
const data = await api("/api/admin/metrics/overview", {}, tokenOverride);
|
||
const scope = String(data.scope || role || "");
|
||
const cards =
|
||
scope === "LAWYER"
|
||
? [
|
||
{ label: "Мои заявки", value: data.assigned_total ?? 0 },
|
||
{ label: "Мои активные", value: data.active_assigned_total ?? 0 },
|
||
{ label: "Неназначенные", value: data.unassigned_total ?? 0 },
|
||
{ label: "Мои непрочитанные", value: data.my_unread_updates ?? 0 },
|
||
{ label: "Просрочено SLA", value: data.sla_overdue ?? 0 },
|
||
]
|
||
: [
|
||
{ label: "Новые", value: data.new ?? 0 },
|
||
{ label: "Назначенные", value: data.assigned_total ?? 0 },
|
||
{ label: "Неназначенные", value: data.unassigned_total ?? 0 },
|
||
{ label: "Просрочено SLA", value: data.sla_overdue ?? 0 },
|
||
{ label: "Непрочитано юристами", value: data.unread_for_lawyers ?? 0 },
|
||
{ label: "Непрочитано клиентами", value: data.unread_for_clients ?? 0 },
|
||
];
|
||
const localized = {};
|
||
Object.entries(data.by_status || {}).forEach(([code, count]) => {
|
||
localized[statusLabel(code)] = count;
|
||
});
|
||
setDashboardData({
|
||
scope,
|
||
cards,
|
||
byStatus: localized,
|
||
lawyerLoads: data.lawyer_loads || [],
|
||
myUnreadByEvent: data.my_unread_by_event || {},
|
||
});
|
||
setStatus("dashboard", "Данные обновлены", "ok");
|
||
} catch (error) {
|
||
setStatus("dashboard", "Ошибка: " + error.message, "error");
|
||
}
|
||
},
|
||
[api, role, 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 === "invoices") return loadTable("invoices", {}, tokenOverride);
|
||
if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride);
|
||
if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride);
|
||
if (section === "availableTables" && canAccessSection(role, "availableTables")) return loadAvailableTables(tokenOverride);
|
||
if (section === "meta") return loadMeta(tokenOverride);
|
||
},
|
||
[loadAvailableTables, 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 usersBody = buildUniversalQuery([], [{ field: "created_at", dir: "desc" }], 500, 0);
|
||
const [catalogData, topicsData, statusesData, fieldsData, usersData] = await Promise.all([
|
||
api("/api/admin/crud/meta/tables", {}, tokenOverride),
|
||
api("/api/admin/crud/topics/query", { method: "POST", body }, tokenOverride),
|
||
api("/api/admin/crud/statuses/query", { method: "POST", body }, tokenOverride),
|
||
api("/api/admin/crud/form_fields/query", { method: "POST", body }, tokenOverride),
|
||
api("/api/admin/crud/admin_users/query", { method: "POST", body: usersBody }, tokenOverride),
|
||
]);
|
||
const catalogRows = (catalogData.tables || [])
|
||
.filter((row) => row && row.table)
|
||
.map((row) => {
|
||
const tableName = String(row.table || "");
|
||
const key = TABLE_KEY_ALIASES[tableName] || String(row.key || tableName);
|
||
return { ...row, key, table: tableName };
|
||
});
|
||
setTableCatalog(catalogRows);
|
||
|
||
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);
|
||
});
|
||
const fieldKeys = (fieldsData.rows || [])
|
||
.filter((row) => row && row.key)
|
||
.map((row) => ({ key: row.key, label: row.label || row.key }))
|
||
.sort((a, b) => String(a.label || a.key).localeCompare(String(b.label || b.key), "ru"));
|
||
|
||
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")),
|
||
formFieldKeys: fieldKeys,
|
||
users: (usersData.rows || []).map((row) => ({
|
||
id: row.id,
|
||
name: row.name || "",
|
||
email: row.email || "",
|
||
role: row.role || "",
|
||
is_active: Boolean(row.is_active),
|
||
})),
|
||
}));
|
||
} catch (_) {
|
||
// Keep defaults when dictionary endpoints are unavailable.
|
||
}
|
||
},
|
||
[api]
|
||
);
|
||
|
||
const updateAvailableTableState = useCallback(
|
||
async (tableName, isActive) => {
|
||
const name = String(tableName || "").trim();
|
||
if (!name) return;
|
||
try {
|
||
setStatus("availableTables", "Сохранение...", "");
|
||
await api("/api/admin/crud/meta/available-tables/" + encodeURIComponent(name), {
|
||
method: "PATCH",
|
||
body: { is_active: Boolean(isActive) },
|
||
});
|
||
await Promise.all([loadAvailableTables(), bootstrapReferenceData(token, role)]);
|
||
setStatus("availableTables", "Сохранено", "ok");
|
||
} catch (error) {
|
||
setStatus("availableTables", "Ошибка: " + error.message, "error");
|
||
}
|
||
},
|
||
[api, bootstrapReferenceData, loadAvailableTables, role, setStatus, token]
|
||
);
|
||
|
||
const loadRequestModalData = useCallback(
|
||
async (requestId, options) => {
|
||
const opts = options || {};
|
||
const showLoading = opts.showLoading !== false;
|
||
if (!requestId) return;
|
||
|
||
if (showLoading) {
|
||
setRequestModal((prev) => ({
|
||
...prev,
|
||
loading: true,
|
||
requestId,
|
||
requestData: null,
|
||
statusRouteNodes: [],
|
||
}));
|
||
}
|
||
|
||
const requestFilter = [{ field: "request_id", op: "=", value: String(requestId) }];
|
||
try {
|
||
const [row, messagesData, attachmentsData, statusRouteData] = await Promise.all([
|
||
api("/api/admin/crud/requests/" + requestId),
|
||
api("/api/admin/chat/requests/" + requestId + "/messages"),
|
||
api("/api/admin/crud/attachments/query", {
|
||
method: "POST",
|
||
body: buildUniversalQuery(requestFilter, [{ field: "created_at", dir: "asc" }], 500, 0),
|
||
}),
|
||
api("/api/admin/requests/" + requestId + "/status-route").catch(() => ({ nodes: [] })),
|
||
]);
|
||
const attachments = (attachmentsData.rows || []).map((item) => ({
|
||
...item,
|
||
download_url: resolveAdminObjectSrc(item.s3_key, token),
|
||
}));
|
||
setRequestModal((prev) => ({
|
||
...prev,
|
||
loading: false,
|
||
requestId: row.id || requestId,
|
||
trackNumber: String(row.track_number || ""),
|
||
requestData: row,
|
||
statusRouteNodes: Array.isArray(statusRouteData?.nodes) ? statusRouteData.nodes : [],
|
||
messages: messagesData.rows || [],
|
||
attachments,
|
||
fileUploading: false,
|
||
}));
|
||
if (showLoading) setStatus("requestModal", "", "");
|
||
} catch (error) {
|
||
setRequestModal((prev) => ({
|
||
...prev,
|
||
loading: false,
|
||
requestId,
|
||
requestData: null,
|
||
statusRouteNodes: [],
|
||
messages: [],
|
||
attachments: [],
|
||
fileUploading: false,
|
||
}));
|
||
setStatus("requestModal", "Ошибка: " + error.message, "error");
|
||
}
|
||
},
|
||
[api, setStatus, token]
|
||
);
|
||
|
||
const openRequestDetails = useCallback(
|
||
(requestId) => {
|
||
if (!requestId) return;
|
||
const normalizedRequestId = String(requestId);
|
||
const now = Date.now();
|
||
const prev = requestOpenGuardRef.current;
|
||
if (prev.requestId === normalizedRequestId && now - prev.ts < 900) return;
|
||
requestOpenGuardRef.current = { requestId: normalizedRequestId, ts: now };
|
||
const url = "/admin.html?view=request&requestId=" + encodeURIComponent(String(requestId));
|
||
const newTab = window.open(url, "_blank");
|
||
if (newTab) {
|
||
try {
|
||
newTab.opener = null;
|
||
} catch (_) {
|
||
// no-op for browsers that restrict this assignment
|
||
}
|
||
return;
|
||
}
|
||
window.location.assign(url);
|
||
},
|
||
[]
|
||
);
|
||
|
||
const refreshRequestModal = useCallback(async () => {
|
||
if (!requestModal.requestId) return;
|
||
await loadRequestModalData(requestModal.requestId, { showLoading: true });
|
||
}, [loadRequestModalData, requestModal.requestId]);
|
||
|
||
const updateRequestModalMessageDraft = useCallback((event) => {
|
||
const value = event.target.value;
|
||
setRequestModal((prev) => ({ ...prev, messageDraft: value }));
|
||
}, []);
|
||
|
||
const submitRequestModalMessage = useCallback(
|
||
async (event) => {
|
||
event.preventDefault();
|
||
const requestId = requestModal.requestId;
|
||
const body = String(requestModal.messageDraft || "").trim();
|
||
if (!requestId || !body) return;
|
||
try {
|
||
setStatus("requestModal", "Отправка сообщения...", "");
|
||
await api("/api/admin/chat/requests/" + requestId + "/messages", {
|
||
method: "POST",
|
||
body: {
|
||
body,
|
||
},
|
||
});
|
||
setRequestModal((prev) => ({ ...prev, messageDraft: "" }));
|
||
setStatus("requestModal", "Сообщение отправлено", "ok");
|
||
await loadRequestModalData(requestId, { showLoading: false });
|
||
} catch (error) {
|
||
setStatus("requestModal", "Ошибка отправки: " + error.message, "error");
|
||
}
|
||
},
|
||
[api, email, loadRequestModalData, requestModal.messageDraft, requestModal.requestId, role, setStatus]
|
||
);
|
||
|
||
const updateRequestModalFile = useCallback((event) => {
|
||
const file = event.target.files && event.target.files[0] ? event.target.files[0] : null;
|
||
setRequestModal((prev) => ({ ...prev, selectedFile: file }));
|
||
}, []);
|
||
|
||
const uploadRequestModalFile = useCallback(async () => {
|
||
const requestId = requestModal.requestId;
|
||
const file = requestModal.selectedFile;
|
||
if (!requestId || !file) return;
|
||
try {
|
||
setRequestModal((prev) => ({ ...prev, fileUploading: true }));
|
||
setStatus("requestModal", "Загрузка файла...", "");
|
||
const mimeType = String(file.type || "application/octet-stream");
|
||
const init = await api("/api/admin/uploads/init", {
|
||
method: "POST",
|
||
body: {
|
||
file_name: file.name,
|
||
mime_type: mimeType,
|
||
size_bytes: file.size,
|
||
scope: "REQUEST_ATTACHMENT",
|
||
request_id: requestId,
|
||
},
|
||
});
|
||
const putResp = await fetch(init.presigned_url, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": mimeType },
|
||
body: file,
|
||
});
|
||
if (!putResp.ok) throw new Error("Не удалось загрузить файл в хранилище");
|
||
await api("/api/admin/uploads/complete", {
|
||
method: "POST",
|
||
body: {
|
||
key: init.key,
|
||
file_name: file.name,
|
||
mime_type: mimeType,
|
||
size_bytes: file.size,
|
||
scope: "REQUEST_ATTACHMENT",
|
||
request_id: requestId,
|
||
},
|
||
});
|
||
setRequestModal((prev) => ({ ...prev, selectedFile: null, fileUploading: false }));
|
||
setStatus("requestModal", "Файл загружен", "ok");
|
||
await loadRequestModalData(requestId, { showLoading: false });
|
||
} catch (error) {
|
||
setRequestModal((prev) => ({ ...prev, fileUploading: false }));
|
||
setStatus("requestModal", "Ошибка загрузки: " + error.message, "error");
|
||
}
|
||
}, [api, loadRequestModalData, requestModal.requestId, requestModal.selectedFile, setStatus]);
|
||
|
||
const openCreateRecordModal = useCallback(
|
||
(tableKey) => {
|
||
const fields = getRecordFields(tableKey);
|
||
const initial = {};
|
||
fields.forEach((field) => {
|
||
if (field.defaultValue !== undefined) initial[field.key] = String(field.defaultValue);
|
||
else if (field.type === "boolean") initial[field.key] = "false";
|
||
else if (field.type === "json") initial[field.key] = field.optional ? "" : "{}";
|
||
else if ((field.type === "reference" || field.type === "enum") && !field.optional) {
|
||
const options = typeof field.options === "function" ? field.options() : [];
|
||
initial[field.key] = options.length ? String(options[0].value) : "";
|
||
}
|
||
else initial[field.key] = "";
|
||
});
|
||
if (tableKey === "requests" && !initial.status_code) initial.status_code = "NEW";
|
||
setRecordModal({ open: true, tableKey, mode: "create", rowId: null, form: initial });
|
||
setStatus("recordForm", "", "");
|
||
},
|
||
[getRecordFields, setStatus]
|
||
);
|
||
|
||
const openEditRecordModal = useCallback(
|
||
(tableKey, row) => {
|
||
const fields = getRecordFields(tableKey);
|
||
const nextForm = {};
|
||
fields.forEach((field) => {
|
||
const value = row[field.key];
|
||
if (field.type === "boolean") nextForm[field.key] = value ? "true" : "false";
|
||
else if (field.type === "json") nextForm[field.key] = value == null ? "" : JSON.stringify(value, null, 2);
|
||
else nextForm[field.key] = value == null ? "" : String(value);
|
||
});
|
||
setRecordModal({ open: true, tableKey, mode: "edit", rowId: row.id, form: nextForm });
|
||
setStatus("recordForm", "", "");
|
||
},
|
||
[getRecordFields, setStatus]
|
||
);
|
||
|
||
const closeRecordModal = useCallback(() => {
|
||
setRecordModal({ open: false, tableKey: null, mode: "create", rowId: null, form: {} });
|
||
setStatus("recordForm", "", "");
|
||
}, [setStatus]);
|
||
|
||
const updateRecordField = useCallback((field, value) => {
|
||
setRecordModal((prev) => ({ ...prev, form: { ...(prev.form || {}), [field]: value } }));
|
||
}, []);
|
||
|
||
const uploadRecordFieldFile = useCallback(
|
||
async (field, file) => {
|
||
if (!recordModal.tableKey || !field || !file) return;
|
||
if (field.uploadScope !== "USER_AVATAR") return;
|
||
if (recordModal.tableKey !== "users") return;
|
||
if (recordModal.mode !== "edit" || !recordModal.rowId) {
|
||
setStatus("recordForm", "Сначала сохраните пользователя, затем загрузите аватар", "error");
|
||
return;
|
||
}
|
||
try {
|
||
setStatus("recordForm", "Загрузка файла...", "");
|
||
const mimeType = String(file.type || "application/octet-stream");
|
||
const initPayload = {
|
||
file_name: file.name,
|
||
mime_type: mimeType,
|
||
size_bytes: file.size,
|
||
scope: "USER_AVATAR",
|
||
user_id: recordModal.rowId,
|
||
};
|
||
const init = await api("/api/admin/uploads/init", { method: "POST", body: initPayload });
|
||
const putResp = await fetch(init.presigned_url, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": mimeType },
|
||
body: file,
|
||
});
|
||
if (!putResp.ok) {
|
||
throw new Error("Не удалось загрузить файл в хранилище");
|
||
}
|
||
const done = await api("/api/admin/uploads/complete", {
|
||
method: "POST",
|
||
body: {
|
||
key: init.key,
|
||
file_name: file.name,
|
||
mime_type: mimeType,
|
||
size_bytes: file.size,
|
||
scope: "USER_AVATAR",
|
||
user_id: recordModal.rowId,
|
||
},
|
||
});
|
||
updateRecordField("avatar_url", String(done.avatar_url || ""));
|
||
setStatus("recordForm", "Аватар загружен", "ok");
|
||
} catch (error) {
|
||
setStatus("recordForm", "Ошибка загрузки: " + error.message, "error");
|
||
}
|
||
},
|
||
[api, recordModal, setStatus, updateRecordField]
|
||
);
|
||
|
||
const buildRecordPayload = useCallback(
|
||
(tableKey, form, mode) => {
|
||
const fields = getRecordFields(tableKey);
|
||
const payload = {};
|
||
const isLawyerRequestEdit = tableKey === "requests" && role === "LAWYER";
|
||
const lawyerRequestRestricted = new Set(["assigned_lawyer_id", "effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"]);
|
||
fields.forEach((field) => {
|
||
if (isLawyerRequestEdit && lawyerRequestRestricted.has(field.key)) return;
|
||
const raw = form[field.key];
|
||
if (field.type === "boolean") {
|
||
payload[field.key] = raw === "true";
|
||
return;
|
||
}
|
||
if (field.type === "number") {
|
||
if (raw === "" || raw == null) {
|
||
if (!field.optional) payload[field.key] = 0;
|
||
return;
|
||
}
|
||
const number = Number(raw);
|
||
if (Number.isNaN(number)) throw new Error("Некорректное число в поле \"" + field.label + "\"");
|
||
payload[field.key] = number;
|
||
return;
|
||
}
|
||
if (field.type === "json") {
|
||
const text = String(raw || "").trim();
|
||
if (!text) {
|
||
if (field.omitIfEmpty) return;
|
||
if (field.optional) payload[field.key] = null;
|
||
else payload[field.key] = {};
|
||
return;
|
||
}
|
||
try {
|
||
payload[field.key] = JSON.parse(text);
|
||
} catch (_) {
|
||
throw new Error("Поле \"" + field.label + "\" должно быть валидным JSON");
|
||
}
|
||
return;
|
||
}
|
||
|
||
const value = String(raw || "").trim();
|
||
if (!value) {
|
||
if (mode === "create" && field.autoCreate) return;
|
||
if (mode === "create" && field.requiredOnCreate) throw new Error("Заполните поле \"" + field.label + "\"");
|
||
if (field.required) throw new Error("Заполните поле \"" + field.label + "\"");
|
||
if (field.omitIfEmpty) return;
|
||
if (tableKey === "requests" && field.key === "track_number") return;
|
||
if (field.optional) payload[field.key] = null;
|
||
return;
|
||
}
|
||
payload[field.key] = value;
|
||
});
|
||
|
||
if (tableKey === "requests" && !payload.extra_fields) payload.extra_fields = {};
|
||
if (tableKey === "invoices" && mode === "edit") delete payload.request_track_number;
|
||
return payload;
|
||
},
|
||
[getRecordFields, role]
|
||
);
|
||
|
||
const submitRecordModal = useCallback(
|
||
async (event) => {
|
||
event.preventDefault();
|
||
const tableKey = recordModal.tableKey;
|
||
if (!tableKey) return;
|
||
const endpoints = resolveMutationConfig(tableKey);
|
||
if (!endpoints) return;
|
||
try {
|
||
setStatus("recordForm", "Сохранение...", "");
|
||
const payload = buildRecordPayload(tableKey, recordModal.form || {}, recordModal.mode);
|
||
if (recordModal.mode === "edit" && recordModal.rowId) {
|
||
await api(endpoints.update(recordModal.rowId), { method: "PATCH", body: payload });
|
||
} else {
|
||
await api(endpoints.create, { method: "POST", body: payload });
|
||
}
|
||
setStatus("recordForm", "Сохранено", "ok");
|
||
await loadTable(tableKey, { resetOffset: true });
|
||
setTimeout(() => closeRecordModal(), 250);
|
||
} catch (error) {
|
||
setStatus("recordForm", "Ошибка: " + error.message, "error");
|
||
}
|
||
},
|
||
[api, buildRecordPayload, closeRecordModal, loadTable, recordModal, resolveMutationConfig, setStatus]
|
||
);
|
||
|
||
const deleteRecord = useCallback(
|
||
async (tableKey, id) => {
|
||
const endpoints = resolveMutationConfig(tableKey);
|
||
if (!endpoints) return;
|
||
if (!confirm("Удалить запись?")) return;
|
||
try {
|
||
await api(endpoints.delete(id), { method: "DELETE" });
|
||
setStatus(tableKey, "Запись удалена", "ok");
|
||
await loadTable(tableKey, { resetOffset: true });
|
||
} catch (error) {
|
||
setStatus(tableKey, "Ошибка удаления: " + error.message, "error");
|
||
}
|
||
},
|
||
[api, loadTable, resolveMutationConfig, setStatus]
|
||
);
|
||
|
||
const claimRequest = useCallback(
|
||
async (requestId) => {
|
||
if (!requestId) return;
|
||
try {
|
||
setStatus("requests", "Назначение заявки...", "");
|
||
await api("/api/admin/requests/" + requestId + "/claim", { method: "POST" });
|
||
setStatus("requests", "Заявка взята в работу", "ok");
|
||
await loadTable("requests", { resetOffset: true });
|
||
} catch (error) {
|
||
setStatus("requests", "Ошибка назначения: " + error.message, "error");
|
||
}
|
||
},
|
||
[api, loadTable, setStatus]
|
||
);
|
||
|
||
const openInvoiceRequest = useCallback(
|
||
(row) => {
|
||
if (!row || !row.request_id) return;
|
||
openRequestDetails(row.request_id);
|
||
},
|
||
[openRequestDetails]
|
||
);
|
||
|
||
const downloadInvoicePdf = useCallback(
|
||
async (row) => {
|
||
if (!row || !row.id || !token) return;
|
||
try {
|
||
setStatus("invoices", "Формируем PDF...", "");
|
||
const response = await fetch("/api/admin/invoices/" + row.id + "/pdf", {
|
||
headers: { Authorization: "Bearer " + token },
|
||
});
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
let payload = {};
|
||
try {
|
||
payload = text ? JSON.parse(text) : {};
|
||
} catch (_) {
|
||
payload = { raw: text };
|
||
}
|
||
const message = payload.detail || payload.error || payload.raw || ("HTTP " + response.status);
|
||
throw new Error(translateApiError(String(message)));
|
||
}
|
||
const blob = await response.blob();
|
||
const fileName = (row.invoice_number || "invoice") + ".pdf";
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = fileName;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
URL.revokeObjectURL(url);
|
||
setStatus("invoices", "PDF скачан", "ok");
|
||
} catch (error) {
|
||
setStatus("invoices", "Ошибка скачивания: " + error.message, "error");
|
||
}
|
||
},
|
||
[setStatus, token]
|
||
);
|
||
|
||
const resetAdminRoute = useCallback(() => {
|
||
const nextUrl = "/admin.html";
|
||
if (window.location.pathname !== nextUrl || window.location.search) {
|
||
window.history.replaceState(null, "", nextUrl);
|
||
}
|
||
}, []);
|
||
|
||
const goBackFromRequestWorkspace = useCallback(() => {
|
||
resetAdminRoute();
|
||
setActiveSection("requests");
|
||
refreshSection("requests");
|
||
}, [refreshSection, resetAdminRoute]);
|
||
|
||
const openReassignModal = useCallback(
|
||
(row) => {
|
||
const options = getLawyerOptions();
|
||
if (!options.length) {
|
||
setStatus("reassignForm", "Нет доступных юристов для переназначения", "error");
|
||
return;
|
||
}
|
||
const current = String(row?.assigned_lawyer_id || "");
|
||
const hasCurrent = options.some((option) => String(option.value) === current);
|
||
const fallback = options[0] ? String(options[0].value) : "";
|
||
setReassignModal({
|
||
open: true,
|
||
requestId: row?.id || null,
|
||
trackNumber: row?.track_number || "",
|
||
lawyerId: hasCurrent ? current : fallback,
|
||
});
|
||
setStatus("reassignForm", "", "");
|
||
},
|
||
[getLawyerOptions, setStatus]
|
||
);
|
||
|
||
const closeReassignModal = useCallback(() => {
|
||
setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" });
|
||
setStatus("reassignForm", "", "");
|
||
}, [setStatus]);
|
||
|
||
const updateReassignLawyer = useCallback((event) => {
|
||
setReassignModal((prev) => ({ ...prev, lawyerId: event.target.value }));
|
||
}, []);
|
||
|
||
const submitReassignModal = useCallback(
|
||
async (event) => {
|
||
event.preventDefault();
|
||
if (!reassignModal.requestId) return;
|
||
const lawyerId = String(reassignModal.lawyerId || "").trim();
|
||
if (!lawyerId) {
|
||
setStatus("reassignForm", "Выберите юриста", "error");
|
||
return;
|
||
}
|
||
try {
|
||
setStatus("reassignForm", "Сохранение...", "");
|
||
await api("/api/admin/requests/" + reassignModal.requestId + "/reassign", {
|
||
method: "POST",
|
||
body: { lawyer_id: lawyerId },
|
||
});
|
||
setStatus("requests", "Заявка переназначена", "ok");
|
||
closeReassignModal();
|
||
await loadTable("requests", { resetOffset: true });
|
||
} catch (error) {
|
||
setStatus("reassignForm", "Ошибка: " + error.message, "error");
|
||
}
|
||
},
|
||
[api, closeReassignModal, loadTable, reassignModal.lawyerId, reassignModal.requestId, 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) => {
|
||
resetAdminRoute();
|
||
setConfigActiveKey(tableKey);
|
||
setActiveSection("config");
|
||
loadCurrentConfigTable(false, undefined, tableKey);
|
||
},
|
||
[loadCurrentConfigTable, resetAdminRoute]
|
||
);
|
||
|
||
const refreshAll = useCallback(() => {
|
||
refreshSection(activeSection);
|
||
}, [activeSection, refreshSection]);
|
||
|
||
const activateSection = useCallback(
|
||
(section) => {
|
||
const nextSection = canAccessSection(role, section) ? section : "dashboard";
|
||
resetAdminRoute();
|
||
setActiveSection(nextSection);
|
||
refreshSection(nextSection);
|
||
},
|
||
[refreshSection, resetAdminRoute, role]
|
||
);
|
||
|
||
const logout = useCallback(() => {
|
||
localStorage.removeItem(LS_TOKEN);
|
||
setToken("");
|
||
setRole("");
|
||
setEmail("");
|
||
setRecordModal({ open: false, tableKey: null, mode: "create", rowId: null, form: {} });
|
||
setRequestModal(createRequestModalState());
|
||
setFilterModal({ open: false, tableKey: null, field: "", op: "=", rawValue: "", editIndex: null });
|
||
setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" });
|
||
setDashboardData({ scope: "", cards: [], byStatus: {}, lawyerLoads: [], myUnreadByEvent: {} });
|
||
setMetaJson("");
|
||
setConfigActiveKey("");
|
||
setReferencesExpanded(true);
|
||
setTableCatalog([]);
|
||
setTables({
|
||
requests: createTableState(),
|
||
invoices: createTableState(),
|
||
quotes: createTableState(),
|
||
topics: createTableState(),
|
||
statuses: createTableState(),
|
||
formFields: createTableState(),
|
||
topicRequiredFields: createTableState(),
|
||
topicDataTemplates: createTableState(),
|
||
statusTransitions: createTableState(),
|
||
users: createTableState(),
|
||
userTopics: createTableState(),
|
||
availableTables: createTableState(),
|
||
});
|
||
setDictionaries({
|
||
topics: [],
|
||
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })),
|
||
formFieldTypes: [...DEFAULT_FORM_FIELD_TYPES],
|
||
formFieldKeys: [],
|
||
users: [],
|
||
});
|
||
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 loadDashboard(token);
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [bootstrapReferenceData, loadDashboard, role, token]);
|
||
|
||
useEffect(() => {
|
||
if (!token || !role) return;
|
||
if (initialRouteHandledRef.current) return;
|
||
initialRouteHandledRef.current = true;
|
||
if (isRequestWorkspaceRoute && routeInfo.requestId) {
|
||
setActiveSection("requestWorkspace");
|
||
loadRequestModalData(routeInfo.requestId, { showLoading: true });
|
||
resetAdminRoute();
|
||
return;
|
||
}
|
||
if (routeInfo.section) {
|
||
if (canAccessSection(role, routeInfo.section)) {
|
||
setActiveSection(routeInfo.section);
|
||
refreshSection(routeInfo.section, token);
|
||
resetAdminRoute();
|
||
} else {
|
||
setActiveSection("dashboard");
|
||
refreshSection("dashboard", token);
|
||
resetAdminRoute();
|
||
}
|
||
}
|
||
}, [isRequestWorkspaceRoute, loadRequestModalData, refreshSection, resetAdminRoute, role, routeInfo.requestId, routeInfo.section, token]);
|
||
|
||
useEffect(() => {
|
||
if (!dictionaryTableItems.length) {
|
||
if (configActiveKey) setConfigActiveKey("");
|
||
return;
|
||
}
|
||
const hasCurrent = dictionaryTableItems.some((item) => item.key === configActiveKey);
|
||
if (!hasCurrent) setConfigActiveKey(dictionaryTableItems[0].key);
|
||
}, [configActiveKey, dictionaryTableItems]);
|
||
|
||
const anyOverlayOpen = recordModal.open || filterModal.open || reassignModal.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;
|
||
setRecordModal((prev) => ({ ...prev, open: false }));
|
||
setFilterModal((prev) => ({ ...prev, open: false }));
|
||
setReassignModal((prev) => ({ ...prev, open: false }));
|
||
};
|
||
document.addEventListener("keydown", onEsc);
|
||
return () => document.removeEventListener("keydown", onEsc);
|
||
}, []);
|
||
|
||
const menuItems = useMemo(() => {
|
||
return [
|
||
{ key: "dashboard", label: "Обзор" },
|
||
{ key: "requests", label: "Заявки" },
|
||
{ key: "invoices", label: "Счета" },
|
||
{ key: "meta", label: "Метаданные" },
|
||
];
|
||
}, []);
|
||
|
||
const activeFilterFields = useMemo(() => {
|
||
if (!filterModal.tableKey) return [];
|
||
return getFilterFields(filterModal.tableKey);
|
||
}, [filterModal.tableKey, getFilterFields]);
|
||
|
||
const filterTableLabel = useMemo(() => getTableLabel(filterModal.tableKey), [filterModal.tableKey, getTableLabel]);
|
||
|
||
const recordModalFields = useMemo(() => {
|
||
const all = getRecordFields(recordModal.tableKey);
|
||
if (recordModal.mode !== "create") return all.filter((field) => !field.createOnly);
|
||
return all.filter((field) => !field.autoCreate);
|
||
}, [getRecordFields, recordModal.mode, recordModal.tableKey]);
|
||
|
||
const activeConfigTableState = useMemo(() => {
|
||
return tables[configActiveKey] || createTableState();
|
||
}, [configActiveKey, tables]);
|
||
|
||
const activeConfigMeta = useMemo(() => tableCatalogMap[configActiveKey] || null, [configActiveKey, tableCatalogMap]);
|
||
const activeConfigActions = useMemo(() => {
|
||
return Array.isArray(activeConfigMeta?.actions) ? activeConfigMeta.actions : [];
|
||
}, [activeConfigMeta]);
|
||
const canCreateInConfig = activeConfigActions.includes("create");
|
||
const canUpdateInConfig = activeConfigActions.includes("update");
|
||
const canDeleteInConfig = activeConfigActions.includes("delete");
|
||
|
||
const genericConfigHeaders = useMemo(() => {
|
||
if (!activeConfigMeta || !Array.isArray(activeConfigMeta.columns)) return [];
|
||
const headers = (activeConfigMeta.columns || [])
|
||
.filter((column) => column && column.name)
|
||
.map((column) => {
|
||
const name = String(column.name);
|
||
return {
|
||
key: name,
|
||
label: String(column.label || humanizeKey(name)),
|
||
sortable: Boolean(column.sortable !== false),
|
||
field: name,
|
||
};
|
||
});
|
||
if (canUpdateInConfig || canDeleteInConfig) headers.push({ key: "actions", label: "Действия" });
|
||
return headers;
|
||
}, [activeConfigMeta, canDeleteInConfig, canUpdateInConfig]);
|
||
|
||
return (
|
||
<>
|
||
<div className="layout">
|
||
<aside className="sidebar">
|
||
<div className="logo">
|
||
<a href="/">Правовой трекер</a>
|
||
</div>
|
||
<nav className="menu">
|
||
{menuItems.map((item) => (
|
||
<button
|
||
key={item.key}
|
||
className={activeSection === item.key ? "active" : ""}
|
||
data-section={item.key}
|
||
type="button"
|
||
onClick={() => activateSection(item.key)}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
{role === "ADMIN" ? (
|
||
<>
|
||
<button
|
||
className={activeSection === "config" ? "active" : ""}
|
||
type="button"
|
||
onClick={() => {
|
||
setReferencesExpanded((prev) => !prev);
|
||
activateSection("config");
|
||
}}
|
||
>
|
||
{"Справочники " + (referencesExpanded ? "▾" : "▸")}
|
||
</button>
|
||
{referencesExpanded ? (
|
||
<div className="menu-tree">
|
||
{dictionaryTableItems.map((item) => (
|
||
<button
|
||
key={item.key}
|
||
type="button"
|
||
className={activeSection === "config" && configActiveKey === item.key ? "active" : ""}
|
||
onClick={() => selectConfigNode(item.key)}
|
||
>
|
||
{getTableLabel(item.key)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
</nav>
|
||
<div className="auth-box">
|
||
{token && role ? (
|
||
<>
|
||
Пользователь: <b>{email}</b>
|
||
<br />
|
||
Роль: <b>{roleLabel(role)}</b>
|
||
</>
|
||
) : (
|
||
"Не авторизован"
|
||
)}
|
||
</div>
|
||
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||
<button className="btn secondary" type="button" onClick={refreshAll}>
|
||
Обновить
|
||
</button>
|
||
<button className="btn danger" type="button" onClick={logout}>
|
||
Выйти
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<main className="main">
|
||
<div className="topbar">
|
||
<div>
|
||
<h1>Панель администратора</h1>
|
||
<p className="muted">UniversalQuery, RBAC и аудит действий по ключевым сущностям системы.</p>
|
||
</div>
|
||
<span className="badge">роль: {roleLabel(role)}</span>
|
||
</div>
|
||
|
||
<Section active={activeSection === "dashboard"} id="section-dashboard">
|
||
<div className="section-head">
|
||
<div>
|
||
<h2>Обзор метрик</h2>
|
||
<p className="muted">Состояние заявок и SLA-мониторинг.</p>
|
||
</div>
|
||
</div>
|
||
<div className="cards">
|
||
{dashboardData.cards.map((card) => (
|
||
<div className="card" key={card.label}>
|
||
<p>{card.label}</p>
|
||
<b>{card.value}</b>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="json">{JSON.stringify(dashboardData.byStatus || {}, null, 2)}</div>
|
||
{dashboardData.scope === "LAWYER" ? (
|
||
<div className="json" style={{ marginTop: "0.5rem" }}>
|
||
{JSON.stringify(dashboardData.myUnreadByEvent || {}, null, 2)}
|
||
</div>
|
||
) : null}
|
||
<div style={{ marginTop: "0.85rem" }}>
|
||
<h3 style={{ margin: "0 0 0.55rem" }}>Загрузка юристов</h3>
|
||
<DataTable
|
||
headers={[
|
||
{ key: "name", label: "Юрист" },
|
||
{ key: "email", label: "Email" },
|
||
{ key: "primary_topic_code", label: "Основная тема" },
|
||
{ key: "active_load", label: "Активные заявки" },
|
||
{ key: "total_assigned", label: "Всего назначено" },
|
||
{ key: "active_amount", label: "Сумма активных" },
|
||
{ key: "monthly_paid_gross", label: "Вал оплат за месяц" },
|
||
{ key: "monthly_salary", label: "Зарплата за месяц" },
|
||
]}
|
||
rows={dashboardData.lawyerLoads || []}
|
||
emptyColspan={8}
|
||
renderRow={(row) => (
|
||
<tr key={row.lawyer_id}>
|
||
<td>
|
||
<div className="user-identity">
|
||
<UserAvatar name={row.name} email={row.email} avatarUrl={row.avatar_url} accessToken={token} size={32} />
|
||
<div className="user-identity-text">
|
||
<b>{row.name || "-"}</b>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>{row.email || "-"}</td>
|
||
<td>{row.primary_topic_code || "-"}</td>
|
||
<td>{String(row.active_load ?? 0)}</td>
|
||
<td>{String(row.total_assigned ?? 0)}</td>
|
||
<td>{String(row.active_amount ?? 0)}</td>
|
||
<td>{String(row.monthly_paid_gross ?? 0)}</td>
|
||
<td>{String(row.monthly_salary ?? 0)}</td>
|
||
</tr>
|
||
)}
|
||
/>
|
||
</div>
|
||
<StatusLine status={getStatus("dashboard")} />
|
||
</Section>
|
||
|
||
<Section active={activeSection === "requests"} id="section-requests">
|
||
<div className="section-head">
|
||
<div>
|
||
<h2>Заявки</h2>
|
||
<p className="muted">Серверная фильтрация и просмотр клиентских заявок.</p>
|
||
</div>
|
||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||
<button className="btn secondary" type="button" onClick={() => loadTable("requests", { resetOffset: true })}>
|
||
Обновить
|
||
</button>
|
||
<button className="btn" type="button" onClick={() => openCreateRecordModal("requests")}>
|
||
Новая заявка
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<FilterToolbar
|
||
filters={tables.requests.filters}
|
||
onOpen={() => 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);
|
||
}}
|
||
/>
|
||
<DataTable
|
||
headers={[
|
||
{ key: "track_number", label: "Номер", sortable: true, field: "track_number" },
|
||
{ key: "client_name", label: "Клиент", sortable: true, field: "client_name" },
|
||
{ key: "client_phone", label: "Телефон", sortable: true, field: "client_phone" },
|
||
{ key: "status_code", label: "Статус", sortable: true, field: "status_code" },
|
||
{ key: "topic_code", label: "Тема", sortable: true, field: "topic_code" },
|
||
{ key: "assigned_lawyer_id", label: "Назначен", sortable: true, field: "assigned_lawyer_id" },
|
||
{ key: "invoice_amount", label: "Счет", sortable: true, field: "invoice_amount" },
|
||
{ key: "paid_at", label: "Оплачено", sortable: true, field: "paid_at" },
|
||
{ key: "updates", label: "Обновления" },
|
||
{ key: "created_at", label: "Создана", sortable: true, field: "created_at" },
|
||
{ key: "actions", label: "Действия" },
|
||
]}
|
||
rows={tables.requests.rows}
|
||
emptyColspan={11}
|
||
onSort={(field) => toggleTableSort("requests", field)}
|
||
sortClause={(tables.requests.sort && tables.requests.sort[0]) || TABLE_SERVER_CONFIG.requests.sort[0]}
|
||
renderRow={(row) => (
|
||
<tr key={row.id}>
|
||
<td>
|
||
<code>{row.track_number || "-"}</code>
|
||
</td>
|
||
<td>{row.client_name || "-"}</td>
|
||
<td>{row.client_phone || "-"}</td>
|
||
<td>{statusLabel(row.status_code)}</td>
|
||
<td>{row.topic_code || "-"}</td>
|
||
<td>{row.assigned_lawyer_id || "-"}</td>
|
||
<td>{row.invoice_amount == null ? "-" : String(row.invoice_amount)}</td>
|
||
<td>{fmtDate(row.paid_at)}</td>
|
||
<td>{renderRequestUpdatesCell(row, role)}</td>
|
||
<td>{fmtDate(row.created_at)}</td>
|
||
<td>
|
||
<div className="table-actions">
|
||
{role === "LAWYER" && !row.assigned_lawyer_id ? (
|
||
<IconButton icon="📥" tooltip="Взять в работу" onClick={() => claimRequest(row.id)} />
|
||
) : null}
|
||
{role === "ADMIN" && row.assigned_lawyer_id ? (
|
||
<IconButton icon="⇄" tooltip="Переназначить" onClick={() => openReassignModal(row)} />
|
||
) : null}
|
||
<IconButton icon="👁" tooltip="Открыть заявку" onClick={() => openRequestDetails(row.id)} />
|
||
<IconButton icon="✎" tooltip="Редактировать заявку" onClick={() => openEditRecordModal("requests", row)} />
|
||
<IconButton icon="🗑" tooltip="Удалить заявку" onClick={() => deleteRecord("requests", row.id)} tone="danger" />
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
/>
|
||
<TablePager
|
||
tableState={tables.requests}
|
||
onPrev={() => loadPrevPage("requests")}
|
||
onNext={() => loadNextPage("requests")}
|
||
onLoadAll={() => loadAllRows("requests")}
|
||
/>
|
||
<StatusLine status={getStatus("requests")} />
|
||
</Section>
|
||
|
||
<Section active={activeSection === "requestWorkspace"} id="section-request-workspace">
|
||
<div className="section-head">
|
||
<div>
|
||
<h2>Карточка заявки</h2>
|
||
<p className="muted">Рабочая вкладка юриста/администратора по заявке.</p>
|
||
</div>
|
||
</div>
|
||
<RequestWorkspace
|
||
loading={requestModal.loading}
|
||
trackNumber={requestModal.trackNumber}
|
||
requestData={requestModal.requestData}
|
||
statusRouteNodes={requestModal.statusRouteNodes}
|
||
messages={requestModal.messages || []}
|
||
attachments={requestModal.attachments || []}
|
||
messageDraft={requestModal.messageDraft || ""}
|
||
selectedFile={requestModal.selectedFile}
|
||
fileUploading={Boolean(requestModal.fileUploading)}
|
||
status={getStatus("requestModal")}
|
||
onBack={goBackFromRequestWorkspace}
|
||
onRefresh={refreshRequestModal}
|
||
onMessageChange={updateRequestModalMessageDraft}
|
||
onSendMessage={submitRequestModalMessage}
|
||
onFileSelect={updateRequestModalFile}
|
||
onUploadFile={uploadRequestModalFile}
|
||
/>
|
||
</Section>
|
||
|
||
<Section active={activeSection === "invoices"} id="section-invoices">
|
||
<div className="section-head">
|
||
<div>
|
||
<h2>Счета</h2>
|
||
<p className="muted">Выставленные счета клиентам, статусы оплаты и выгрузка PDF.</p>
|
||
</div>
|
||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||
<button className="btn secondary" type="button" onClick={() => loadTable("invoices", { resetOffset: true })}>
|
||
Обновить
|
||
</button>
|
||
<button className="btn" type="button" onClick={() => openCreateRecordModal("invoices")}>
|
||
Новый счет
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<FilterToolbar
|
||
filters={tables.invoices.filters}
|
||
onOpen={() => openFilterModal("invoices")}
|
||
onRemove={(index) => removeFilterChip("invoices", index)}
|
||
onEdit={(index) => openFilterEditModal("invoices", index)}
|
||
getChipLabel={(clause) => {
|
||
const fieldDef = getFieldDef("invoices", clause.field);
|
||
return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("invoices", clause);
|
||
}}
|
||
/>
|
||
<DataTable
|
||
headers={[
|
||
{ key: "invoice_number", label: "Номер", sortable: true, field: "invoice_number" },
|
||
{ key: "status", label: "Статус", sortable: true, field: "status" },
|
||
{ key: "amount", label: "Сумма", sortable: true, field: "amount" },
|
||
{ key: "payer_display_name", label: "Плательщик", sortable: true, field: "payer_display_name" },
|
||
{ key: "request_track_number", label: "Заявка" },
|
||
{ key: "issued_by_name", label: "Выставил", sortable: true, field: "issued_by_admin_user_id" },
|
||
{ key: "issued_at", label: "Сформирован", sortable: true, field: "issued_at" },
|
||
{ key: "paid_at", label: "Оплачен", sortable: true, field: "paid_at" },
|
||
{ key: "actions", label: "Действия" },
|
||
]}
|
||
rows={tables.invoices.rows}
|
||
emptyColspan={9}
|
||
onSort={(field) => toggleTableSort("invoices", field)}
|
||
sortClause={(tables.invoices.sort && tables.invoices.sort[0]) || TABLE_SERVER_CONFIG.invoices.sort[0]}
|
||
renderRow={(row) => (
|
||
<tr key={row.id}>
|
||
<td>
|
||
<code>{row.invoice_number || "-"}</code>
|
||
</td>
|
||
<td>{row.status_label || invoiceStatusLabel(row.status)}</td>
|
||
<td>{row.amount == null ? "-" : String(row.amount) + " " + String(row.currency || "RUB")}</td>
|
||
<td>{row.payer_display_name || "-"}</td>
|
||
<td>{row.request_track_number || row.request_id || "-"}</td>
|
||
<td>{row.issued_by_name || "-"}</td>
|
||
<td>{fmtDate(row.issued_at)}</td>
|
||
<td>{fmtDate(row.paid_at)}</td>
|
||
<td>
|
||
<div className="table-actions">
|
||
<IconButton icon="👁" tooltip="Открыть заявку" onClick={() => openInvoiceRequest(row)} />
|
||
<IconButton icon="⬇" tooltip="Скачать PDF" onClick={() => downloadInvoicePdf(row)} />
|
||
<IconButton icon="✎" tooltip="Редактировать счет" onClick={() => openEditRecordModal("invoices", row)} />
|
||
{role === "ADMIN" ? (
|
||
<IconButton icon="🗑" tooltip="Удалить счет" onClick={() => deleteRecord("invoices", row.id)} tone="danger" />
|
||
) : null}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
/>
|
||
<TablePager
|
||
tableState={tables.invoices}
|
||
onPrev={() => loadPrevPage("invoices")}
|
||
onNext={() => loadNextPage("invoices")}
|
||
onLoadAll={() => loadAllRows("invoices")}
|
||
/>
|
||
<StatusLine status={getStatus("invoices")} />
|
||
</Section>
|
||
|
||
<Section active={activeSection === "quotes"} id="section-quotes">
|
||
<div className="section-head">
|
||
<div>
|
||
<h2>Цитаты</h2>
|
||
<p className="muted">Управление публичной лентой цитат с серверными фильтрами.</p>
|
||
</div>
|
||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||
<button className="btn secondary" type="button" onClick={() => loadTable("quotes", { resetOffset: true })}>
|
||
Обновить
|
||
</button>
|
||
<button className="btn" type="button" onClick={() => openCreateRecordModal("quotes")}>
|
||
Новая цитата
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<FilterToolbar
|
||
filters={tables.quotes.filters}
|
||
onOpen={() => 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);
|
||
}}
|
||
/>
|
||
<DataTable
|
||
headers={[
|
||
{ key: "author", label: "Автор", sortable: true, field: "author" },
|
||
{ key: "text", label: "Текст", sortable: true, field: "text" },
|
||
{ key: "source", label: "Источник", sortable: true, field: "source" },
|
||
{ key: "is_active", label: "Активна", sortable: true, field: "is_active" },
|
||
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
|
||
{ key: "created_at", label: "Создана", sortable: true, field: "created_at" },
|
||
{ key: "actions", label: "Действия" },
|
||
]}
|
||
rows={tables.quotes.rows}
|
||
emptyColspan={7}
|
||
onSort={(field) => toggleTableSort("quotes", field)}
|
||
sortClause={(tables.quotes.sort && tables.quotes.sort[0]) || TABLE_SERVER_CONFIG.quotes.sort[0]}
|
||
renderRow={(row) => (
|
||
<tr key={row.id}>
|
||
<td>{row.author || "-"}</td>
|
||
<td>{row.text || "-"}</td>
|
||
<td>{row.source || "-"}</td>
|
||
<td>{boolLabel(row.is_active)}</td>
|
||
<td>{String(row.sort_order ?? 0)}</td>
|
||
<td>{fmtDate(row.created_at)}</td>
|
||
<td>
|
||
<div className="table-actions">
|
||
<IconButton icon="✎" tooltip="Редактировать цитату" onClick={() => openEditRecordModal("quotes", row)} />
|
||
<IconButton icon="🗑" tooltip="Удалить цитату" onClick={() => deleteRecord("quotes", row.id)} tone="danger" />
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
/>
|
||
<TablePager
|
||
tableState={tables.quotes}
|
||
onPrev={() => loadPrevPage("quotes")}
|
||
onNext={() => loadNextPage("quotes")}
|
||
onLoadAll={() => loadAllRows("quotes")}
|
||
/>
|
||
<StatusLine status={getStatus("quotes")} />
|
||
</Section>
|
||
|
||
<Section active={activeSection === "config"} id="section-config">
|
||
<div className="section-head">
|
||
<div>
|
||
<h2>Справочники</h2>
|
||
<p className="breadcrumbs">{"Справочники -> " + (configActiveKey ? getTableLabel(configActiveKey) : "Справочник не выбран")}</p>
|
||
<p className="muted">Выберите справочник в дереве слева.</p>
|
||
</div>
|
||
<button className="btn secondary" type="button" onClick={() => loadCurrentConfigTable(true)}>
|
||
Обновить
|
||
</button>
|
||
</div>
|
||
<div className="config-layout">
|
||
<div className="config-panel">
|
||
<div className="block">
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||
<h3 style={{ margin: 0 }}>{configActiveKey ? getTableLabel(configActiveKey) : "Справочник не выбран"}</h3>
|
||
{canCreateInConfig && configActiveKey ? (
|
||
<button className="btn" type="button" onClick={() => openCreateRecordModal(configActiveKey)}>
|
||
Добавить
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<FilterToolbar
|
||
filters={activeConfigTableState.filters}
|
||
onOpen={() => 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" ? (
|
||
<DataTable
|
||
headers={[
|
||
{ key: "code", label: "Код", sortable: true, field: "code" },
|
||
{ key: "name", label: "Название", sortable: true, field: "name" },
|
||
{ key: "enabled", label: "Активна", sortable: true, field: "enabled" },
|
||
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
|
||
{ key: "actions", label: "Действия" },
|
||
]}
|
||
rows={tables.topics.rows}
|
||
emptyColspan={5}
|
||
onSort={(field) => toggleTableSort("topics", field)}
|
||
sortClause={(tables.topics.sort && tables.topics.sort[0]) || TABLE_SERVER_CONFIG.topics.sort[0]}
|
||
renderRow={(row) => (
|
||
<tr key={row.id}>
|
||
<td>
|
||
<code>{row.code || "-"}</code>
|
||
</td>
|
||
<td>{row.name || "-"}</td>
|
||
<td>{boolLabel(row.enabled)}</td>
|
||
<td>{String(row.sort_order ?? 0)}</td>
|
||
<td>
|
||
<div className="table-actions">
|
||
<IconButton icon="✎" tooltip="Редактировать тему" onClick={() => openEditRecordModal("topics", row)} />
|
||
<IconButton icon="🗑" tooltip="Удалить тему" onClick={() => deleteRecord("topics", row.id)} tone="danger" />
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
/>
|
||
) : null}
|
||
{configActiveKey === "quotes" ? (
|
||
<DataTable
|
||
headers={[
|
||
{ key: "author", label: "Автор", sortable: true, field: "author" },
|
||
{ key: "text", label: "Текст", sortable: true, field: "text" },
|
||
{ key: "source", label: "Источник", sortable: true, field: "source" },
|
||
{ key: "is_active", label: "Активна", sortable: true, field: "is_active" },
|
||
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
|
||
{ key: "created_at", label: "Создана", sortable: true, field: "created_at" },
|
||
{ key: "actions", label: "Действия" },
|
||
]}
|
||
rows={tables.quotes.rows}
|
||
emptyColspan={7}
|
||
onSort={(field) => toggleTableSort("quotes", field)}
|
||
sortClause={(tables.quotes.sort && tables.quotes.sort[0]) || TABLE_SERVER_CONFIG.quotes.sort[0]}
|
||
renderRow={(row) => (
|
||
<tr key={row.id}>
|
||
<td>{row.author || "-"}</td>
|
||
<td>{row.text || "-"}</td>
|
||
<td>{row.source || "-"}</td>
|
||
<td>{boolLabel(row.is_active)}</td>
|
||
<td>{String(row.sort_order ?? 0)}</td>
|
||
<td>{fmtDate(row.created_at)}</td>
|
||
<td>
|
||
<div className="table-actions">
|
||
<IconButton icon="✎" tooltip="Редактировать цитату" onClick={() => openEditRecordModal("quotes", row)} />
|
||
<IconButton icon="🗑" tooltip="Удалить цитату" onClick={() => deleteRecord("quotes", row.id)} tone="danger" />
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
/>
|
||
) : null}
|
||
{configActiveKey === "statuses" ? (
|
||
<DataTable
|
||
headers={[
|
||
{ key: "code", label: "Код", sortable: true, field: "code" },
|
||
{ key: "name", label: "Название", sortable: true, field: "name" },
|
||
{ key: "kind", label: "Тип", sortable: true, field: "kind" },
|
||
{ key: "enabled", label: "Активен", sortable: true, field: "enabled" },
|
||
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
|
||
{ key: "is_terminal", label: "Терминальный", sortable: true, field: "is_terminal" },
|
||
{ key: "invoice_template", label: "Шаблон счета" },
|
||
{ key: "actions", label: "Действия" },
|
||
]}
|
||
rows={tables.statuses.rows}
|
||
emptyColspan={8}
|
||
onSort={(field) => toggleTableSort("statuses", field)}
|
||
sortClause={(tables.statuses.sort && tables.statuses.sort[0]) || TABLE_SERVER_CONFIG.statuses.sort[0]}
|
||
renderRow={(row) => (
|
||
<tr key={row.id}>
|
||
<td>
|
||
<code>{row.code || "-"}</code>
|
||
</td>
|
||
<td>{row.name || "-"}</td>
|
||
<td>{statusKindLabel(row.kind)}</td>
|
||
<td>{boolLabel(row.enabled)}</td>
|
||
<td>{String(row.sort_order ?? 0)}</td>
|
||
<td>{boolLabel(row.is_terminal)}</td>
|
||
<td>{row.invoice_template || "-"}</td>
|
||
<td>
|
||
<div className="table-actions">
|
||
<IconButton icon="✎" tooltip="Редактировать статус" onClick={() => openEditRecordModal("statuses", row)} />
|
||
<IconButton icon="🗑" tooltip="Удалить статус" onClick={() => deleteRecord("statuses", row.id)} tone="danger" />
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
/>
|
||
) : null}
|
||
{configActiveKey === "formFields" ? (
|
||
<DataTable
|
||
headers={[
|
||
{ key: "key", label: "Ключ", sortable: true, field: "key" },
|
||
{ key: "label", label: "Метка", sortable: true, field: "label" },
|
||
{ key: "type", label: "Тип", sortable: true, field: "type" },
|
||
{ key: "required", label: "Обязательное", sortable: true, field: "required" },
|
||
{ key: "enabled", label: "Активно", sortable: true, field: "enabled" },
|
||
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
|
||
{ key: "actions", label: "Действия" },
|
||
]}
|
||
rows={tables.formFields.rows}
|
||
emptyColspan={7}
|
||
onSort={(field) => toggleTableSort("formFields", field)}
|
||
sortClause={(tables.formFields.sort && tables.formFields.sort[0]) || TABLE_SERVER_CONFIG.formFields.sort[0]}
|
||
renderRow={(row) => (
|
||
<tr key={row.id}>
|
||
<td>
|
||
<code>{row.key || "-"}</code>
|
||
</td>
|
||
<td>{row.label || "-"}</td>
|
||
<td>{row.type || "-"}</td>
|
||
<td>{boolLabel(row.required)}</td>
|
||
<td>{boolLabel(row.enabled)}</td>
|
||
<td>{String(row.sort_order ?? 0)}</td>
|
||
<td>
|
||
<div className="table-actions">
|
||
<IconButton icon="✎" tooltip="Редактировать поле формы" onClick={() => openEditRecordModal("formFields", row)} />
|
||
<IconButton icon="🗑" tooltip="Удалить поле формы" onClick={() => deleteRecord("formFields", row.id)} tone="danger" />
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
/>
|
||
) : null}
|
||
{configActiveKey === "topicRequiredFields" ? (
|
||
<DataTable
|
||
headers={[
|
||
{ key: "topic_code", label: "Тема", sortable: true, field: "topic_code" },
|
||
{ key: "field_key", label: "Поле формы", sortable: true, field: "field_key" },
|
||
{ key: "required", label: "Обязательное", sortable: true, field: "required" },
|
||
{ key: "enabled", label: "Активно", sortable: true, field: "enabled" },
|
||
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
|
||
{ key: "created_at", label: "Создано", sortable: true, field: "created_at" },
|
||
{ key: "actions", label: "Действия" },
|
||
]}
|
||
rows={tables.topicRequiredFields.rows}
|
||
emptyColspan={7}
|
||
onSort={(field) => toggleTableSort("topicRequiredFields", field)}
|
||
sortClause={
|
||
(tables.topicRequiredFields.sort && tables.topicRequiredFields.sort[0]) ||
|
||
TABLE_SERVER_CONFIG.topicRequiredFields.sort[0]
|
||
}
|
||
renderRow={(row) => (
|
||
<tr key={row.id}>
|
||
<td>{row.topic_code || "-"}</td>
|
||
<td>
|
||
<code>{row.field_key || "-"}</code>
|
||
</td>
|
||
<td>{boolLabel(row.required)}</td>
|
||
<td>{boolLabel(row.enabled)}</td>
|
||
<td>{String(row.sort_order ?? 0)}</td>
|
||
<td>{fmtDate(row.created_at)}</td>
|
||
<td>
|
||
<div className="table-actions">
|
||
<IconButton
|
||
icon="✎"
|
||
tooltip="Редактировать обязательное поле"
|
||
onClick={() => openEditRecordModal("topicRequiredFields", row)}
|
||
/>
|
||
<IconButton
|
||
icon="🗑"
|
||
tooltip="Удалить обязательное поле"
|
||
onClick={() => deleteRecord("topicRequiredFields", row.id)}
|
||
tone="danger"
|
||
/>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
/>
|
||
) : null}
|
||
{configActiveKey === "topicDataTemplates" ? (
|
||
<DataTable
|
||
headers={[
|
||
{ key: "topic_code", label: "Тема", sortable: true, field: "topic_code" },
|
||
{ key: "key", label: "Ключ", sortable: true, field: "key" },
|
||
{ key: "label", label: "Метка", sortable: true, field: "label" },
|
||
{ key: "description", label: "Описание", sortable: true, field: "description" },
|
||
{ key: "required", label: "Обязательное", sortable: true, field: "required" },
|
||
{ key: "enabled", label: "Активно", sortable: true, field: "enabled" },
|
||
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
|
||
{ key: "created_at", label: "Создано", sortable: true, field: "created_at" },
|
||
{ key: "actions", label: "Действия" },
|
||
]}
|
||
rows={tables.topicDataTemplates.rows}
|
||
emptyColspan={9}
|
||
onSort={(field) => toggleTableSort("topicDataTemplates", field)}
|
||
sortClause={
|
||
(tables.topicDataTemplates.sort && tables.topicDataTemplates.sort[0]) ||
|
||
TABLE_SERVER_CONFIG.topicDataTemplates.sort[0]
|
||
}
|
||
renderRow={(row) => (
|
||
<tr key={row.id}>
|
||
<td>{row.topic_code || "-"}</td>
|
||
<td>
|
||
<code>{row.key || "-"}</code>
|
||
</td>
|
||
<td>{row.label || "-"}</td>
|
||
<td>{row.description || "-"}</td>
|
||
<td>{boolLabel(row.required)}</td>
|
||
<td>{boolLabel(row.enabled)}</td>
|
||
<td>{String(row.sort_order ?? 0)}</td>
|
||
<td>{fmtDate(row.created_at)}</td>
|
||
<td>
|
||
<div className="table-actions">
|
||
<IconButton icon="✎" tooltip="Редактировать шаблон" onClick={() => openEditRecordModal("topicDataTemplates", row)} />
|
||
<IconButton icon="🗑" tooltip="Удалить шаблон" onClick={() => deleteRecord("topicDataTemplates", row.id)} tone="danger" />
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
/>
|
||
) : null}
|
||
{configActiveKey === "statusTransitions" ? (
|
||
<DataTable
|
||
headers={[
|
||
{ key: "topic_code", label: "Тема", sortable: true, field: "topic_code" },
|
||
{ key: "from_status", label: "Из статуса", sortable: true, field: "from_status" },
|
||
{ key: "to_status", label: "В статус", sortable: true, field: "to_status" },
|
||
{ key: "sla_hours", label: "SLA (часы)", sortable: true, field: "sla_hours" },
|
||
{ key: "enabled", label: "Активен", sortable: true, field: "enabled" },
|
||
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
|
||
{ key: "actions", label: "Действия" },
|
||
]}
|
||
rows={tables.statusTransitions.rows}
|
||
emptyColspan={7}
|
||
onSort={(field) => toggleTableSort("statusTransitions", field)}
|
||
sortClause={
|
||
(tables.statusTransitions.sort && tables.statusTransitions.sort[0]) || TABLE_SERVER_CONFIG.statusTransitions.sort[0]
|
||
}
|
||
renderRow={(row) => (
|
||
<tr key={row.id}>
|
||
<td>{row.topic_code || "-"}</td>
|
||
<td>{statusLabel(row.from_status)}</td>
|
||
<td>{statusLabel(row.to_status)}</td>
|
||
<td>{row.sla_hours == null ? "-" : String(row.sla_hours)}</td>
|
||
<td>{boolLabel(row.enabled)}</td>
|
||
<td>{String(row.sort_order ?? 0)}</td>
|
||
<td>
|
||
<div className="table-actions">
|
||
<IconButton
|
||
icon="✎"
|
||
tooltip="Редактировать переход"
|
||
onClick={() => openEditRecordModal("statusTransitions", row)}
|
||
/>
|
||
<IconButton
|
||
icon="🗑"
|
||
tooltip="Удалить переход"
|
||
onClick={() => deleteRecord("statusTransitions", row.id)}
|
||
tone="danger"
|
||
/>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
/>
|
||
) : null}
|
||
{configActiveKey === "users" ? (
|
||
<DataTable
|
||
headers={[
|
||
{ key: "name", label: "Пользователь", sortable: true, field: "name" },
|
||
{ key: "email", label: "Email", sortable: true, field: "email" },
|
||
{ key: "role", label: "Роль", sortable: true, field: "role" },
|
||
{ key: "primary_topic_code", label: "Профиль (тема)", sortable: true, field: "primary_topic_code" },
|
||
{ key: "default_rate", label: "Ставка", sortable: true, field: "default_rate" },
|
||
{ key: "salary_percent", label: "Процент", sortable: true, field: "salary_percent" },
|
||
{ key: "is_active", label: "Активен", sortable: true, field: "is_active" },
|
||
{ key: "responsible", label: "Ответственный", sortable: true, field: "responsible" },
|
||
{ key: "created_at", label: "Создан", sortable: true, field: "created_at" },
|
||
{ key: "actions", label: "Действия" },
|
||
]}
|
||
rows={tables.users.rows}
|
||
emptyColspan={10}
|
||
onSort={(field) => toggleTableSort("users", field)}
|
||
sortClause={(tables.users.sort && tables.users.sort[0]) || TABLE_SERVER_CONFIG.users.sort[0]}
|
||
renderRow={(row) => (
|
||
<tr key={row.id}>
|
||
<td>
|
||
<div className="user-identity">
|
||
<UserAvatar name={row.name} email={row.email} avatarUrl={row.avatar_url} accessToken={token} size={32} />
|
||
<div className="user-identity-text">
|
||
<b>{row.name || "-"}</b>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>{row.email || "-"}</td>
|
||
<td>{roleLabel(row.role)}</td>
|
||
<td>{row.primary_topic_code || "-"}</td>
|
||
<td>{row.default_rate == null ? "-" : String(row.default_rate)}</td>
|
||
<td>{row.salary_percent == null ? "-" : String(row.salary_percent)}</td>
|
||
<td>{boolLabel(row.is_active)}</td>
|
||
<td>{row.responsible || "-"}</td>
|
||
<td>{fmtDate(row.created_at)}</td>
|
||
<td>
|
||
<div className="table-actions">
|
||
<IconButton icon="✎" tooltip="Редактировать пользователя" onClick={() => openEditRecordModal("users", row)} />
|
||
<IconButton icon="🗑" tooltip="Удалить пользователя" onClick={() => deleteRecord("users", row.id)} tone="danger" />
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
/>
|
||
) : null}
|
||
{configActiveKey === "userTopics" ? (
|
||
<DataTable
|
||
headers={[
|
||
{ key: "admin_user_id", label: "Юрист", sortable: true, field: "admin_user_id" },
|
||
{ key: "topic_code", label: "Доп. тема", sortable: true, field: "topic_code" },
|
||
{ key: "responsible", label: "Ответственный", sortable: true, field: "responsible" },
|
||
{ key: "created_at", label: "Создано", sortable: true, field: "created_at" },
|
||
{ key: "actions", label: "Действия" },
|
||
]}
|
||
rows={tables.userTopics.rows}
|
||
emptyColspan={5}
|
||
onSort={(field) => toggleTableSort("userTopics", field)}
|
||
sortClause={(tables.userTopics.sort && tables.userTopics.sort[0]) || TABLE_SERVER_CONFIG.userTopics.sort[0]}
|
||
renderRow={(row) => {
|
||
const lawyer = (dictionaries.users || []).find((item) => String(item.id) === String(row.admin_user_id));
|
||
const lawyerLabel = lawyer ? (lawyer.name || lawyer.email || row.admin_user_id) : row.admin_user_id || "-";
|
||
return (
|
||
<tr key={row.id}>
|
||
<td>{lawyerLabel}</td>
|
||
<td>{row.topic_code || "-"}</td>
|
||
<td>{row.responsible || "-"}</td>
|
||
<td>{fmtDate(row.created_at)}</td>
|
||
<td>
|
||
<div className="table-actions">
|
||
<IconButton icon="✎" tooltip="Редактировать связь" onClick={() => openEditRecordModal("userTopics", row)} />
|
||
<IconButton icon="🗑" tooltip="Удалить связь" onClick={() => deleteRecord("userTopics", row.id)} tone="danger" />
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}}
|
||
/>
|
||
) : null}
|
||
{configActiveKey && !KNOWN_CONFIG_TABLE_KEYS.has(configActiveKey) ? (
|
||
<DataTable
|
||
headers={genericConfigHeaders}
|
||
rows={activeConfigTableState.rows}
|
||
emptyColspan={Math.max(1, genericConfigHeaders.length)}
|
||
onSort={(field) => toggleTableSort(configActiveKey, field)}
|
||
sortClause={
|
||
(activeConfigTableState.sort && activeConfigTableState.sort[0]) ||
|
||
((resolveTableConfig(configActiveKey)?.sort || [])[0])
|
||
}
|
||
renderRow={(row) => (
|
||
<tr key={row.id || JSON.stringify(row)}>
|
||
{(activeConfigMeta?.columns || []).map((column) => {
|
||
const key = String(column.name || "");
|
||
const value = row[key];
|
||
if (column.kind === "boolean") return <td key={key}>{boolLabel(Boolean(value))}</td>;
|
||
if (column.kind === "date" || column.kind === "datetime") return <td key={key}>{fmtDate(value)}</td>;
|
||
if (column.kind === "json") return <td key={key}>{value == null ? "-" : JSON.stringify(value)}</td>;
|
||
return <td key={key}>{value == null || value === "" ? "-" : String(value)}</td>;
|
||
})}
|
||
{canUpdateInConfig || canDeleteInConfig ? (
|
||
<td>
|
||
<div className="table-actions">
|
||
{canUpdateInConfig ? (
|
||
<IconButton icon="✎" tooltip="Редактировать запись" onClick={() => openEditRecordModal(configActiveKey, row)} />
|
||
) : null}
|
||
{canDeleteInConfig ? (
|
||
<IconButton icon="🗑" tooltip="Удалить запись" onClick={() => deleteRecord(configActiveKey, row.id)} tone="danger" />
|
||
) : null}
|
||
</div>
|
||
</td>
|
||
) : null}
|
||
</tr>
|
||
)}
|
||
/>
|
||
) : null}
|
||
<TablePager
|
||
tableState={activeConfigTableState}
|
||
onPrev={() => loadPrevPage(configActiveKey)}
|
||
onNext={() => loadNextPage(configActiveKey)}
|
||
onLoadAll={() => loadAllRows(configActiveKey)}
|
||
/>
|
||
<StatusLine status={getStatus(configActiveKey)} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section active={activeSection === "availableTables"} id="section-available-tables">
|
||
<div className="section-head">
|
||
<div>
|
||
<h2>Доступность таблиц</h2>
|
||
<p className="muted">Скрытая служебная вкладка. Доступ только для администратора по прямой ссылке.</p>
|
||
</div>
|
||
<button className="btn secondary" type="button" onClick={() => loadAvailableTables()}>
|
||
Обновить
|
||
</button>
|
||
</div>
|
||
<DataTable
|
||
headers={[
|
||
{ key: "label", label: "Таблица" },
|
||
{ key: "table", label: "Код" },
|
||
{ key: "section", label: "Раздел" },
|
||
{ key: "is_active", label: "Активна" },
|
||
{ key: "updated_at", label: "Обновлена" },
|
||
{ key: "responsible", label: "Ответственный" },
|
||
{ key: "actions", label: "Действия" },
|
||
]}
|
||
rows={tables.availableTables.rows}
|
||
emptyColspan={7}
|
||
renderRow={(row) => (
|
||
<tr key={String(row.table || row.label)}>
|
||
<td>{row.label || "-"}</td>
|
||
<td>
|
||
<code>{row.table || "-"}</code>
|
||
</td>
|
||
<td>{row.section || "-"}</td>
|
||
<td>{boolLabel(Boolean(row.is_active))}</td>
|
||
<td>{fmtDate(row.updated_at)}</td>
|
||
<td>{row.responsible || "-"}</td>
|
||
<td>
|
||
<div className="table-actions">
|
||
<IconButton
|
||
icon={row.is_active ? "⏸" : "▶"}
|
||
tooltip={row.is_active ? "Деактивировать таблицу" : "Активировать таблицу"}
|
||
onClick={() => updateAvailableTableState(row.table, !Boolean(row.is_active))}
|
||
/>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
/>
|
||
<StatusLine status={getStatus("availableTables")} />
|
||
</Section>
|
||
|
||
<Section active={activeSection === "meta"} id="section-meta">
|
||
<div className="section-head">
|
||
<div>
|
||
<h2>Схема метаданных</h2>
|
||
<p className="muted">Поля сущностей для meta-driven форм.</p>
|
||
</div>
|
||
</div>
|
||
<div className="filters" style={{ gridTemplateColumns: "1fr auto" }}>
|
||
<div className="field">
|
||
<label htmlFor="meta-entity">Сущность</label>
|
||
<input
|
||
id="meta-entity"
|
||
value={metaEntity}
|
||
placeholder="quotes"
|
||
onChange={(event) => setMetaEntity(event.target.value)}
|
||
/>
|
||
</div>
|
||
<div style={{ display: "flex", alignItems: "end" }}>
|
||
<button className="btn secondary" type="button" onClick={() => loadMeta()}>
|
||
Загрузить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="json">{metaJson}</div>
|
||
<StatusLine status={getStatus("meta")} />
|
||
</Section>
|
||
</main>
|
||
</div>
|
||
|
||
<RecordModal
|
||
open={recordModal.open}
|
||
title={(recordModal.mode === "edit" ? "Редактирование • " : "Создание • ") + getTableLabel(recordModal.tableKey)}
|
||
fields={recordModalFields}
|
||
form={recordModal.form || {}}
|
||
status={getStatus("recordForm")}
|
||
onClose={closeRecordModal}
|
||
onChange={updateRecordField}
|
||
onUploadField={uploadRecordFieldFile}
|
||
onSubmit={submitRecordModal}
|
||
/>
|
||
|
||
<FilterModal
|
||
open={filterModal.open}
|
||
tableLabel={filterTableLabel}
|
||
fields={activeFilterFields}
|
||
draft={filterModal}
|
||
status={getStatus("filter")}
|
||
onClose={closeFilterModal}
|
||
onFieldChange={updateFilterField}
|
||
onOpChange={updateFilterOp}
|
||
onValueChange={updateFilterValue}
|
||
onSubmit={applyFilterModal}
|
||
onClear={clearFiltersFromModal}
|
||
getOperators={getOperatorsForType}
|
||
getFieldOptions={getFieldOptions}
|
||
/>
|
||
|
||
<ReassignModal
|
||
open={reassignModal.open}
|
||
status={getStatus("reassignForm")}
|
||
options={getLawyerOptions()}
|
||
value={reassignModal.lawyerId}
|
||
onChange={updateReassignLawyer}
|
||
onClose={closeReassignModal}
|
||
onSubmit={submitReassignModal}
|
||
trackNumber={reassignModal.trackNumber}
|
||
/>
|
||
|
||
{!token || !role ? <LoginScreen onSubmit={login} status={getStatus("login")} /> : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
const root = ReactDOM.createRoot(document.getElementById("admin-root"));
|
||
root.render(<App />);
|
||
})();
|