Law/app/web/admin.jsx
2026-03-29 22:10:09 +03:00

4245 lines
182 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
DEFAULT_FORM_FIELD_TYPES,
ADMIN_AUTH_REDIRECT_REASON_KEY,
INVOICE_STATUS_LABELS,
LS_TOKEN,
OPERATOR_LABELS,
ROLE_LABELS,
SERVICE_REQUEST_STATUS_LABELS,
SERVICE_REQUEST_TYPE_LABELS,
STATUS_LABELS,
STATUS_KIND_LABELS,
TABLE_KEY_ALIASES,
TABLE_MUTATION_CONFIG,
TABLE_SERVER_CONFIG,
TABLE_UNALIASES,
PAGE_SIZE,
} from "./admin/shared/constants.js";
import { createTableState } from "./admin/shared/state.js";
import { KanbanBoard } from "./admin/features/kanban/KanbanBoard.jsx";
import { ConfigSection } from "./admin/features/config/ConfigSection.jsx";
import { DashboardSection } from "./admin/features/dashboard/DashboardSection.jsx";
import { InvoicesSection } from "./admin/features/invoices/InvoicesSection.jsx";
import { RequestsSection } from "./admin/features/requests/RequestsSection.jsx";
import { QuotesSection } from "./admin/features/quotes/QuotesSection.jsx";
import { ServiceRequestsSection } from "./admin/features/service-requests/ServiceRequestsSection.jsx";
import { RequestWorkspace } from "./admin/features/requests/RequestWorkspace.jsx";
import { AvailableTablesSection } from "./admin/features/tables/AvailableTablesSection.jsx";
import { useAdminApi } from "./admin/hooks/useAdminApi.js";
import { useAdminCatalogLoaders } from "./admin/hooks/useAdminCatalogLoaders.js";
import { useKanban } from "./admin/hooks/useKanban.js";
import { useRequestWorkspace } from "./admin/hooks/useRequestWorkspace.js";
import { useTableActions } from "./admin/hooks/useTableActions.js";
import { useTableFilterActions } from "./admin/hooks/useTableFilterActions.js";
import { useTablesState } from "./admin/hooks/useTablesState.js";
import {
avatarColor,
boolFilterLabel,
buildUniversalQuery,
canAccessSection,
decodeJwtPayload,
detectAttachmentPreviewKind,
fallbackStatusGroup,
fmtAmount,
fmtBytes,
fmtDateOnly,
fmtKanbanDate,
fmtTimeOnly,
getOperatorsForType,
humanizeKey,
localizeMeta,
localizeRequestDetails,
metaKindToFilterType,
metaKindToRecordType,
normalizeReferenceMeta,
normalizeStringList,
resolveAdminObjectSrc,
resolveAdminRoute,
resolveAvatarSrc,
resolveDeadlineTone,
roleLabel,
sortByName,
statusLabel,
translateApiError,
userInitials,
} from "./admin/shared/utils.js";
import { AddIcon, DownloadIcon, FilterIcon, NextIcon, PrevIcon, RefreshIcon } from "./admin/shared/icons.jsx";
import QRCode from "qrcode";
(function () {
const { useCallback, useEffect, useMemo, useRef, useState } = React;
const LEGACY_HIDDEN_DICTIONARY_TABLES = new Set(["formFields", "topicRequiredFields", "statusTransitions"]);
const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
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-scroll-region">
<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, onRefresh, onCreate, onOpenFilter }) {
return (
<div className="pager table-footer-bar">
<div>
{tableState.showAll
? "Всего: " + tableState.total + " • показаны все записи"
: "Всего: " + tableState.total + " • смещение: " + tableState.offset}
</div>
<div className="table-footer-actions">
<button
className="btn secondary table-control-btn table-control-loadall"
type="button"
onClick={onLoadAll}
disabled={tableState.total === 0 || tableState.showAll || tableState.rows.length >= tableState.total}
title={"Загрузить все " + tableState.total}
aria-label={"Загрузить все " + tableState.total}
>
<DownloadIcon />
<span>{tableState.total}</span>
</button>
{onRefresh ? (
<button className="btn secondary table-control-btn" type="button" onClick={onRefresh} title="Обновить" aria-label="Обновить">
<RefreshIcon />
</button>
) : null}
{onCreate ? (
<button className="btn secondary table-control-btn" type="button" onClick={onCreate} title="Добавить" aria-label="Добавить">
<AddIcon />
</button>
) : null}
{onOpenFilter ? (
<button className="btn secondary table-control-btn" type="button" onClick={onOpenFilter} title="Фильтр" aria-label="Фильтр">
<FilterIcon />
</button>
) : null}
<button className="btn secondary table-control-btn" type="button" onClick={onPrev} disabled={tableState.showAll || tableState.offset <= 0} title="Назад" aria-label="Назад">
<PrevIcon />
</button>
<button
className="btn secondary table-control-btn"
type="button"
onClick={onNext}
disabled={tableState.showAll || tableState.offset + PAGE_SIZE >= tableState.total}
title="Вперед"
aria-label="Вперед"
>
<NextIcon />
</button>
</div>
</div>
);
}
function FilterToolbar({ filters, onOpen, onRemove, onEdit, getChipLabel, hideAction = false }) {
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>
{!hideAction ? (
<div className="filter-action">
<button className="btn secondary table-control-btn" type="button" onClick={onOpen} title="Фильтр" aria-label="Фильтр">
<FilterIcon />
</button>
</div>
) : null}
</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, disabled = false }) {
const handleClick = (event) => {
if (disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
event.preventDefault();
event.stopPropagation();
if (event.nativeEvent && typeof event.nativeEvent.stopImmediatePropagation === "function") {
event.nativeEvent.stopImmediatePropagation();
}
if (typeof onClick === "function") onClick(event);
};
const handleAuxClick = (event) => {
event.preventDefault();
event.stopPropagation();
if (event.nativeEvent && typeof event.nativeEvent.stopImmediatePropagation === "function") {
event.nativeEvent.stopImmediatePropagation();
}
};
return (
<button
className={"icon-btn" + (tone ? " " + tone : "")}
type="button"
data-tooltip={tooltip}
onClick={handleClick}
onAuxClick={handleAuxClick}
aria-label={tooltip}
disabled={disabled}
>
{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, size);
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"}
loading="lazy"
decoding="async"
fetchPriority={size >= 64 ? "low" : "auto"}
onError={() => setBroken(true)}
/>
) : (
<span>{initials}</span>
)}
</span>
);
}
function LoginScreen({ onSubmit, status }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [totpCode, setTotpCode] = useState("");
const submit = (event) => {
event.preventDefault();
onSubmit(email, password, totpCode);
};
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>
<div className="field">
<label htmlFor="login-totp">TOTP / резервный код</label>
<input
id="login-totp"
type="text"
placeholder="123456 или backup-code"
value={totpCode}
onChange={(event) => setTotpCode(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">
{draft.editIndex !== null ? "Сохранить" : "Добавить"}
</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 KanbanSortModal({ open, value, status, onChange, onClose, onSubmit }) {
if (!open) return null;
return (
<Overlay open={open} id="kanban-sort-overlay" onClose={(event) => event.target.id === "kanban-sort-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" }}>
Выберите способ сортировки карточек.
</p>
</div>
<button className="close" type="button" onClick={onClose}>
×
</button>
</div>
<form className="stack" onSubmit={onSubmit}>
<div className="field">
<label htmlFor="kanban-sort-mode">Тип сортировки</label>
<select id="kanban-sort-mode" value={value} onChange={onChange}>
<option value="created_newest">Дата заявки (новые сверху)</option>
<option value="lawyer">Юрист</option>
<option value="deadline">Дедлайн</option>
</select>
</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 TotpSetupModal({
open,
status,
secret,
uri,
qrDataUrl,
code,
loading,
onCodeChange,
onClose,
onSubmit,
onCopySecret,
onCopyUri,
}) {
if (!open) return null;
return (
<Overlay open={open} id="totp-setup-overlay" onClose={(event) => event.target.id === "totp-setup-overlay" && onClose()}>
<div className="modal" style={{ width: "min(700px, 100%)" }} onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<div>
<h3>Настройка 2FA</h3>
<p className="muted" style={{ marginTop: "0.35rem" }}>
Сканируйте QR-код в Google Authenticator и подтвердите 6-значным кодом.
</p>
</div>
<button className="close" type="button" onClick={onClose}>
×
</button>
</div>
<div className="totp-setup-grid">
<div className="totp-qr-box">
{qrDataUrl ? (
<img className="totp-qr-img" src={qrDataUrl} alt="QR-код для настройки 2FA" />
) : (
<p className="muted">QR-код не удалось сгенерировать. Используйте ключ вручную.</p>
)}
</div>
<div className="stack">
<div className="field">
<label htmlFor="totp-secret">Секретный ключ</label>
<input id="totp-secret" type="text" value={secret} readOnly />
</div>
<div className="field">
<label htmlFor="totp-uri">URI (otpauth)</label>
<textarea id="totp-uri" rows={3} value={uri} readOnly />
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button className="btn secondary" type="button" onClick={onCopySecret}>
Копировать ключ
</button>
<button className="btn secondary" type="button" onClick={onCopyUri}>
Копировать URI
</button>
</div>
</div>
</div>
<form className="stack" onSubmit={onSubmit}>
<div className="field">
<label htmlFor="totp-verify-code">Код из Google Authenticator</label>
<input
id="totp-verify-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
value={code}
onChange={onCodeChange}
/>
</div>
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
<button className="btn" type="submit" disabled={loading}>
{loading ? "Включаем..." : "Включить 2FA"}
</button>
<button className="btn secondary" type="button" onClick={onClose} disabled={loading}>
Отмена
</button>
</div>
<StatusLine status={status} />
</form>
</div>
</Overlay>
);
}
function AccountModal({
open,
status,
profileLoading,
saveLoading,
form,
currentEmail,
currentRoleLabel,
totpStatus,
onFieldChange,
onClose,
onSubmit,
onSetupTotp,
onRegenerateBackupCodes,
onDisableTotp,
onLogout,
}) {
if (!open) return null;
return (
<Overlay open={open} id="account-overlay" onClose={(event) => event.target.id === "account-overlay" && onClose()}>
<div className="modal account-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<div>
<h3>Личный кабинет</h3>
<p className="muted" style={{ marginTop: "0.35rem" }}>
Профиль и безопасность аккаунта.
</p>
</div>
<div className="modal-head-actions">
<button className="icon-btn" type="button" data-tooltip="Выйти из аккаунта" aria-label="Выйти из аккаунта" onClick={onLogout}>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path
d="M15.4 5.4a1 1 0 0 1 1.4 0l5.2 5.2a1 1 0 0 1 0 1.4l-5.2 5.2a1 1 0 1 1-1.4-1.4l3.5-3.4H9a1 1 0 1 1 0-2h9.9l-3.5-3.4a1 1 0 0 1 0-1.4zM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h6a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4z"
fill="currentColor"
/>
</svg>
</button>
<button className="close" type="button" onClick={onClose}>
×
</button>
</div>
</div>
{profileLoading ? (
<p className="muted">Загрузка профиля...</p>
) : (
<form className="stack" onSubmit={onSubmit}>
<div className="account-security-box">
Пользователь: <b>{currentEmail || "-"}</b>
<br />
Роль: <b>{currentRoleLabel || "-"}</b>
<br />
2FA: <b>{totpStatus.enabled ? "Включена" : "Выключена"}</b>
</div>
<div className="account-modal-grid">
<div className="field">
<label htmlFor="account-name">Имя</label>
<input id="account-name" name="name" type="text" value={form.name} onChange={onFieldChange} />
</div>
<div className="field">
<label htmlFor="account-email">Почта</label>
<input id="account-email" name="email" type="email" value={form.email} onChange={onFieldChange} />
</div>
</div>
<div className="account-modal-grid">
<div className="field">
<label htmlFor="account-phone">Телефон</label>
<input id="account-phone" name="phone" type="text" value={form.phone} onChange={onFieldChange} />
</div>
<div className="field">
<label htmlFor="account-password">Новый пароль</label>
<input
id="account-password"
name="password"
type="password"
autoComplete="new-password"
value={form.password}
onChange={onFieldChange}
placeholder="Оставьте пустым, если не меняете"
/>
</div>
</div>
<div className="account-modal-grid">
<div className="field">
<label htmlFor="account-password-confirm">Подтверждение пароля</label>
<input
id="account-password-confirm"
name="passwordConfirm"
type="password"
autoComplete="new-password"
value={form.passwordConfirm}
onChange={onFieldChange}
/>
</div>
<div className="field" />
</div>
<div className="account-security-box">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" }}>
<div>
<b>2FA</b>: {totpStatus.enabled ? "Включена" : "Выключена"}
</div>
<div className="muted">Режим: {String(totpStatus.mode || "-")}</div>
</div>
<div style={{ marginTop: "0.6rem", display: "flex", gap: "0.45rem", flexWrap: "wrap" }}>
<button className="btn secondary" type="button" onClick={onSetupTotp}>
Настроить 2FA
</button>
{totpStatus.enabled ? (
<>
<button className="btn secondary" type="button" onClick={onRegenerateBackupCodes}>
Backup-коды
</button>
<button className="btn danger" type="button" onClick={onDisableTotp}>
Отключить 2FA
</button>
</>
) : null}
</div>
</div>
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
<button className="btn" type="submit" disabled={saveLoading}>
{saveLoading ? "Сохраняем..." : "Сохранить изменения"}
</button>
<button className="btn secondary" type="button" onClick={onClose} disabled={saveLoading}>
Закрыть
</button>
</div>
<StatusLine status={status} />
</form>
)}
</div>
</Overlay>
);
}
function AttachmentPreviewModal({ open, title, url, fileName, mimeType, onClose }) {
const [resolvedUrl, setResolvedUrl] = useState("");
const [resolvedText, setResolvedText] = useState("");
const [resolvedKind, setResolvedKind] = useState("");
const [hint, setHint] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const decodeTextPreview = (arrayBuffer) => {
const bytes = new Uint8Array(arrayBuffer || new ArrayBuffer(0));
const sampleLength = Math.min(bytes.length, 4096);
let suspicious = 0;
for (let i = 0; i < sampleLength; i += 1) {
const byte = bytes[i];
if (byte === 0) suspicious += 4;
else if (byte < 9 || (byte > 13 && byte < 32)) suspicious += 1;
}
if (sampleLength && suspicious / sampleLength > 0.08) return null;
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes).replace(/\u0000/g, "");
const normalized = text.length > 200000 ? text.slice(0, 200000) + "\n\n[Текст обрезан для предпросмотра]" : text;
return normalized;
};
useEffect(() => {
if (!open || !url) {
setResolvedUrl("");
setResolvedText("");
setResolvedKind("");
setHint("");
setLoading(false);
setError("");
return;
}
const kind = detectAttachmentPreviewKind(fileName, mimeType);
setResolvedKind(kind);
setResolvedText("");
setHint("");
if (kind === "none") {
setResolvedUrl("");
setLoading(false);
setError("");
return;
}
let cancelled = false;
let objectUrl = "";
setLoading(true);
setError("");
setResolvedUrl("");
(async () => {
try {
const response = await fetch(url, { credentials: "same-origin" });
if (!response.ok) throw new Error("Не удалось загрузить файл для предпросмотра");
const buffer = await response.arrayBuffer();
if (cancelled) return;
if (kind === "pdf") {
const header = new Uint8Array(buffer.slice(0, 5));
const isPdf =
header.length >= 5 &&
header[0] === 0x25 &&
header[1] === 0x50 &&
header[2] === 0x44 &&
header[3] === 0x46 &&
header[4] === 0x2d;
if (isPdf) {
setResolvedUrl(String(url));
setResolvedKind("pdf");
setLoading(false);
return;
}
const textPreview = decodeTextPreview(buffer);
if (textPreview != null) {
setResolvedUrl("");
setResolvedText(textPreview);
setResolvedKind("text");
setHint("Файл помечен как PDF, но не является валидным PDF. Показан текстовый предпросмотр.");
setLoading(false);
return;
}
throw new Error("Файл помечен как PDF, но не является валидным PDF-документом.");
}
if (kind === "text") {
const textPreview = decodeTextPreview(buffer);
if (textPreview == null) throw new Error("Не удалось распознать текстовый файл для предпросмотра.");
setResolvedUrl("");
setResolvedText(textPreview);
setResolvedKind("text");
setLoading(false);
return;
}
const blob = new Blob([buffer], { type: response.headers.get("content-type") || mimeType || "application/octet-stream" });
objectUrl = URL.createObjectURL(blob);
if (cancelled) {
URL.revokeObjectURL(objectUrl);
return;
}
setResolvedUrl(objectUrl);
setResolvedKind(kind);
setLoading(false);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Не удалось открыть предпросмотр");
setLoading(false);
}
})();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [fileName, mimeType, open, url]);
if (!open || !url) return null;
const kind = resolvedKind || 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>
<div className="request-preview-head-actions">
<a
className="icon-btn file-action-btn request-preview-download-icon"
href={url}
target="_blank"
rel="noreferrer"
aria-label="Скачать файл"
data-tooltip="Скачать"
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path
d="M12 3a1 1 0 0 1 1 1v8.17l2.58-2.58a1 1 0 1 1 1.42 1.42l-4.3 4.3a1 1 0 0 1-1.4 0l-4.3-4.3a1 1 0 0 1 1.42-1.42L11 12.17V4a1 1 0 0 1 1-1zm-7 14a1 1 0 0 1 1 1v1h12v-1a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1z"
fill="currentColor"
/>
</svg>
</a>
<button className="close" type="button" onClick={onClose}>
×
</button>
</div>
</div>
<div className="request-preview-body">
{loading ? <p className="request-preview-note">Загрузка предпросмотра...</p> : null}
{!loading && !error && hint ? <p className="request-preview-note">{hint}</p> : null}
{error ? <p className="request-preview-note">{error}</p> : null}
{!loading && !error && kind === "image" && resolvedUrl ? (
<img className="request-preview-image" src={resolvedUrl} alt={fileName || "attachment"} />
) : null}
{!loading && !error && kind === "video" && resolvedUrl ? (
<video className="request-preview-video" src={resolvedUrl} controls preload="metadata" />
) : null}
{!loading && !error && kind === "pdf" && resolvedUrl ? (
<iframe className="request-preview-frame" src={resolvedUrl} title={fileName || "preview"} />
) : null}
{!loading && !error && kind === "text" ? (
<pre className="request-preview-text">{resolvedText || "Файл пуст."}</pre>
) : null}
{kind === "none" ? <p className="request-preview-note">Для этого типа файла доступно только открытие или скачивание.</p> : null}
</div>
</div>
</Overlay>
);
}
function RecordModal({ open, title, fields, form, status, onClose, onChange, onSubmit, onUploadField }) {
if (!open) return null;
const visibleFields = (fields || []).filter((field) => {
if (typeof field.visibleWhen !== "function") return true;
try {
return Boolean(field.visibleWhen(form || {}));
} catch (_) {
return true;
}
});
const renderField = (field) => {
const value = form[field.key] ?? "";
const options = typeof field.options === "function" ? field.options(form || {}) : [];
const id = "record-field-" + field.key;
const disabled = Boolean(field.readOnly) || (typeof field.readOnlyWhen === "function" ? Boolean(field.readOnlyWhen(form || {})) : false);
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") {
const extraOptions = Array.isArray(field.extraOptions) ? field.extraOptions : [];
const hasCurrentValue =
String(value || "").trim() !== "" &&
[...extraOptions, ...options].some((option) => String(option?.value || "") === String(value));
return (
<select id={id} value={value} onChange={(event) => onChange(field.key, event.target.value)} disabled={disabled}>
{field.optional ? <option value="">-</option> : null}
{!hasCurrentValue && String(value || "").trim() !== "" ? <option value={String(value)}>{String(value)}</option> : null}
{extraOptions.map((option) => (
<option value={String(option.value)} key={String(option.value)}>
{option.label}
</option>
))}
{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))" }}>
{visibleFields.map((field) => (
<div className="field" key={field.key} style={field.fullRow ? { gridColumn: "1 / -1" } : undefined}>
<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 GlobalTooltipLayer() {
const [tooltip, setTooltip] = useState({ open: false, text: "", x: 0, y: 0, maxWidth: 320 });
const activeRef = useRef(null);
useEffect(() => {
const getTarget = (node) => {
if (!(node instanceof Element)) return null;
const el = node.closest("[data-tooltip]");
if (!el) return null;
const text = String(el.getAttribute("data-tooltip") || "").trim();
return text ? el : null;
};
const reposition = (el) => {
if (!(el instanceof Element)) return;
const text = String(el.getAttribute("data-tooltip") || "").trim();
if (!text) return;
const rect = el.getBoundingClientRect();
const vw = window.innerWidth || 0;
const maxWidth = Math.min(360, Math.max(140, vw - 24));
const approxWidth = Math.min(maxWidth, Math.max(80, text.length * 7.1 + 22));
const centerX = rect.left + rect.width / 2;
const x = Math.max(12 + approxWidth / 2, Math.min(vw - 12 - approxWidth / 2, centerX));
const y = Math.max(8, rect.top - 8);
setTooltip({ open: true, text, x, y, maxWidth });
};
const open = (node) => {
const target = getTarget(node);
if (!target) return;
activeRef.current = target;
reposition(target);
};
const closeIfNeeded = (related) => {
const current = activeRef.current;
if (!current) return;
if (related instanceof Element) {
if (related === current || current.contains(related)) return;
const nextTarget = getTarget(related);
if (nextTarget === current) return;
}
activeRef.current = null;
setTooltip((prev) => ({ ...prev, open: false }));
};
const onMouseOver = (event) => open(event.target);
const onFocusIn = (event) => open(event.target);
const onMouseOut = (event) => closeIfNeeded(event.relatedTarget);
const onFocusOut = (event) => closeIfNeeded(event.relatedTarget);
const onUpdatePosition = () => {
if (activeRef.current) reposition(activeRef.current);
};
document.addEventListener("mouseover", onMouseOver, true);
document.addEventListener("focusin", onFocusIn, true);
document.addEventListener("mouseout", onMouseOut, true);
document.addEventListener("focusout", onFocusOut, true);
window.addEventListener("scroll", onUpdatePosition, true);
window.addEventListener("resize", onUpdatePosition);
return () => {
document.removeEventListener("mouseover", onMouseOver, true);
document.removeEventListener("focusin", onFocusIn, true);
document.removeEventListener("mouseout", onMouseOut, true);
document.removeEventListener("focusout", onFocusOut, true);
window.removeEventListener("scroll", onUpdatePosition, true);
window.removeEventListener("resize", onUpdatePosition);
};
}, []);
return (
<div
className={"global-tooltip-layer" + (tooltip.open ? " open" : "")}
style={{ left: tooltip.x + "px", top: tooltip.y + "px", maxWidth: tooltip.maxWidth + "px" }}
role="tooltip"
aria-hidden={tooltip.open ? "false" : "true"}
>
{tooltip.text}
</div>
);
}
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 [userId, setUserId] = useState("");
const [activeSection, setActiveSection] = useState(initialSection);
const dashboardLoadRef = useRef(0);
const [dashboardData, setDashboardData] = useState({
scope: "",
cards: [],
byStatus: {},
lawyerLoads: [],
myUnreadByEvent: {},
myUnreadTotal: 0,
myUnreadNotificationsTotal: 0,
unreadForClients: 0,
unreadForLawyers: 0,
serviceRequestUnreadTotal: 0,
deadlineAlertTotal: 0,
monthRevenue: 0,
monthExpenses: 0,
});
const {
tables,
tablesRef,
setTableState,
resetTablesState,
tableCatalog,
setTableCatalog,
referenceRowsMap,
setReferenceRowsMap,
} = useTablesState();
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 [smsProviderHealth, setSmsProviderHealth] = useState(null);
const [totpStatus, setTotpStatus] = useState({
mode: "password_totp_optional",
enabled: false,
required: false,
has_backup_codes: false,
});
const [totpSetupModal, setTotpSetupModal] = useState({
open: false,
secret: "",
uri: "",
qrDataUrl: "",
code: "",
loading: false,
});
const [accountModal, setAccountModal] = useState({
open: false,
loading: false,
saving: false,
initial: {
name: "",
email: "",
phone: "",
},
form: {
name: "",
email: "",
phone: "",
password: "",
passwordConfirm: "",
},
});
const [recordModal, setRecordModal] = useState({
open: false,
tableKey: null,
mode: "create",
rowId: null,
form: {},
});
const [configActiveKey, setConfigActiveKey] = useState("");
const [referencesExpanded, setReferencesExpanded] = useState(true);
const [statusDesignerTopicCode, setStatusDesignerTopicCode] = useState("");
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 initialRouteHandledRef = useRef(false);
const statusDesignerLoadedTopicRef = useRef("");
const setStatus = useCallback((key, message, kind) => {
setStatusMap((prev) => ({ ...prev, [key]: { message: message || "", kind: kind || "" } }));
}, []);
const getStatus = useCallback((key) => statusMap[key] || { message: "", kind: "" }, [statusMap]);
const isAdminTokenExpired = useCallback((rawToken) => {
const payload = decodeJwtPayload(rawToken || "");
const exp = Number(payload?.exp || 0);
if (!payload || !payload.role || !payload.email) return true;
if (!Number.isFinite(exp) || exp <= 0) return true;
return exp * 1000 <= Date.now();
}, []);
const api = useAdminApi(token);
const {
requestModal,
setRequestModal,
resetRequestWorkspaceState,
updateRequestModalMessageDraft,
appendRequestModalFiles,
removeRequestModalFile,
clearRequestModalFiles,
loadRequestModalData,
refreshRequestModal,
openRequestDetails,
clearPendingStatusChangePreset,
submitRequestStatusChange,
submitRequestModalMessage,
probeRequestLive,
loadOlderRequestMessages,
setRequestTyping,
loadRequestDataTemplates,
loadRequestDataBatch,
loadRequestDataTemplateDetails,
saveRequestDataTemplate,
saveRequestDataBatch,
issueRequestInvoice,
} = useRequestWorkspace({
api,
setStatus,
setActiveSection,
token,
users: dictionaries.users,
buildUniversalQuery,
resolveAdminObjectSrc,
});
const getStatusOptions = useCallback(() => {
return (dictionaries.statuses || [])
.filter((item) => item && item.code)
.map((item) => ({ value: item.code, label: String(item.name || "").trim() || humanizeKey(item.code) }));
}, [dictionaries.statuses]);
const getInvoiceStatusOptions = useCallback(() => {
return Object.entries(INVOICE_STATUS_LABELS).map(([code, name]) => ({ value: code, label: name }));
}, []);
const getServiceRequestTypeOptions = useCallback(() => {
return Object.entries(SERVICE_REQUEST_TYPE_LABELS).map(([code, name]) => ({ value: code, label: name }));
}, []);
const getServiceRequestStatusOptions = useCallback(() => {
return Object.entries(SERVICE_REQUEST_STATUS_LABELS).map(([code, name]) => ({ value: code, label: name }));
}, []);
const getStatusKindOptions = useCallback(() => {
return Object.entries(STATUS_KIND_LABELS).map(([code, name]) => ({ value: code, label: name }));
}, []);
const getTopicOptions = useCallback(() => {
return (dictionaries.topics || [])
.filter((item) => item && item.code)
.map((item) => ({ value: item.code, label: String(item.name || "").trim() || humanizeKey(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 getRequestDataValueTypeOptions = useCallback(() => {
return [
{ value: "string", label: "Строка" },
{ value: "date", label: "Дата" },
{ value: "number", label: "Число" },
{ value: "file", label: "Файл" },
{ value: "text", label: "Текст" },
];
}, []);
const getFormFieldKeyOptions = useCallback(() => {
return (dictionaries.formFieldKeys || [])
.filter((item) => item && item.key)
.map((item) => ({ value: item.key, label: String(item.label || "").trim() || humanizeKey(item.key) }));
}, [dictionaries.formFieldKeys]);
const getRoleOptions = useCallback(() => {
return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label }));
}, []);
const tableCatalogMap = useMemo(() => {
const map = {};
(tableCatalog || []).forEach((item) => {
if (!item || !item.key) return;
map[item.key] = item;
});
return map;
}, [tableCatalog]);
const getReferenceOptions = useCallback(
(rawReference) => {
const reference = normalizeReferenceMeta(rawReference);
if (!reference) return [];
const rows = referenceRowsMap[reference.table] || [];
const map = new Map();
rows.forEach((row) => {
if (!row || typeof row !== "object") return;
const rawValue = row[reference.value_field];
if (rawValue == null || rawValue === "") return;
const value = String(rawValue);
const labelRaw = row[reference.label_field];
const label = String(labelRaw == null || labelRaw === "" ? rawValue : labelRaw);
if (!map.has(value)) map.set(value, label);
});
return Array.from(map.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => String(a.label).localeCompare(String(b.label), "ru"));
},
[referenceRowsMap]
);
const resolveReferenceLabel = useCallback(
(rawReference, rawValue) => {
if (rawValue == null || rawValue === "") return "-";
const value = String(rawValue);
const options = getReferenceOptions(rawReference);
const found = options.find((item) => String(item.value) === value);
return found ? found.label : value;
},
[getReferenceOptions]
);
const getStatusGroupOptions = useCallback(() => {
return getReferenceOptions({ table: "status_groups", value_field: "id", label_field: "name" });
}, [getReferenceOptions]);
const getClientOptions = useCallback(() => {
return getReferenceOptions({ table: "clients", value_field: "id", label_field: "full_name" });
}, [getReferenceOptions]);
const getInvoiceRequestRows = useCallback(() => {
const fromReferences = Array.isArray(referenceRowsMap.requests) ? referenceRowsMap.requests : [];
const fromTable = Array.isArray(tables.requests?.rows) ? tables.requests.rows : [];
const byTrack = new Map();
[...fromReferences, ...fromTable].forEach((row) => {
const track = String(row?.track_number || "").trim().toUpperCase();
if (!track) return;
if (!byTrack.has(track)) byTrack.set(track, row);
});
return Array.from(byTrack.values());
}, [referenceRowsMap.requests, tables.requests?.rows]);
const getInvoiceRequestTrackOptions = useCallback(() => {
const rows = getInvoiceRequestRows();
return rows
.map((row) => {
const track = String(row?.track_number || "").trim().toUpperCase();
if (!track) return null;
const clientName = String(row?.client_name || "").trim();
const clientPhone = String(row?.client_phone || "").trim();
const parts = [track];
if (clientName) parts.push(clientName);
if (clientPhone) parts.push(clientPhone);
return { value: track, label: parts.join(" • ") };
})
.filter(Boolean)
.sort((a, b) => String(a.label).localeCompare(String(b.label), "ru"));
}, [getInvoiceRequestRows]);
const getInvoicePayerOptions = useCallback((formOrTrack) => {
const map = new Map();
const addPayer = (nameRaw, phoneRaw) => {
const name = String(nameRaw || "").trim();
if (!name) return;
const phone = String(phoneRaw || "").trim();
if (map.has(name)) return;
map.set(name, phone ? `${name} (${phone})` : name);
};
const rows = getInvoiceRequestRows();
const trackFromInput =
typeof formOrTrack === "string"
? formOrTrack
: String(formOrTrack?.request_track_number || "").trim();
const requestIdFromInput = typeof formOrTrack === "string" ? "" : String(formOrTrack?.request_id || "").trim();
const normalizedTrack = String(trackFromInput || "").trim().toUpperCase();
const selectedRequest = rows.find((row) => {
const rowTrack = String(row?.track_number || "").trim().toUpperCase();
const rowId = String(row?.id || "").trim();
return (normalizedTrack && rowTrack === normalizedTrack) || (requestIdFromInput && rowId === requestIdFromInput);
});
if (selectedRequest) {
addPayer(selectedRequest?.client_name, selectedRequest?.client_phone);
} else {
const clientRows = Array.isArray(referenceRowsMap.clients) ? referenceRowsMap.clients : [];
clientRows.forEach((row) => addPayer(row?.full_name || row?.client_name, row?.phone || row?.client_phone));
rows.forEach((row) => addPayer(row?.client_name, row?.client_phone));
}
return Array.from(map.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => String(a.label).localeCompare(String(b.label), "ru"));
}, [getInvoiceRequestRows, referenceRowsMap.clients]);
const dictionaryTableItems = useMemo(() => {
return (tableCatalog || [])
.filter(
(item) =>
item &&
item.section === "dictionary" &&
Array.isArray(item.actions) &&
item.actions.includes("query") &&
!LEGACY_HIDDEN_DICTIONARY_TABLES.has(String(item.key || ""))
)
.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 === "kanban") {
return [
{ field: "assigned_lawyer_id", label: "Юрист", type: "reference", options: getLawyerOptions },
{ field: "client_name", label: "Клиент", type: "text" },
{ field: "status_code", label: "Статус", type: "reference", options: getStatusOptions },
{ field: "created_at", label: "Дата", type: "date" },
{ field: "topic_code", label: "Тема", type: "reference", options: getTopicOptions },
{ field: "has_unread_updates", label: "Есть оповещения", type: "boolean" },
{ field: "deadline_alert", label: "Горящие дедлайны", type: "boolean" },
{ field: "overdue", label: "Просрочен", type: "boolean" },
];
}
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: "important_date_at", label: "Важная дата", type: "date" },
{ field: "has_unread_updates", label: "Есть оповещения", type: "boolean" },
{ field: "deadline_alert", label: "Горящие дедлайны", type: "boolean" },
{ field: "client_has_unread_updates", label: "Непрочитано клиентом", type: "boolean" },
{ field: "lawyer_has_unread_updates", label: "Непрочитано юристом", type: "boolean" },
{ 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 === "serviceRequests") {
return [
{ field: "type", label: "Тип", type: "enum", options: getServiceRequestTypeOptions },
{ field: "status", label: "Статус", type: "enum", options: getServiceRequestStatusOptions },
{ field: "request_id", label: "ID заявки", type: "text" },
{ field: "client_id", label: "ID клиента", type: "text" },
{ field: "assigned_lawyer_id", label: "Назначенный юрист", type: "reference", options: getLawyerOptions },
{ field: "admin_unread", label: "Непрочитано администратором", type: "boolean" },
{ field: "lawyer_unread", label: "Непрочитано юристом", type: "boolean" },
{ field: "resolved_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: "name", label: "Название", type: "text" },
{ field: "enabled", label: "Активна", type: "boolean" },
{ field: "sort_order", label: "Порядок", type: "number" },
];
}
if (tableKey === "statuses") {
return [
{ field: "name", label: "Название", type: "text" },
{ field: "status_group_id", label: "Группа", type: "reference", options: getStatusGroupOptions },
{ 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: "value_type", label: "Тип значения", type: "enum", options: getRequestDataValueTypeOptions },
{ field: "document_name", 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: "phone", label: "Телефон", 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 && String(column.name) !== "id")
.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 };
const reference = normalizeReferenceMeta(column.reference);
if (reference) {
return { field: name, label, type: "reference", options: () => getReferenceOptions(reference) };
}
return { field: name, label, type: metaKindToFilterType(column.kind) };
});
},
[
getReferenceOptions,
tableCatalogMap,
getFormFieldKeyOptions,
getFormFieldTypeOptions,
getInvoiceStatusOptions,
getLawyerOptions,
getRoleOptions,
getServiceRequestStatusOptions,
getServiceRequestTypeOptions,
role,
getStatusGroupOptions,
getStatusKindOptions,
getStatusOptions,
getTopicOptions,
]
);
const getTableLabel = useCallback((tableKey) => {
if (tableKey === "kanban") return "Канбан";
if (tableKey === "requests") return "Заявки";
if (tableKey === "serviceRequests") return "Запросы";
if (tableKey === "invoices") return "Счета";
if (tableKey === "quotes") return "Цитаты";
if (tableKey === "topics") return "Темы";
if (tableKey === "statuses") return "Статусы";
if (tableKey === "statusGroups") 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 statusDesignerRows = useMemo(() => {
const activeTopic = String(statusDesignerTopicCode || "").trim();
const rows = tables.statusTransitions.rows || [];
if (!activeTopic) return rows;
return rows.filter((row) => String(row.topic_code || "") === activeTopic);
}, [statusDesignerTopicCode, tables.statusTransitions.rows]);
const statusDesignerCards = useMemo(() => {
const rows = statusDesignerRows || [];
if (!rows.length) return [];
const orderMap = new Map();
(tables.statuses.rows || []).forEach((row, index) => {
const code = String(row?.code || "").trim();
if (!code) return;
const sortOrder = Number(row?.sort_order);
orderMap.set(code, Number.isFinite(sortOrder) ? sortOrder : index);
});
const statusMetaMap = new Map();
(dictionaries.statuses || []).forEach((row, index) => {
const code = String(row?.code || "").trim();
if (!code) return;
statusMetaMap.set(code, {
name: String(row?.name || code),
isTerminal: false,
order: orderMap.get(code) ?? index,
});
});
(tables.statuses.rows || []).forEach((row, index) => {
const code = String(row?.code || "").trim();
if (!code) return;
statusMetaMap.set(code, {
name: String(row?.name || code),
isTerminal: Boolean(row?.is_terminal),
order: orderMap.get(code) ?? index,
});
});
const codeSet = new Set();
rows.forEach((row) => {
const fromCode = String(row?.from_status || "").trim();
const toCode = String(row?.to_status || "").trim();
if (fromCode) codeSet.add(fromCode);
if (toCode) codeSet.add(toCode);
});
const codes = Array.from(codeSet.values()).sort((a, b) => {
const aOrder = statusMetaMap.get(a)?.order;
const bOrder = statusMetaMap.get(b)?.order;
if (aOrder != null && bOrder != null && aOrder !== bOrder) return aOrder - bOrder;
if (aOrder != null && bOrder == null) return -1;
if (aOrder == null && bOrder != null) return 1;
return String(a).localeCompare(String(b), "ru");
});
return codes.map((code) => {
const outgoing = rows
.filter((row) => String(row?.from_status || "").trim() === code)
.sort((a, b) => {
const aOrder = Number(a?.sort_order || 0);
const bOrder = Number(b?.sort_order || 0);
if (aOrder !== bOrder) return aOrder - bOrder;
return String(a?.to_status || "").localeCompare(String(b?.to_status || ""), "ru");
});
const meta = statusMetaMap.get(code) || { name: statusLabel(code), isTerminal: false };
return {
code,
name: String(meta.name || statusLabel(code)),
isTerminal: Boolean(meta.isTerminal),
outgoing,
};
});
}, [dictionaries.statuses, statusDesignerRows, tables.statuses.rows]);
const getRecordFields = useCallback(
(tableKey) => {
if (tableKey === "requests") {
const isNewClientMode = (form) => {
const value = String(form?.client_id || "").trim();
return !value || value === NEW_REQUEST_CLIENT_OPTION;
};
const fields = [
{ key: "track_number", label: "Номер заявки", type: "text", optional: true, placeholder: "Оставьте пустым для автогенерации" },
...(role !== "LAWYER"
? [
{
key: "client_id",
label: "Клиент",
type: "reference",
defaultValue: NEW_REQUEST_CLIENT_OPTION,
options: getClientOptions,
extraOptions: [{ value: NEW_REQUEST_CLIENT_OPTION, label: "Новый клиент" }],
fullRow: true,
},
]
: []),
{
key: "client_name",
label: role !== "LAWYER" ? "ФИО нового клиента" : "Клиент",
type: "text",
required: true,
visibleWhen: role === "LAWYER" ? undefined : isNewClientMode,
},
{
key: "client_phone",
label: role !== "LAWYER" ? "Телефон нового клиента" : "Телефон",
type: "text",
required: true,
visibleWhen: role === "LAWYER" ? undefined : isNewClientMode,
},
{ 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: "request_cost", label: "Стоимость заявки", type: "number", optional: true },
];
if (role !== "LAWYER") {
fields.push({ key: "assigned_lawyer_id", label: "Назначенный юрист", type: "reference", optional: true, options: getLawyerOptions });
fields.push({ key: "effective_rate", label: "Ставка (фикс.)", type: "number", optional: true });
}
return fields;
}
if (tableKey === "invoices") {
return [
{ key: "request_track_number", label: "Номер заявки", type: "reference", required: true, createOnly: true, options: getInvoiceRequestTrackOptions },
{ 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: "reference",
required: true,
options: (form) => getInvoicePayerOptions(form),
},
];
}
if (tableKey === "serviceRequests") {
return [
{ key: "type", label: "Тип", type: "enum", required: true, options: getServiceRequestTypeOptions },
{ key: "status", label: "Статус", type: "enum", required: true, options: getServiceRequestStatusOptions },
{ key: "body", label: "Обращение", type: "textarea", required: true, fullRow: true },
{ key: "request_id", label: "ID заявки", type: "text", required: true },
{ key: "client_id", label: "ID клиента", type: "text", optional: true },
{ key: "assigned_lawyer_id", label: "ID назначенного юриста", type: "text", optional: true },
];
}
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: "status_group_id", label: "Группа", type: "reference", optional: true, options: getStatusGroupOptions },
{ 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: "value_type", label: "Тип значения", type: "enum", required: true, options: getRequestDataValueTypeOptions, defaultValue: "string" },
{ key: "document_name", label: "Документ", type: "text", optional: true, placeholder: "Например: Договор / Паспорт" },
{ 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: "required_data_keys",
label: "Обязательные ключи данных (JSON-массив)",
type: "json",
optional: true,
defaultValue: "[]",
placeholder: "[\"passport_scan\", \"client_address\"]",
},
{
key: "required_mime_types",
label: "Обязательные MIME-типы файлов (JSON-массив)",
type: "json",
optional: true,
defaultValue: "[]",
placeholder: "[\"application/pdf\", \"image/*\"]",
},
{ 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: "phone", label: "Телефон", type: "text", optional: true, placeholder: "+7..." },
{ 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);
const reference = normalizeReferenceMeta(column.reference);
return {
key,
label: String(column.label || humanizeKey(key)),
type: reference ? "reference" : metaKindToRecordType(column.kind),
options: reference ? () => getReferenceOptions(reference) : undefined,
requiredOnCreate,
optional: !requiredOnCreate,
};
});
},
[
getReferenceOptions,
tableCatalogMap,
getFormFieldKeyOptions,
getFormFieldTypeOptions,
getInvoiceStatusOptions,
getInvoicePayerOptions,
getInvoiceRequestTrackOptions,
getClientOptions,
getLawyerOptions,
getRoleOptions,
getServiceRequestStatusOptions,
getServiceRequestTypeOptions,
getStatusGroupOptions,
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 {
kanbanData,
kanbanLoading,
kanbanSortModal,
kanbanSortApplied,
loadKanban,
openKanbanSortModal,
closeKanbanSortModal,
updateKanbanSortMode,
submitKanbanSortModal,
resetKanbanState,
} = useKanban({
api,
setStatus,
setTableState,
tablesRef,
});
const { loadTable, loadPrevPage, loadNextPage, loadAllRows, toggleTableSort } = useTableActions({
api,
setStatus,
resolveTableConfig,
tablesRef,
setTableState,
setDictionaries,
buildUniversalQuery,
});
const { loadAvailableTables, loadReferenceRows } = useAdminCatalogLoaders({
api,
setStatus,
setTableState,
setReferenceRowsMap,
buildUniversalQuery,
});
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 loadStatusDesignerTopic = useCallback(
async (topicCode) => {
const code = String(topicCode || "").trim();
setStatusDesignerTopicCode(code);
statusDesignerLoadedTopicRef.current = code;
if (!code) {
await loadTable("statusTransitions", { resetOffset: true, filtersOverride: [] });
return;
}
await loadTable("statusTransitions", {
resetOffset: true,
filtersOverride: [{ field: "topic_code", op: "=", value: code }],
});
},
[loadTable]
);
useEffect(() => {
if (configActiveKey !== "statusTransitions") {
statusDesignerLoadedTopicRef.current = "";
return;
}
const topics = dictionaries.topics || [];
if (!topics.length) {
setStatusDesignerTopicCode("");
return;
}
const hasSelected = topics.some((item) => String(item?.code || "") === String(statusDesignerTopicCode || ""));
const nextTopic = String(hasSelected ? statusDesignerTopicCode : topics[0]?.code || "").trim();
if (!nextTopic) return;
if (nextTopic !== statusDesignerTopicCode) {
setStatusDesignerTopicCode(nextTopic);
return;
}
if (statusDesignerLoadedTopicRef.current === nextTopic) return;
statusDesignerLoadedTopicRef.current = nextTopic;
loadTable("statusTransitions", {
resetOffset: true,
filtersOverride: [{ field: "topic_code", op: "=", value: nextTopic }],
});
}, [configActiveKey, dictionaries.topics, loadTable, statusDesignerTopicCode]);
const loadDashboard = useCallback(
async (tokenOverride) => {
const loadId = Date.now();
dashboardLoadRef.current = loadId;
setStatus("dashboard", "Загрузка...", "");
try {
const buildDashboardCards = (scope, payload) =>
scope === "LAWYER"
? [
{ label: "Мои заявки", value: payload.assigned_total ?? 0 },
{ label: "Мои активные", value: payload.active_assigned_total ?? 0 },
{ label: "Неназначенные", value: payload.unassigned_total ?? 0 },
{ label: "Мои непрочитанные", value: payload.my_unread_notifications_total ?? payload.my_unread_updates ?? 0 },
{ label: "Просрочено SLA", value: payload.sla_overdue ?? 0 },
]
: [
{ label: "Новые", value: payload.new ?? 0 },
{ label: "Назначенные", value: payload.assigned_total ?? 0 },
{ label: "Неназначенные", value: payload.unassigned_total ?? 0 },
{ label: "Просрочено SLA", value: payload.sla_overdue ?? 0 },
{ label: "Мои непрочитанные", value: payload.my_unread_notifications_total ?? payload.my_unread_updates ?? 0 },
{ label: "Выручка (мес.)", value: Number(payload.month_revenue ?? 0).toFixed(2) },
{ label: "Расходы (мес.)", value: Number(payload.month_expenses ?? 0).toFixed(2) },
{ label: "Непрочитано юристами", value: payload.unread_for_lawyers ?? 0 },
{ label: "Непрочитано клиентами", value: payload.unread_for_clients ?? 0 },
];
const data = await api("/api/admin/metrics/overview?include_sla=false", {}, tokenOverride);
const scope = String(data.scope || role || "");
const localized = {};
Object.entries(data.by_status || {}).forEach(([code, count]) => {
localized[statusLabel(code)] = count;
});
setDashboardData({
scope,
cards: buildDashboardCards(scope, data),
byStatus: localized,
lawyerLoads: data.lawyer_loads || [],
myUnreadByEvent: data.my_unread_by_event || {},
myUnreadTotal: Number(data.my_unread_updates || 0),
myUnreadNotificationsTotal: Number(data.my_unread_notifications_total || data.my_unread_updates || 0),
unreadForClients: Number(data.unread_for_clients_notifications_total || data.unread_for_clients || 0),
unreadForLawyers: Number(data.unread_for_lawyers_notifications_total || data.unread_for_lawyers || 0),
serviceRequestUnreadTotal: Number(data.service_request_unread_total || 0),
deadlineAlertTotal: Number(data.deadline_alert_total || 0),
monthRevenue: Number(data.month_revenue || 0),
monthExpenses: Number(data.month_expenses || 0),
});
setStatus("dashboard", "Данные обновлены", "ok");
void (async () => {
try {
const slaData = await api("/api/admin/metrics/overview-sla", {}, tokenOverride);
if (dashboardLoadRef.current !== loadId) return;
setDashboardData((prev) => ({
...prev,
cards: buildDashboardCards(String(prev.scope || scope || ""), { ...data, ...slaData }),
}));
} catch (_) {
// Keep fast dashboard payload if SLA snapshot is unavailable.
}
})();
} 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 loadSmsProviderHealth = useCallback(
async (tokenOverride, options) => {
const opts = options || {};
const silent = Boolean(opts.silent);
const currentRole = String(role || "").toUpperCase();
const authToken = tokenOverride !== undefined ? tokenOverride : token;
if (!authToken || currentRole !== "ADMIN") {
setSmsProviderHealth(null);
return null;
}
if (!silent) setStatus("smsProviderHealth", "Обновляем баланс SMS Aero...", "");
try {
const payload = await api("/api/admin/system/sms-provider-health", {}, tokenOverride);
const enriched = { ...(payload || {}), loaded_at: new Date().toISOString() };
setSmsProviderHealth(enriched);
if (!silent) setStatus("smsProviderHealth", "Баланс SMS Aero обновлен", "ok");
return enriched;
} catch (error) {
const fallback = {
provider: "smsaero",
status: "error",
mode: "real",
can_send: false,
balance_available: false,
balance_amount: null,
balance_currency: "RUB",
issues: [error.message],
loaded_at: new Date().toISOString(),
};
setSmsProviderHealth(fallback);
if (!silent) setStatus("smsProviderHealth", "Ошибка: " + error.message, "error");
return null;
}
},
[api, role, setStatus, token]
);
const refreshSection = useCallback(
async (section, tokenOverride) => {
if (!(tokenOverride !== undefined ? tokenOverride : token)) return;
if (section === "dashboard") return loadDashboard(tokenOverride);
if (section === "kanban") return loadKanban(tokenOverride);
if (section === "requests" && canAccessSection(role, "requests")) return loadTable("requests", {}, tokenOverride);
if (section === "serviceRequests" && canAccessSection(role, "serviceRequests")) return loadTable("serviceRequests", {}, tokenOverride);
if (section === "invoices" && canAccessSection(role, "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, loadKanban, 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);
await loadReferenceRows(catalogRows, tokenOverride);
const statusesMap = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }]));
(statusesData.rows || []).forEach((row) => {
if (!row.code) return;
statusesMap.set(row.code, { code: row.code, name: row.name || statusLabel(row.code) });
});
const typeSet = new Set(DEFAULT_FORM_FIELD_TYPES);
(fieldsData.rows || []).forEach((row) => {
if (row?.type) typeSet.add(row.type);
});
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 || "",
phone: row.phone || "",
role: row.role || "",
is_active: Boolean(row.is_active),
})),
}));
} catch (_) {
// Keep defaults when dictionary endpoints are unavailable.
}
},
[api, loadReferenceRows]
);
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 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";
if (tableKey === "invoices") {
const selectedTrack = String(initial.request_track_number || "").trim().toUpperCase();
if (selectedTrack) {
const rows = getInvoiceRequestRows();
const found = rows.find((row) => String(row?.track_number || "").trim().toUpperCase() === selectedTrack);
const autoPayer = String(found?.client_name || "").trim();
if (autoPayer) initial.payer_display_name = autoPayer;
}
}
setRecordModal({ open: true, tableKey, mode: "create", rowId: null, form: initial });
setStatus("recordForm", "", "");
},
[getInvoiceRequestRows, getRecordFields, setStatus]
);
const openCreateStatusTransitionForTopic = useCallback(() => {
const topicCode = String(statusDesignerTopicCode || "").trim();
if (!topicCode) {
setStatus("statusTransitions", "Сначала выберите тему для конструктора", "error");
return;
}
setRecordModal({
open: true,
tableKey: "statusTransitions",
mode: "create",
rowId: null,
form: {
topic_code: topicCode,
from_status: "",
to_status: "",
sla_hours: "",
required_data_keys: "[]",
required_mime_types: "[]",
enabled: "true",
sort_order: String(Math.max(1, (statusDesignerRows || []).length + 1)),
},
});
setStatus("recordForm", "", "");
}, [setStatus, statusDesignerRows, statusDesignerTopicCode]);
const openEditRecordModal = useCallback(
async (tableKey, row) => {
let sourceRow = row || {};
if (tableKey === "requests" && role === "ADMIN" && row?.id) {
try {
setStatus("requests", "Загружаем полную карточку заявки...", "");
const loaded = await api("/api/admin/requests/" + row.id);
sourceRow = { ...(row || {}), ...(loaded || {}) };
setStatus("requests", "", "");
} catch (error) {
setStatus("requests", "Ошибка загрузки заявки: " + error.message, "error");
return;
}
}
const fields = getRecordFields(tableKey);
const nextForm = {};
fields.forEach((field) => {
const value = sourceRow[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);
});
if (tableKey === "requests" && role !== "LAWYER" && !String(nextForm.client_id || "").trim()) {
nextForm.client_id = NEW_REQUEST_CLIENT_OPTION;
}
setRecordModal({ open: true, tableKey, mode: "edit", rowId: sourceRow.id, form: nextForm });
setStatus("recordForm", "", "");
},
[api, getRecordFields, role, setStatus]
);
const closeRecordModal = useCallback(() => {
setRecordModal({ open: false, tableKey: null, mode: "create", rowId: null, form: {} });
setStatus("recordForm", "", "");
}, [setStatus]);
const updateRecordField = useCallback(
(field, value) => {
setRecordModal((prev) => {
const nextForm = { ...(prev.form || {}), [field]: value };
if (prev.tableKey === "requests") {
if (field === "client_id") {
const selectedId = String(value || "").trim();
if (!selectedId || selectedId === NEW_REQUEST_CLIENT_OPTION) {
nextForm.client_id = NEW_REQUEST_CLIENT_OPTION;
nextForm.client_name = "";
nextForm.client_phone = "";
} else if (selectedId) {
const rows = Array.isArray(referenceRowsMap.clients) ? referenceRowsMap.clients : [];
const found = rows.find((row) => String(row?.id || "") === selectedId);
if (found) {
nextForm.client_name = String(found.full_name || nextForm.client_name || "");
nextForm.client_phone = String(found.phone || nextForm.client_phone || "");
}
}
}
if (
(field === "client_name" || field === "client_phone") &&
String(nextForm.client_id || "").trim() &&
String(nextForm.client_id || "").trim() !== NEW_REQUEST_CLIENT_OPTION
) {
const selectedId = String(nextForm.client_id || "").trim();
const rows = Array.isArray(referenceRowsMap.clients) ? referenceRowsMap.clients : [];
const found = rows.find((row) => String(row?.id || "") === selectedId);
if (found) {
const selectedName = String(found.full_name || "");
const selectedPhone = String(found.phone || "");
const currentName = String(field === "client_name" ? value : nextForm.client_name || "");
const currentPhone = String(field === "client_phone" ? value : nextForm.client_phone || "");
if (currentName !== selectedName || currentPhone !== selectedPhone) {
nextForm.client_id = "";
}
}
}
}
if (prev.tableKey === "invoices" && field === "request_track_number") {
const selectedTrack = String(value || "").trim().toUpperCase();
if (selectedTrack) {
const rows = getInvoiceRequestRows();
const found = rows.find((row) => String(row?.track_number || "").trim().toUpperCase() === selectedTrack);
if (found) {
nextForm.request_track_number = String(found.track_number || selectedTrack).trim().toUpperCase();
const autoPayer = String(found.client_name || "").trim();
if (autoPayer) nextForm.payer_display_name = autoPayer;
}
}
}
return { ...prev, form: nextForm };
});
},
[getInvoiceRequestRows, referenceRowsMap.clients]
);
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) {
const errorText = (await putResp.text()).trim();
throw new Error(
`Не удалось загрузить файл в хранилище (${putResp.status}${errorText ? `: ${errorText.slice(0, 200)}` : ""})`
);
}
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" && mode === "edit";
const isAdminRequestEdit = tableKey === "requests" && role === "ADMIN" && mode === "edit";
const adminRequestRestricted = new Set(["client_id", "client_name", "client_phone"]);
fields.forEach((field) => {
if (isLawyerRequestEdit && field.key !== "topic_code") return;
if (isAdminRequestEdit && adminRequestRestricted.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 (tableKey === "requests" && field.key === "client_id" && value === NEW_REQUEST_CLIENT_OPTION) {
payload[field.key] = null;
return;
}
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" && mode === "create" && !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 });
await loadReferenceRows(tableCatalog, undefined);
setTimeout(() => closeRecordModal(), 250);
} catch (error) {
setStatus("recordForm", "Ошибка: " + error.message, "error");
}
},
[api, buildRecordPayload, closeRecordModal, loadReferenceRows, loadTable, recordModal, resolveMutationConfig, setStatus, tableCatalog]
);
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 });
await loadReferenceRows(tableCatalog, undefined);
} catch (error) {
setStatus(tableKey, "Ошибка удаления: " + error.message, "error");
}
},
[api, loadReferenceRows, loadTable, resolveMutationConfig, setStatus, tableCatalog]
);
const claimRequest = useCallback(
async (requestId) => {
if (!requestId) return;
try {
setStatus("requests", "Назначение заявки...", "");
setStatus("kanban", "Назначение заявки...", "");
await api("/api/admin/requests/" + requestId + "/claim", { method: "POST" });
setStatus("requests", "Заявка взята в работу", "ok");
setStatus("kanban", "Заявка взята в работу", "ok");
const refreshRequests = canAccessSection(role, "requests")
? loadTable("requests", { resetOffset: true })
: Promise.resolve();
await Promise.all([refreshRequests, loadKanban()]);
} catch (error) {
setStatus("requests", "Ошибка назначения: " + error.message, "error");
setStatus("kanban", "Ошибка назначения: " + error.message, "error");
}
},
[api, loadKanban, loadTable, role, setStatus]
);
const openInvoiceRequest = useCallback(
(row, event) => {
if (!row || !row.request_id) return;
openRequestDetails(row.request_id, event);
},
[openRequestDetails]
);
const moveRequestFromKanban = useCallback(
async (row, targetGroup, explicitStatus) => {
const requestId = String(row?.id || "").trim();
if (!requestId) return;
const currentGroup = String(row?.status_group || fallbackStatusGroup(row?.status_code));
const groupKey = String(targetGroup || "").trim();
const targetStatusFromSelect = String(explicitStatus || "").trim();
const assignedLawyerId = String(row?.assigned_lawyer_id || "").trim();
if (role === "LAWYER" && !assignedLawyerId) {
setStatus("kanban", "Сначала возьмите заявку в работу", "error");
return;
}
if (
role === "LAWYER" &&
assignedLawyerId &&
String(assignedLawyerId) !== String(userId || "")
) {
setStatus("kanban", "Юрист может менять статус только своих заявок", "error");
return;
}
let targetStatus = targetStatusFromSelect;
const transitions = Array.isArray(row?.available_transitions) ? row.available_transitions : [];
if (!targetStatus) {
if (!groupKey || groupKey === currentGroup) return;
const candidates = transitions.filter((item) => String(item?.target_group || "") === groupKey);
if (!candidates.length) {
setStatus("kanban", "Для этой карточки нет перехода в выбранную колонку", "error");
return;
}
if (candidates.length > 1) {
await openRequestDetails(requestId, undefined, {
statusChangePreset: {
source: "kanban",
targetGroup: groupKey,
suggestedStatuses: candidates.map((item) => String(item?.to_status || "")).filter(Boolean),
},
});
setStatus("kanban", "Откройте модальное окно смены статуса и выберите конкретный статус", "ok");
return;
}
targetStatus = String(candidates[0]?.to_status || "").trim();
}
if (!targetStatus || targetStatus === String(row?.status_code || "")) return;
try {
setStatus("kanban", "Переводим заявку...", "");
await submitRequestStatusChange({ requestId, statusCode: targetStatus });
setStatus("kanban", "Статус заявки обновлен", "ok");
const refreshRequests = canAccessSection(role, "requests")
? loadTable("requests", { resetOffset: true })
: Promise.resolve();
await Promise.all([loadKanban(), refreshRequests]);
} catch (error) {
setStatus("kanban", "Ошибка перехода: " + error.message, "error");
}
},
[loadKanban, loadTable, openRequestDetails, role, setStatus, submitRequestStatusChange, userId]
);
const downloadInvoicePdf = useCallback(
async (row, statusKey = "invoices") => {
if (!row || !row.id || !token) return;
try {
setStatus(statusKey, "Формируем 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(statusKey, "PDF скачан", "ok");
} catch (error) {
setStatus(statusKey, "Ошибка скачивания: " + error.message, "error");
}
},
[setStatus, token]
);
const downloadRequestInvoicePdf = useCallback(
async (row) => {
await downloadInvoicePdf(row, "requestModal");
},
[downloadInvoicePdf]
);
const resetAdminRoute = useCallback(() => {
const nextUrl = "/admin.html";
if (window.location.pathname !== nextUrl || window.location.search) {
window.history.replaceState(null, "", nextUrl);
}
}, []);
const goBackFromRequestWorkspace = useCallback(() => {
const targetSection = canAccessSection(role, "requests") ? "requests" : "kanban";
resetAdminRoute();
setActiveSection(targetSection);
refreshSection(targetSection);
}, [refreshSection, resetAdminRoute, role]);
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, clearFiltersFromModal, removeFilterChip } = useTableFilterActions({
filterModal,
closeFilterModal,
getFieldDef,
loadKanban,
loadTable,
setStatus,
setTableState,
tablesRef,
});
const selectConfigNode = useCallback(
(tableKey) => {
resetAdminRoute();
setConfigActiveKey(tableKey);
setActiveSection("config");
loadCurrentConfigTable(false, undefined, tableKey);
},
[loadCurrentConfigTable, resetAdminRoute]
);
const activateSection = useCallback(
(section) => {
const nextSection = canAccessSection(role, section) ? section : "dashboard";
resetAdminRoute();
setActiveSection(nextSection);
refreshSection(nextSection);
},
[refreshSection, resetAdminRoute, role]
);
const applyRequestsQuickFilterPreset = useCallback(
async (filters, statusMessage) => {
if (!canAccessSection(role, "requests")) return;
const nextFilters = Array.isArray(filters) ? filters.filter((item) => item && item.field) : [];
resetAdminRoute();
setActiveSection("requests");
const currentState = tablesRef.current.requests || createTableState();
setTableState("requests", {
...currentState,
filters: nextFilters,
offset: 0,
showAll: false,
});
if (statusMessage) setStatus("requests", statusMessage, "");
await loadTable("requests", { resetOffset: true, filtersOverride: nextFilters });
},
[loadTable, resetAdminRoute, role, setStatus, setTableState, tablesRef]
);
const applyKanbanQuickFilterPreset = useCallback(
async (filters, statusMessage) => {
const nextFilters = Array.isArray(filters) ? filters.filter((item) => item && item.field) : [];
resetAdminRoute();
setActiveSection("kanban");
const currentState = tablesRef.current.kanban || createTableState();
setTableState("kanban", {
...currentState,
filters: nextFilters,
offset: 0,
showAll: false,
});
if (statusMessage) setStatus("kanban", statusMessage, "");
await loadKanban(undefined, { filtersOverride: nextFilters });
},
[loadKanban, resetAdminRoute, setStatus, setTableState, tablesRef]
);
const openRequestsWithUnreadAlerts = useCallback(async () => {
await applyRequestsQuickFilterPreset([{ field: "has_unread_updates", op: "=", value: true }], "Показаны заявки с новыми оповещениями");
}, [applyRequestsQuickFilterPreset]);
const openRequestsWithDeadlineAlerts = useCallback(async () => {
await applyRequestsQuickFilterPreset([{ field: "deadline_alert", op: "=", value: true }], "Показаны заявки с горящими дедлайнами");
}, [applyRequestsQuickFilterPreset]);
const openKanbanWithUnreadAlerts = useCallback(async () => {
await applyKanbanQuickFilterPreset([{ field: "has_unread_updates", op: "=", value: true }], "Показаны заявки с новыми оповещениями");
}, [applyKanbanQuickFilterPreset]);
const openKanbanWithDeadlineAlerts = useCallback(async () => {
await applyKanbanQuickFilterPreset([{ field: "deadline_alert", op: "=", value: true }], "Показаны заявки с горящими дедлайнами");
}, [applyKanbanQuickFilterPreset]);
const applyServiceRequestsQuickFilterPreset = useCallback(
async (filters, statusMessage) => {
const nextFilters = Array.isArray(filters) ? filters.filter((item) => item && item.field) : [];
resetAdminRoute();
setActiveSection("serviceRequests");
const currentState = tablesRef.current.serviceRequests || createTableState();
setTableState("serviceRequests", {
...currentState,
filters: nextFilters,
offset: 0,
showAll: false,
});
if (statusMessage) setStatus("serviceRequests", statusMessage, "");
await loadTable("serviceRequests", { resetOffset: true, filtersOverride: nextFilters });
},
[loadTable, resetAdminRoute, setStatus, setTableState, tablesRef]
);
const openServiceRequestsWithUnreadAlerts = useCallback(async () => {
if (String(role || "").toUpperCase() === "LAWYER") {
await applyServiceRequestsQuickFilterPreset(
[{ field: "lawyer_unread", op: "=", value: true }],
"Показаны непрочитанные запросы клиента"
);
return;
}
await applyServiceRequestsQuickFilterPreset(
[{ field: "admin_unread", op: "=", value: true }],
"Показаны непрочитанные запросы клиента"
);
}, [applyServiceRequestsQuickFilterPreset, role]);
const markServiceRequestRead = useCallback(
async (serviceRequestId) => {
const rowId = String(serviceRequestId || "").trim();
if (!rowId) return;
try {
setStatus("serviceRequests", "Отмечаем как прочитанный...", "");
await api("/api/admin/requests/service-requests/" + encodeURIComponent(rowId) + "/read", { method: "POST" });
await Promise.all([loadTable("serviceRequests", { resetOffset: true }), loadDashboard()]);
if (canAccessSection(role, "requests")) await loadTable("requests", { resetOffset: true });
setStatus("serviceRequests", "Запрос отмечен как прочитанный", "ok");
} catch (error) {
setStatus("serviceRequests", "Ошибка: " + error.message, "error");
}
},
[api, loadDashboard, loadTable, role, setStatus]
);
const loadTotpStatus = useCallback(
async (tokenOverride) => {
const activeToken = tokenOverride !== undefined ? tokenOverride : token;
if (!activeToken) return;
try {
const data = await api("/api/admin/auth/totp/status", { method: "GET" }, activeToken);
if (data && typeof data === "object") {
setTotpStatus({
mode: String(data.mode || "password_totp_optional"),
enabled: Boolean(data.enabled),
required: Boolean(data.required),
has_backup_codes: Boolean(data.has_backup_codes),
});
}
} catch (_) {}
},
[api, token]
);
const openAccountModal = useCallback(async () => {
if (!token || !userId) {
setStatus("account", "Не удалось открыть профиль: отсутствует идентификатор пользователя", "error");
return;
}
setAccountModal((prev) => ({
...prev,
open: true,
loading: true,
saving: false,
}));
setStatus("account", "Загрузка профиля...", "");
try {
const row = await api("/api/admin/crud/admin_users/" + encodeURIComponent(String(userId)));
const nextInitial = {
name: String(row?.name || ""),
email: String(row?.email || email || ""),
phone: String(row?.phone || ""),
};
setAccountModal({
open: true,
loading: false,
saving: false,
initial: nextInitial,
form: {
...nextInitial,
password: "",
passwordConfirm: "",
},
});
setStatus("account", "", "");
} catch (error) {
setAccountModal((prev) => ({ ...prev, loading: false }));
setStatus("account", "Ошибка загрузки профиля: " + error.message, "error");
}
}, [api, email, setStatus, token, userId]);
const closeAccountModal = useCallback(() => {
setAccountModal((prev) => ({
...prev,
open: false,
loading: false,
saving: false,
form: {
name: prev.initial.name,
email: prev.initial.email,
phone: prev.initial.phone,
password: "",
passwordConfirm: "",
},
}));
setStatus("account", "", "");
}, [setStatus]);
const updateAccountField = useCallback((event) => {
const fieldName = String(event?.target?.name || "");
if (!fieldName) return;
setAccountModal((prev) => ({
...prev,
form: {
...prev.form,
[fieldName]: event.target.value,
},
}));
}, []);
const submitAccountModal = useCallback(
async (event) => {
event.preventDefault();
if (!token || !userId) return;
const form = accountModal.form || {};
const initial = accountModal.initial || {};
const nextName = String(form.name || "").trim();
const nextEmail = String(form.email || "").trim().toLowerCase();
const nextPhone = String(form.phone || "").trim();
const nextPassword = String(form.password || "");
const nextPasswordConfirm = String(form.passwordConfirm || "");
if (!nextName) {
setStatus("account", "Имя не может быть пустым", "error");
return;
}
if (!nextEmail) {
setStatus("account", "Почта не может быть пустой", "error");
return;
}
if (nextPassword && nextPassword.length < 8) {
setStatus("account", "Пароль должен быть не менее 8 символов", "error");
return;
}
if (nextPassword !== nextPasswordConfirm) {
setStatus("account", "Пароли не совпадают", "error");
return;
}
const payload = {};
if (nextName !== String(initial.name || "").trim()) payload.name = nextName;
if (nextEmail !== String(initial.email || "").trim().toLowerCase()) payload.email = nextEmail;
if (nextPhone !== String(initial.phone || "").trim()) payload.phone = nextPhone || null;
if (nextPassword) payload.password = nextPassword;
if (!Object.keys(payload).length) {
setStatus("account", "Нет изменений для сохранения", "");
return;
}
try {
setAccountModal((prev) => ({ ...prev, saving: true }));
setStatus("account", "Сохранение...", "");
const row = await api("/api/admin/crud/admin_users/" + encodeURIComponent(String(userId)), {
method: "PATCH",
body: payload,
});
const nextInitial = {
name: String(row?.name || nextName),
email: String(row?.email || nextEmail),
phone: String(row?.phone || nextPhone),
};
setAccountModal((prev) => ({
...prev,
saving: false,
initial: nextInitial,
form: {
...nextInitial,
password: "",
passwordConfirm: "",
},
}));
if (nextInitial.email) setEmail(nextInitial.email);
setStatus("account", "Профиль обновлен", "ok");
} catch (error) {
setAccountModal((prev) => ({ ...prev, saving: false }));
setStatus("account", "Ошибка сохранения: " + error.message, "error");
}
},
[accountModal.form, accountModal.initial, api, setStatus, token, userId]
);
const closeTotpSetupModal = useCallback(() => {
setTotpSetupModal({
open: false,
secret: "",
uri: "",
qrDataUrl: "",
code: "",
loading: false,
});
setStatus("totpSetup", "", "");
}, [setStatus]);
const updateTotpSetupCode = useCallback((event) => {
setTotpSetupModal((prev) => ({ ...prev, code: event.target.value }));
}, []);
const copyTotpSecret = useCallback(async () => {
const value = String(totpSetupModal.secret || "").trim();
if (!value) return;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(value);
setStatus("totpSetup", "Ключ скопирован в буфер обмена", "ok");
} else {
setStatus("totpSetup", "Буфер обмена недоступен в этом браузере", "error");
}
} catch (_) {
setStatus("totpSetup", "Не удалось скопировать ключ", "error");
}
}, [setStatus, totpSetupModal.secret]);
const copyTotpUri = useCallback(async () => {
const value = String(totpSetupModal.uri || "").trim();
if (!value) return;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(value);
setStatus("totpSetup", "URI скопирован в буфер обмена", "ok");
} else {
setStatus("totpSetup", "Буфер обмена недоступен в этом браузере", "error");
}
} catch (_) {
setStatus("totpSetup", "Не удалось скопировать URI", "error");
}
}, [setStatus, totpSetupModal.uri]);
const setupTotp = useCallback(async () => {
try {
const setup = await api("/api/admin/auth/totp/setup", { method: "POST", body: {} });
const secret = String(setup?.secret || "").trim();
const uri = String(setup?.otpauth_uri || "").trim();
if (!secret || !uri) throw new Error("Не удалось получить секрет TOTP");
let qrDataUrl = "";
try {
qrDataUrl = await QRCode.toDataURL(uri, {
margin: 1,
width: 240,
errorCorrectionLevel: "M",
});
} catch (_) {
qrDataUrl = "";
}
setTotpSetupModal({
open: true,
secret,
uri,
qrDataUrl,
code: "",
loading: false,
});
setStatus("totpSetup", "", "");
} catch (error) {
setStatus("login", "Ошибка настройки 2FA: " + error.message, "error");
}
}, [api, setStatus]);
const submitTotpSetup = useCallback(
async (event) => {
event.preventDefault();
const secret = String(totpSetupModal.secret || "").trim();
const rawCode = String(totpSetupModal.code || "").trim();
const digitsOnly = rawCode.replace(/\D+/g, "");
if (!secret) {
setStatus("totpSetup", "Не найден TOTP secret. Перезапустите настройку.", "error");
return;
}
if (digitsOnly.length !== 6) {
setStatus("totpSetup", "Введите корректный 6-значный код", "error");
return;
}
try {
setTotpSetupModal((prev) => ({ ...prev, loading: true }));
const enabled = await api("/api/admin/auth/totp/enable", { method: "POST", body: { secret, code: digitsOnly } });
closeTotpSetupModal();
setStatus("login", "2FA включена", "ok");
const backupCodes = Array.isArray(enabled?.backup_codes) ? enabled.backup_codes : [];
window.alert(
"2FA включена.\nСохраните резервные коды (однократно):\n\n" + (backupCodes.length ? backupCodes.join("\n") : "-")
);
await loadTotpStatus();
} catch (error) {
setTotpSetupModal((prev) => ({ ...prev, loading: false }));
setStatus("totpSetup", "Ошибка включения 2FA: " + error.message, "error");
}
},
[api, closeTotpSetupModal, loadTotpStatus, setStatus, totpSetupModal.code, totpSetupModal.secret]
);
const regenerateTotpBackupCodes = useCallback(async () => {
try {
const code = String(window.prompt("Введите TOTP код (или резервный код) для регенерации", "") || "").trim();
if (!code) return;
const payload = /^\d{6}$/.test(code) ? { code } : { backup_code: code };
const data = await api("/api/admin/auth/totp/backup/regenerate", { method: "POST", body: payload });
const backupCodes = Array.isArray(data?.backup_codes) ? data.backup_codes : [];
window.alert("Новые резервные коды:\n\n" + (backupCodes.length ? backupCodes.join("\n") : "-"));
await loadTotpStatus();
} catch (error) {
setStatus("login", "Ошибка регенерации backup-кодов: " + error.message, "error");
}
}, [api, loadTotpStatus, setStatus]);
const disableTotp = useCallback(async () => {
try {
const code = String(window.prompt("Введите TOTP код (или резервный код) для отключения 2FA", "") || "").trim();
if (!code) return;
const payload = /^\d{6}$/.test(code) ? { code } : { backup_code: code };
await api("/api/admin/auth/totp/disable", { method: "POST", body: payload });
setStatus("login", "2FA отключена", "ok");
await loadTotpStatus();
} catch (error) {
setStatus("login", "Ошибка отключения 2FA: " + error.message, "error");
}
}, [api, loadTotpStatus, setStatus]);
const logout = useCallback(() => {
localStorage.removeItem(LS_TOKEN);
setToken("");
setRole("");
setEmail("");
setUserId("");
setRecordModal({ open: false, tableKey: null, mode: "create", rowId: null, form: {} });
resetRequestWorkspaceState();
setFilterModal({ open: false, tableKey: null, field: "", op: "=", rawValue: "", editIndex: null });
resetKanbanState();
setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" });
setDashboardData({
scope: "",
cards: [],
byStatus: {},
lawyerLoads: [],
myUnreadByEvent: {},
myUnreadTotal: 0,
myUnreadNotificationsTotal: 0,
unreadForClients: 0,
unreadForLawyers: 0,
serviceRequestUnreadTotal: 0,
deadlineAlertTotal: 0,
monthRevenue: 0,
monthExpenses: 0,
});
setMetaJson("");
setConfigActiveKey("");
setReferencesExpanded(true);
resetTablesState();
setDictionaries({
topics: [],
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })),
formFieldTypes: [...DEFAULT_FORM_FIELD_TYPES],
formFieldKeys: [],
users: [],
});
setStatusMap({});
setSmsProviderHealth(null);
setTotpStatus({
mode: "password_totp_optional",
enabled: false,
required: false,
has_backup_codes: false,
});
setTotpSetupModal({
open: false,
secret: "",
uri: "",
qrDataUrl: "",
code: "",
loading: false,
});
setAccountModal({
open: false,
loading: false,
saving: false,
initial: { name: "", email: "", phone: "" },
form: { name: "", email: "", phone: "", password: "", passwordConfirm: "" },
});
setActiveSection("dashboard");
}, [resetKanbanState, resetRequestWorkspaceState, resetTablesState]);
const login = useCallback(
async (emailInput, passwordInput, totpCodeInput) => {
try {
setStatus("login", "Выполняем вход...", "");
const rawTotp = String(totpCodeInput || "").trim();
const digitsOnly = rawTotp.replace(/\D+/g, "");
const loginBody = {
email: String(emailInput || "").trim(),
password: passwordInput || "",
...(rawTotp
? digitsOnly.length === 6
? { totp_code: digitsOnly }
: { backup_code: rawTotp }
: {}),
};
const data = await api(
"/api/admin/auth/login",
{
method: "POST",
auth: false,
body: loginBody,
},
""
);
const nextToken = data.access_token;
const payload = decodeJwtPayload(nextToken || "");
if (!payload || !payload.role || !payload.email) throw new Error("Не удалось прочитать данные токена");
sessionStorage.removeItem(ADMIN_AUTH_REDIRECT_REASON_KEY);
localStorage.setItem(LS_TOKEN, nextToken);
setToken(nextToken);
setRole(payload.role);
setEmail(payload.email);
setUserId(String(payload.sub || ""));
setActiveSection("dashboard");
setStatus("login", "Успешный вход", "ok");
} catch (error) {
setStatus("login", "Ошибка входа: " + error.message, "error");
}
},
[api, setStatus]
);
useEffect(() => {
const authRedirectReason = sessionStorage.getItem(ADMIN_AUTH_REDIRECT_REASON_KEY) || "";
if (authRedirectReason === "expired") {
setStatus("login", "Сессия истекла. Войдите снова.", "error");
sessionStorage.removeItem(ADMIN_AUTH_REDIRECT_REASON_KEY);
}
const saved = localStorage.getItem(LS_TOKEN) || "";
if (!saved) return;
if (isAdminTokenExpired(saved)) {
localStorage.removeItem(LS_TOKEN);
setStatus("login", "Сессия истекла. Войдите снова.", "error");
return;
}
const payload = decodeJwtPayload(saved);
if (!payload || !payload.role || !payload.email) {
localStorage.removeItem(LS_TOKEN);
setStatus("login", "Сессия истекла. Войдите снова.", "error");
return;
}
setToken(saved);
setRole(payload.role);
setEmail(payload.email);
setUserId(String(payload.sub || ""));
}, [isAdminTokenExpired, setStatus]);
useEffect(() => {
if (!token || !role) return;
let cancelled = false;
let deferredBootstrapCleanup = null;
const scheduleDeferredBootstrap = () => {
if (typeof window !== "undefined" && typeof window.requestIdleCallback === "function") {
const handle = window.requestIdleCallback(() => {
if (!cancelled) bootstrapReferenceData(token, role);
}, { timeout: 1500 });
return () => {
if (typeof window.cancelIdleCallback === "function") window.cancelIdleCallback(handle);
};
}
const handle = window.setTimeout(() => {
if (!cancelled) bootstrapReferenceData(token, role);
}, 250);
return () => window.clearTimeout(handle);
};
(async () => {
if (!isRequestWorkspaceRoute && !routeInfo.section) {
if (!cancelled) await loadDashboard(token);
if (!cancelled) await loadTotpStatus(token);
if (!cancelled) deferredBootstrapCleanup = scheduleDeferredBootstrap();
return;
}
bootstrapReferenceData(token, role);
if (!cancelled) await loadTotpStatus(token);
})();
return () => {
cancelled = true;
if (typeof deferredBootstrapCleanup === "function") deferredBootstrapCleanup();
};
}, [bootstrapReferenceData, isRequestWorkspaceRoute, loadDashboard, loadTotpStatus, role, routeInfo.section, 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 (!token) {
setSmsProviderHealth(null);
return;
}
if (String(role || "").toUpperCase() !== "ADMIN") {
setSmsProviderHealth(null);
return;
}
if (activeSection !== "config" || configActiveKey !== "otp_sessions") return;
loadSmsProviderHealth(undefined, { silent: true });
}, [activeSection, configActiveKey, loadSmsProviderHealth, role, 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 || kanbanSortModal.open || totpSetupModal.open || accountModal.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 }));
closeKanbanSortModal();
setReassignModal((prev) => ({ ...prev, open: false }));
closeTotpSetupModal();
closeAccountModal();
};
document.addEventListener("keydown", onEsc);
return () => document.removeEventListener("keydown", onEsc);
}, [closeAccountModal, closeKanbanSortModal, closeTotpSetupModal]);
const menuItems = useMemo(() => {
const baseItems = [
{ key: "dashboard", label: "Обзор" },
{ key: "kanban", label: "Канбан" },
{ key: "requests", label: "Заявки" },
{ key: "serviceRequests", label: "Запросы" },
{ key: "invoices", label: "Счета" },
];
return baseItems.filter((item) => canAccessSection(role, item.key));
}, [role]);
const topbarUnreadCount = useMemo(() => {
const roleCode = String(role || "").toUpperCase();
if (roleCode === "LAWYER" || roleCode === "ADMIN" || roleCode === "CURATOR") {
return Number(dashboardData.myUnreadNotificationsTotal || dashboardData.myUnreadTotal || 0);
}
return Number(dashboardData.unreadForClients || 0) + Number(dashboardData.unreadForLawyers || 0);
}, [dashboardData.myUnreadNotificationsTotal, dashboardData.myUnreadTotal, dashboardData.unreadForClients, dashboardData.unreadForLawyers, role]);
const topbarDeadlineAlertCount = useMemo(() => Number(dashboardData.deadlineAlertTotal || 0), [dashboardData.deadlineAlertTotal]);
const topbarServiceRequestUnreadCount = useMemo(
() => Number(dashboardData.serviceRequestUnreadTotal || 0),
[dashboardData.serviceRequestUnreadTotal]
);
const topbarRoleCode = String(role || "").toUpperCase();
const canUseRequestsAlerts = topbarRoleCode === "ADMIN";
const canUseKanbanAlerts = topbarRoleCode === "LAWYER";
const showRequestAlertIcons = canUseRequestsAlerts || canUseKanbanAlerts;
const showServiceRequestIcon = canAccessSection(role, "serviceRequests");
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);
const isEdit = recordModal.mode !== "create";
const roleCode = String(role || "").toUpperCase();
const visible = isEdit ? all.filter((field) => !field.createOnly) : all.filter((field) => !field.autoCreate);
return visible.map((field) => {
const nextField = { ...field };
if (recordModal.tableKey === "requests" && isEdit) {
if (roleCode === "LAWYER" && field.key !== "topic_code") nextField.readOnly = true;
if (roleCode === "ADMIN" && (field.key === "client_id" || field.key === "client_name" || field.key === "client_phone")) {
nextField.readOnly = true;
}
}
if (
recordModal.tableKey === "serviceRequests" &&
isEdit &&
(field.key === "request_id" || field.key === "client_id" || field.key === "assigned_lawyer_id")
) {
nextField.readOnly = true;
}
return nextField;
});
}, [getRecordFields, recordModal.mode, recordModal.tableKey, role]);
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 && String(column.name) !== "id")
.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="/">
<img className="brand-mark" src="/brand-mark.svg" alt="" width="24" height="24" />
<span>Правовой трекер</span>
</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>
</aside>
<main className="main">
<div className="topbar">
<div>
<h1>Панель администратора</h1>
<p className="muted">UniversalQuery, RBAC и аудит действий по ключевым сущностям системы.</p>
</div>
<div className="topbar-actions" aria-label="Быстрые уведомления и профиль">
{showServiceRequestIcon ? (
<button
type="button"
className={
"icon-btn topbar-alert-btn" + (topbarServiceRequestUnreadCount > 0 ? " has-alert alert-danger" : "")
}
data-tooltip={
topbarServiceRequestUnreadCount > 0
? "Новые клиентские запросы: " + String(topbarServiceRequestUnreadCount)
: "Новых клиентских запросов нет"
}
aria-label="Показать непрочитанные запросы клиента"
onClick={openServiceRequestsWithUnreadAlerts}
>
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false">
<path
d="M4.5 4.5h15a1.5 1.5 0 0 1 1.5 1.5v9.8a1.5 1.5 0 0 1-1.5 1.5H9.1l-3.7 3.1c-.98.82-2.4.13-2.4-1.14V6a1.5 1.5 0 0 1 1.5-1.5zm1.7 4.2a1.1 1.1 0 1 0 0 2.2 1.1 1.1 0 0 0 0-2.2zm5.8 0a1.1 1.1 0 1 0 0 2.2 1.1 1.1 0 0 0 0-2.2zm5.8 0a1.1 1.1 0 1 0 0 2.2 1.1 1.1 0 0 0 0-2.2z"
fill="currentColor"
/>
</svg>
<span className="topbar-alert-dot" aria-hidden="true" />
</button>
) : null}
{showRequestAlertIcons ? (
<>
<button
type="button"
className={
"icon-btn topbar-alert-btn" + (topbarDeadlineAlertCount > 0 ? " has-alert alert-danger" : "")
}
data-tooltip={
topbarDeadlineAlertCount > 0
? "Горящие дедлайны: " + String(topbarDeadlineAlertCount)
: "Горящих дедлайнов нет"
}
aria-label="Показать заявки с горящими дедлайнами"
onClick={canUseRequestsAlerts ? openRequestsWithDeadlineAlerts : openKanbanWithDeadlineAlerts}
>
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false">
<path
d="M12 3a1.6 1.6 0 0 1 1.42.86l7.14 13.7A1.6 1.6 0 0 1 19.14 20H4.86a1.6 1.6 0 0 1-1.42-2.44l7.14-13.7A1.6 1.6 0 0 1 12 3zm0 4.2a1 1 0 0 0-1 1v5.2a1 1 0 1 0 2 0V8.2a1 1 0 0 0-1-1zm0 9.4a1.15 1.15 0 1 0 0 2.3 1.15 1.15 0 0 0 0-2.3z"
fill="currentColor"
/>
</svg>
<span className="topbar-alert-dot" aria-hidden="true" />
</button>
<button
type="button"
className={
"icon-btn topbar-alert-btn" + (topbarUnreadCount > 0 ? " has-alert alert-success" : "")
}
data-tooltip={
topbarUnreadCount > 0
? "Новые оповещения по заявкам: " + String(topbarUnreadCount)
: "Новых оповещений нет"
}
aria-label="Показать заявки с новыми оповещениями"
onClick={canUseRequestsAlerts ? openRequestsWithUnreadAlerts : openKanbanWithUnreadAlerts}
>
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false">
<path
d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5v-11zm2 .5v.32l6 4.44 6-4.44V7a.5.5 0 0 0-.5-.5h-11A.5.5 0 0 0 6 7zm12 2.8-5.4 4a1 1 0 0 1-1.2 0L6 9.8v7.7c0 .28.22.5.5.5h11a.5.5 0 0 0 .5-.5V9.8z"
fill="currentColor"
/>
</svg>
<span className="topbar-alert-dot" aria-hidden="true" />
</button>
</>
) : null}
<button
type="button"
className="icon-btn topbar-alert-btn"
data-tooltip="Личный кабинет"
aria-label="Открыть личный кабинет"
onClick={openAccountModal}
>
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true" focusable="false">
<path
d="M12 12.2a4.1 4.1 0 1 0-4.1-4.1 4.1 4.1 0 0 0 4.1 4.1zm0 2c-3.8 0-7 2.2-7.8 5.3-.1.4.2.8.6.8h14.4c.4 0 .7-.4.6-.8-.8-3.1-4-5.3-7.8-5.3z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<Section active={activeSection === "dashboard"} id="section-dashboard">
<DashboardSection
dashboardData={dashboardData}
token={token}
status={getStatus("dashboard")}
apiCall={api}
onOpenRequest={openRequestDetails}
DataTableComponent={DataTable}
StatusLineComponent={StatusLine}
UserAvatarComponent={UserAvatar}
/>
</Section>
<Section active={activeSection === "kanban"} id="section-kanban">
<KanbanBoard
loading={kanbanLoading}
columns={kanbanData.columns}
rows={kanbanData.rows}
role={role}
actorId={userId}
onRefresh={() => loadKanban()}
filters={tables.kanban.filters}
onOpenFilter={() => openFilterModal("kanban")}
onRemoveFilter={(index) => removeFilterChip("kanban", index)}
onEditFilter={(index) => openFilterEditModal("kanban", index)}
getFilterChipLabel={(clause) => {
const fieldDef = getFieldDef("kanban", clause.field);
return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("kanban", clause);
}}
onOpenSort={openKanbanSortModal}
sortActive={kanbanSortApplied}
onOpenRequest={openRequestDetails}
onClaimRequest={claimRequest}
onMoveRequest={moveRequestFromKanban}
status={getStatus("kanban")}
FilterToolbarComponent={FilterToolbar}
StatusLineComponent={StatusLine}
/>
</Section>
{canAccessSection(role, "requests") ? (
<Section active={activeSection === "requests"} id="section-requests">
<RequestsSection
role={role}
tables={tables}
status={getStatus("requests")}
getFieldDef={getFieldDef}
getFilterValuePreview={getFilterValuePreview}
resolveReferenceLabel={resolveReferenceLabel}
onRefresh={() => loadTable("requests", { resetOffset: true })}
onCreate={() => openCreateRecordModal("requests")}
onOpenFilter={() => openFilterModal("requests")}
onRemoveFilter={(index) => removeFilterChip("requests", index)}
onEditFilter={(index) => openFilterEditModal("requests", index)}
onSort={(field) => toggleTableSort("requests", field)}
onPrev={() => loadPrevPage("requests")}
onNext={() => loadNextPage("requests")}
onLoadAll={() => loadAllRows("requests")}
onClaimRequest={claimRequest}
onOpenReassign={openReassignModal}
onOpenRequest={openRequestDetails}
onEditRecord={(row) => openEditRecordModal("requests", row)}
onDeleteRecord={(id) => deleteRecord("requests", id)}
FilterToolbarComponent={FilterToolbar}
DataTableComponent={DataTable}
TablePagerComponent={TablePager}
StatusLineComponent={StatusLine}
IconButtonComponent={IconButton}
/>
</Section>
) : null}
<Section active={activeSection === "serviceRequests"} id="section-service-requests">
<ServiceRequestsSection
role={role}
tables={tables}
status={getStatus("serviceRequests")}
getFieldDef={getFieldDef}
getFilterValuePreview={getFilterValuePreview}
resolveReferenceLabel={resolveReferenceLabel}
onRefresh={() => loadTable("serviceRequests", { resetOffset: true })}
onCreate={() => openCreateRecordModal("serviceRequests")}
onOpenFilter={() => openFilterModal("serviceRequests")}
onRemoveFilter={(index) => removeFilterChip("serviceRequests", index)}
onEditFilter={(index) => openFilterEditModal("serviceRequests", index)}
onSort={(field) => toggleTableSort("serviceRequests", field)}
onPrev={() => loadPrevPage("serviceRequests")}
onNext={() => loadNextPage("serviceRequests")}
onLoadAll={() => loadAllRows("serviceRequests")}
onOpenRequest={openRequestDetails}
onMarkRead={markServiceRequestRead}
onEditRecord={(row) => openEditRecordModal("serviceRequests", row)}
onDeleteRecord={(id) => deleteRecord("serviceRequests", id)}
FilterToolbarComponent={FilterToolbar}
DataTableComponent={DataTable}
TablePagerComponent={TablePager}
StatusLineComponent={StatusLine}
IconButtonComponent={IconButton}
/>
</Section>
<Section active={activeSection === "requestWorkspace"} id="section-request-workspace">
<div className="section-head">
<div>
<h2>{requestModal.trackNumber ? "Карточка заявки " + requestModal.trackNumber : "Карточка заявки"}</h2>
</div>
<div style={{ display: "flex", gap: "0.45rem", flexWrap: "wrap" }}>
<button className="icon-btn workspace-head-icon" type="button" data-tooltip="Назад" aria-label="Назад" onClick={goBackFromRequestWorkspace}>
<span className="workspace-head-icon-glyph"></span>
</button>
<button
className="icon-btn workspace-head-icon"
type="button"
data-tooltip="Обновить"
aria-label="Обновить"
onClick={refreshRequestModal}
disabled={requestModal.loading || requestModal.fileUploading}
>
<span className="workspace-head-icon-glyph"></span>
</button>
</div>
</div>
<RequestWorkspace
viewerRole={role}
viewerUserId={userId}
viewerUserEmail={email}
viewerUserName={dictionaries.users?.find((item) => String(item?.id || "") === String(userId || ""))?.name || ""}
loading={requestModal.loading}
trackNumber={requestModal.trackNumber}
requestData={requestModal.requestData}
financeSummary={requestModal.financeSummary}
invoices={requestModal.invoices || []}
statusRouteNodes={requestModal.statusRouteNodes}
statusHistory={requestModal.statusHistory || []}
availableStatuses={requestModal.availableStatuses || []}
currentImportantDateAt={requestModal.currentImportantDateAt || ""}
pendingStatusChangePreset={requestModal.pendingStatusChangePreset}
messages={requestModal.messages || []}
messagesHasMore={Boolean(requestModal.messagesHasMore)}
messagesLoadingMore={Boolean(requestModal.messagesLoadingMore)}
attachments={requestModal.attachments || []}
messageDraft={requestModal.messageDraft || ""}
selectedFiles={requestModal.selectedFiles || []}
fileUploading={Boolean(requestModal.fileUploading)}
status={getStatus("requestModal")}
onMessageChange={updateRequestModalMessageDraft}
onSendMessage={submitRequestModalMessage}
onLoadOlderMessages={loadOlderRequestMessages}
onFilesSelect={appendRequestModalFiles}
onRemoveSelectedFile={removeRequestModalFile}
onClearSelectedFiles={clearRequestModalFiles}
onLoadRequestDataTemplates={loadRequestDataTemplates}
onLoadRequestDataBatch={loadRequestDataBatch}
onLoadRequestDataTemplateDetails={loadRequestDataTemplateDetails}
onSaveRequestDataTemplate={saveRequestDataTemplate}
onSaveRequestDataBatch={saveRequestDataBatch}
onIssueInvoice={issueRequestInvoice}
onDownloadInvoicePdf={downloadRequestInvoicePdf}
onChangeStatus={submitRequestStatusChange}
onConsumePendingStatusChangePreset={clearPendingStatusChangePreset}
onLiveProbe={probeRequestLive}
onTypingSignal={setRequestTyping}
AttachmentPreviewModalComponent={AttachmentPreviewModal}
StatusLineComponent={StatusLine}
/>
</Section>
<Section active={activeSection === "invoices"} id="section-invoices">
<InvoicesSection
role={role}
tables={tables}
status={getStatus("invoices")}
getFieldDef={getFieldDef}
getFilterValuePreview={getFilterValuePreview}
onRefresh={() => loadTable("invoices", { resetOffset: true })}
onCreate={() => openCreateRecordModal("invoices")}
onOpenFilter={() => openFilterModal("invoices")}
onRemoveFilter={(index) => removeFilterChip("invoices", index)}
onEditFilter={(index) => openFilterEditModal("invoices", index)}
onSort={(field) => toggleTableSort("invoices", field)}
onPrev={() => loadPrevPage("invoices")}
onNext={() => loadNextPage("invoices")}
onLoadAll={() => loadAllRows("invoices")}
onOpenRequest={openInvoiceRequest}
onDownloadPdf={downloadInvoicePdf}
onEditRecord={(row) => openEditRecordModal("invoices", row)}
onDeleteRecord={(id) => deleteRecord("invoices", id)}
FilterToolbarComponent={FilterToolbar}
DataTableComponent={DataTable}
TablePagerComponent={TablePager}
StatusLineComponent={StatusLine}
IconButtonComponent={IconButton}
/>
</Section>
<Section active={activeSection === "quotes"} id="section-quotes">
<QuotesSection
tables={tables}
status={getStatus("quotes")}
getFieldDef={getFieldDef}
getFilterValuePreview={getFilterValuePreview}
onRefresh={() => loadTable("quotes", { resetOffset: true })}
onCreate={() => openCreateRecordModal("quotes")}
onOpenFilter={() => openFilterModal("quotes")}
onRemoveFilter={(index) => removeFilterChip("quotes", index)}
onEditFilter={(index) => openFilterEditModal("quotes", index)}
onSort={(field) => toggleTableSort("quotes", field)}
onPrev={() => loadPrevPage("quotes")}
onNext={() => loadNextPage("quotes")}
onLoadAll={() => loadAllRows("quotes")}
onEditRecord={(row) => openEditRecordModal("quotes", row)}
onDeleteRecord={(id) => deleteRecord("quotes", id)}
FilterToolbarComponent={FilterToolbar}
DataTableComponent={DataTable}
TablePagerComponent={TablePager}
StatusLineComponent={StatusLine}
IconButtonComponent={IconButton}
/>
</Section>
<Section active={activeSection === "config"} id="section-config">
<ConfigSection
token={token}
tables={tables}
dictionaries={dictionaries}
configActiveKey={configActiveKey}
activeConfigTableState={activeConfigTableState}
activeConfigMeta={activeConfigMeta}
genericConfigHeaders={genericConfigHeaders}
canCreateInConfig={canCreateInConfig}
canUpdateInConfig={canUpdateInConfig}
canDeleteInConfig={canDeleteInConfig}
statusDesignerTopicCode={statusDesignerTopicCode}
statusDesignerCards={statusDesignerCards}
getTableLabel={getTableLabel}
getFieldDef={getFieldDef}
getFilterValuePreview={getFilterValuePreview}
resolveReferenceLabel={resolveReferenceLabel}
resolveTableConfig={resolveTableConfig}
getStatus={getStatus}
loadCurrentConfigTable={loadCurrentConfigTable}
onRefreshSmsProviderHealth={() => loadSmsProviderHealth(undefined, { silent: false })}
smsProviderHealth={smsProviderHealth}
openCreateRecordModal={openCreateRecordModal}
openFilterModal={openFilterModal}
removeFilterChip={removeFilterChip}
openFilterEditModal={openFilterEditModal}
toggleTableSort={toggleTableSort}
openEditRecordModal={openEditRecordModal}
deleteRecord={deleteRecord}
loadStatusDesignerTopic={loadStatusDesignerTopic}
openCreateStatusTransitionForTopic={openCreateStatusTransitionForTopic}
loadPrevPage={loadPrevPage}
loadNextPage={loadNextPage}
loadAllRows={loadAllRows}
FilterToolbarComponent={FilterToolbar}
DataTableComponent={DataTable}
TablePagerComponent={TablePager}
StatusLineComponent={StatusLine}
IconButtonComponent={IconButton}
UserAvatarComponent={UserAvatar}
/>
</Section>
<Section active={activeSection === "availableTables"} id="section-available-tables">
<AvailableTablesSection
tables={tables}
status={getStatus("availableTables")}
onRefresh={() => loadAvailableTables()}
onToggleActive={updateAvailableTableState}
DataTableComponent={DataTable}
StatusLineComponent={StatusLine}
IconButtonComponent={IconButton}
/>
</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}
/>
<KanbanSortModal
open={kanbanSortModal.open}
value={kanbanSortModal.value}
status={getStatus("kanbanSort")}
onChange={updateKanbanSortMode}
onClose={closeKanbanSortModal}
onSubmit={submitKanbanSortModal}
/>
<ReassignModal
open={reassignModal.open}
status={getStatus("reassignForm")}
options={getLawyerOptions()}
value={reassignModal.lawyerId}
onChange={updateReassignLawyer}
onClose={closeReassignModal}
onSubmit={submitReassignModal}
trackNumber={reassignModal.trackNumber}
/>
<TotpSetupModal
open={totpSetupModal.open}
status={getStatus("totpSetup")}
secret={totpSetupModal.secret}
uri={totpSetupModal.uri}
qrDataUrl={totpSetupModal.qrDataUrl}
code={totpSetupModal.code}
loading={totpSetupModal.loading}
onCodeChange={updateTotpSetupCode}
onClose={closeTotpSetupModal}
onSubmit={submitTotpSetup}
onCopySecret={copyTotpSecret}
onCopyUri={copyTotpUri}
/>
<AccountModal
open={accountModal.open}
status={getStatus("account")}
profileLoading={accountModal.loading}
saveLoading={accountModal.saving}
form={accountModal.form}
currentEmail={email}
currentRoleLabel={roleLabel(role)}
totpStatus={totpStatus}
onFieldChange={updateAccountField}
onClose={closeAccountModal}
onSubmit={submitAccountModal}
onSetupTotp={setupTotp}
onRegenerateBackupCodes={regenerateTotpBackupCodes}
onDisableTotp={disableTotp}
onLogout={logout}
/>
{!token || !role ? <LoginScreen onSubmit={login} status={getStatus("login")} /> : null}
<GlobalTooltipLayer />
</>
);
}
const root = ReactDOM.createRoot(document.getElementById("admin-root"));
root.render(<App />);
})();