Law/app/web/admin/features/requests/RequestWorkspace.jsx
2026-03-02 21:56:37 +03:00

2423 lines
109 KiB
JavaScript
Raw 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 {
detectAttachmentPreviewKind,
fmtAmount,
fmtBytes,
fmtDate,
fmtDateOnly,
fmtShortDateTime,
fmtTimeOnly,
humanizeKey,
statusLabel,
} from "../../shared/utils.js";
export function RequestWorkspace({
viewerRole,
viewerUserId,
loading,
trackNumber,
requestData,
financeSummary,
statusRouteNodes,
statusHistory,
availableStatuses,
currentImportantDateAt,
pendingStatusChangePreset,
messages,
attachments,
messageDraft,
selectedFiles,
fileUploading,
status,
onMessageChange,
onSendMessage,
onFilesSelect,
onRemoveSelectedFile,
onClearSelectedFiles,
onLoadRequestDataTemplates,
onLoadRequestDataBatch,
onLoadRequestDataTemplateDetails,
onSaveRequestDataTemplate,
onSaveRequestDataBatch,
onSaveRequestDataValues,
onUploadRequestAttachment,
onChangeStatus,
onConsumePendingStatusChangePreset,
onLiveProbe,
onTypingSignal,
domIds,
AttachmentPreviewModalComponent,
StatusLineComponent,
}) {
const { useEffect, useMemo, useRef, useState } = React;
const [preview, setPreview] = useState({ open: false, url: "", fileName: "", mimeType: "" });
const [chatTab, setChatTab] = useState("chat");
const [dropActive, setDropActive] = useState(false);
const [financeOpen, setFinanceOpen] = useState(false);
const [requestDataListOpen, setRequestDataListOpen] = useState(false);
const [descriptionOpen, setDescriptionOpen] = useState(false);
const [requestTemplateSuggestOpen, setRequestTemplateSuggestOpen] = useState(false);
const [catalogFieldSuggestOpen, setCatalogFieldSuggestOpen] = useState(false);
const [statusChangeModal, setStatusChangeModal] = useState({
open: false,
saving: false,
statusCode: "",
allowedStatusCodes: null,
importantDateAt: "",
comment: "",
files: [],
error: "",
});
const [draggedRequestRowId, setDraggedRequestRowId] = useState("");
const [dragOverRequestRowId, setDragOverRequestRowId] = useState("");
const [dataRequestModal, setDataRequestModal] = useState({
open: false,
loading: false,
saving: false,
savingTemplate: false,
messageId: "",
documentName: "",
availableDocuments: [],
templateList: [],
requestTemplateQuery: "",
templateName: "",
selectedRequestTemplateId: "",
templates: [],
catalogFieldQuery: "",
selectedCatalogTemplateId: "",
rows: [],
customLabel: "",
customType: "string",
templateStatus: "",
error: "",
});
const [clientDataModal, setClientDataModal] = useState({
open: false,
loading: false,
saving: false,
messageId: "",
items: [],
status: "",
error: "",
});
const [composerFocused, setComposerFocused] = useState(false);
const [typingPeers, setTypingPeers] = useState([]);
const [liveMode, setLiveMode] = useState("online");
const fileInputRef = useRef(null);
const statusChangeFileInputRef = useRef(null);
const chatListRef = useRef(null);
const liveCursorRef = useRef("");
const liveTimerRef = useRef(null);
const liveInFlightRef = useRef(false);
const liveFailCountRef = useRef(0);
const typingHeartbeatRef = useRef(null);
const typingActiveRef = useRef(false);
const lastAutoScrollCursorRef = useRef("");
const idMap = useMemo(
() => ({
messagesList: "request-modal-messages",
filesList: "request-modal-files",
messageBody: "request-modal-message-body",
sendButton: "request-modal-message-send",
fileInput: "request-modal-file-input",
fileUploadButton: "",
dataRequestOverlay: "data-request-overlay",
dataRequestItems: "data-request-items",
dataRequestStatus: "data-request-status",
dataRequestSave: "data-request-save",
...(domIds || {}),
}),
[domIds]
);
const requestDataTypeOptions = useMemo(
() => [
{ value: "string", label: "Строка" },
{ value: "date", label: "Дата" },
{ value: "number", label: "Число" },
{ value: "file", label: "Файл" },
{ value: "text", label: "Текст" },
],
[]
);
const openPreview = (item) => {
if (!item?.download_url) return;
setPreview({
open: true,
url: String(item.download_url),
fileName: String(item.file_name || ""),
mimeType: String(item.mime_type || ""),
});
};
const closePreview = () => setPreview({ open: false, url: "", fileName: "", mimeType: "" });
const pendingFiles = Array.isArray(selectedFiles) ? selectedFiles : [];
const hasPendingFiles = pendingFiles.length > 0;
const canSubmit = Boolean(String(messageDraft || "").trim() || hasPendingFiles);
const onInputFiles = (event) => {
const files = Array.from((event.target && event.target.files) || []);
if (files.length && typeof onFilesSelect === "function") onFilesSelect(files);
event.target.value = "";
};
const onDropFiles = (event) => {
event.preventDefault();
setDropActive(false);
const files = Array.from((event.dataTransfer && event.dataTransfer.files) || []);
if (files.length && typeof onFilesSelect === "function") onFilesSelect(files);
};
const row = requestData && typeof requestData === "object" ? requestData : null;
const finance = financeSummary && typeof financeSummary === "object" ? financeSummary : null;
const viewerRoleCode = String(viewerRole || "").toUpperCase();
const canRequestData = viewerRoleCode === "LAWYER" || viewerRoleCode === "ADMIN";
const canFillRequestData = viewerRoleCode === "CLIENT";
const canSeeRate = viewerRoleCode !== "CLIENT";
const canSeeCreatedUpdatedInCard = viewerRoleCode !== "CLIENT";
const showTopicStatusInCard = viewerRoleCode !== "CLIENT";
const safeMessages = Array.isArray(messages) ? messages : [];
const safeAttachments = Array.isArray(attachments) ? attachments : [];
const safeStatusHistory = Array.isArray(statusHistory) ? statusHistory : [];
const safeAvailableStatuses = Array.isArray(availableStatuses) ? availableStatuses : [];
const totalFilesBytes = safeAttachments.reduce((acc, item) => acc + Number(item?.size_bytes || 0), 0);
const clientLabel = row?.client_name || "-";
const clientPhone = String(row?.client_phone || "").trim();
const lawyerLabel = row?.assigned_lawyer_name || row?.assigned_lawyer_id || "Не назначен";
const lawyerPhone = String(row?.assigned_lawyer_phone || "").trim();
const clientHasPhone = Boolean(clientPhone);
const lawyerHasPhone = Boolean(lawyerPhone);
const messagePlaceholder = canFillRequestData ? "Введите сообщение для юриста" : "Введите сообщение для клиента";
const selectedRequestTemplateCandidate = useMemo(
() =>
(dataRequestModal.templateList || []).find((item) => {
const query = String(dataRequestModal.requestTemplateQuery || "").trim().toLowerCase();
if (!query) return false;
return query === String(item?.name || "").trim().toLowerCase() || query === String(item?.id || "").trim().toLowerCase();
}) || null,
[dataRequestModal.requestTemplateQuery, dataRequestModal.templateList]
);
const selectedCatalogFieldCandidate = useMemo(
() =>
(dataRequestModal.templates || []).find((item) => {
const query = String(dataRequestModal.catalogFieldQuery || "").trim().toLowerCase();
if (!query) return false;
return (
query === String(item?.label || "").trim().toLowerCase() ||
query === String(item?.key || "").trim().toLowerCase() ||
query === String(item?.id || "").trim().toLowerCase()
);
}) || null,
[dataRequestModal.catalogFieldQuery, dataRequestModal.templates]
);
const filteredRequestTemplates = useMemo(() => {
const query = String(dataRequestModal.requestTemplateQuery || "").trim().toLowerCase();
const rows = Array.isArray(dataRequestModal.templateList) ? dataRequestModal.templateList : [];
if (!query) return rows.slice(0, 8);
return rows
.filter((item) => String(item?.name || "").toLowerCase().includes(query))
.slice(0, 8);
}, [dataRequestModal.requestTemplateQuery, dataRequestModal.templateList]);
const filteredCatalogFields = useMemo(() => {
const query = String(dataRequestModal.catalogFieldQuery || "").trim().toLowerCase();
const rows = Array.isArray(dataRequestModal.templates) ? dataRequestModal.templates : [];
if (!query) return rows.slice(0, 10);
return rows
.filter((item) => {
const label = String(item?.label || "").toLowerCase();
const key = String(item?.key || "").toLowerCase();
return label.includes(query) || key.includes(query);
})
.slice(0, 10);
}, [dataRequestModal.catalogFieldQuery, dataRequestModal.templates]);
const requestTemplateActionMode = selectedRequestTemplateCandidate ? "save" : String(dataRequestModal.requestTemplateQuery || "").trim() ? "create" : "";
const catalogFieldActionMode = selectedCatalogFieldCandidate ? "add" : String(dataRequestModal.catalogFieldQuery || "").trim() ? "create" : "";
const requestTemplateBadge = useMemo(() => {
const query = String(dataRequestModal.requestTemplateQuery || "").trim();
if (!query) return null;
const matched = selectedRequestTemplateCandidate;
if (!matched) return { kind: "create", label: "Новый шаблон" };
const roleCode = String(viewerRole || "").toUpperCase();
const actorId = String(viewerUserId || "").trim();
const ownerId = String(matched.created_by_admin_id || "").trim();
if (roleCode === "LAWYER" && ownerId && actorId && ownerId !== actorId) {
return { kind: "readonly", label: "Чужой шаблон" };
}
return { kind: "existing", label: "Существующий шаблон" };
}, [dataRequestModal.requestTemplateQuery, selectedRequestTemplateCandidate, viewerRole, viewerUserId]);
const canSaveSelectedRequestTemplate = useMemo(() => {
if (!String(dataRequestModal.requestTemplateQuery || "").trim()) return false;
if (!requestTemplateBadge) return true;
return requestTemplateBadge.kind !== "readonly";
}, [dataRequestModal.requestTemplateQuery, requestTemplateBadge]);
const attachmentById = useMemo(() => {
const map = new Map();
safeAttachments.forEach((item) => {
const id = String(item?.id || "").trim();
if (id) map.set(id, item);
});
return map;
}, [safeAttachments]);
const statusOptions = useMemo(
() =>
safeAvailableStatuses
.filter((item) => item && item.code)
.map((item) => ({
code: String(item.code),
name: String(item.name || item.code),
groupName: item.status_group_name ? String(item.status_group_name) : "",
isTerminal: Boolean(item.is_terminal),
})),
[safeAvailableStatuses]
);
const statusByCode = useMemo(() => new Map(statusOptions.map((item) => [item.code, item])), [statusOptions]);
const toDateTimeLocalValue = (value) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const pad = (n) => String(n).padStart(2, "0");
return (
date.getFullYear() +
"-" +
pad(date.getMonth() + 1) +
"-" +
pad(date.getDate()) +
"T" +
pad(date.getHours()) +
":" +
pad(date.getMinutes())
);
};
const defaultImportantDateLocal = useMemo(() => {
const source = String(currentImportantDateAt || row?.important_date_at || "").trim();
if (source) {
const local = toDateTimeLocalValue(source);
if (local) return local;
}
const next = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000);
return toDateTimeLocalValue(next.toISOString());
}, [currentImportantDateAt, row?.important_date_at]);
const formatDuration = (seconds) => {
const total = Number(seconds);
if (!Number.isFinite(total) || total < 0) return "—";
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
if (days > 0) return days + " д " + hours + " ч";
if (hours > 0) return hours + " ч " + minutes + " мин";
return Math.max(0, minutes) + " мин";
};
const openStatusChangeModal = (preset) => {
const suggested = Array.isArray(preset?.suggestedStatuses) ? preset.suggestedStatuses.filter(Boolean) : [];
const currentCode = String(row?.status_code || "").trim();
const firstSuggested = suggested.find((code) => code && code !== currentCode) || "";
setStatusChangeModal({
open: true,
saving: false,
statusCode: firstSuggested,
allowedStatusCodes: suggested.length ? suggested : null,
importantDateAt: defaultImportantDateLocal,
comment: "",
files: [],
error: "",
});
};
const closeStatusChangeModal = () => {
setStatusChangeModal((prev) => ({ ...prev, open: false, saving: false, error: "", files: [] }));
};
useEffect(() => {
if (!pendingStatusChangePreset) return;
openStatusChangeModal(pendingStatusChangePreset);
if (typeof onConsumePendingStatusChangePreset === "function") onConsumePendingStatusChangePreset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingStatusChangePreset]);
const requestDataListItems = useMemo(() => {
const byKey = new Map();
const messagesChrono = [...safeMessages].sort((a, b) => {
const at = new Date(a?.created_at || 0).getTime();
const bt = new Date(b?.created_at || 0).getTime();
if (at !== bt) return at - bt;
return String(a?.id || "").localeCompare(String(b?.id || ""), "ru");
});
messagesChrono.forEach((msg) => {
if (String(msg?.message_kind || "") !== "REQUEST_DATA") return;
const items = Array.isArray(msg?.request_data_items) ? msg.request_data_items : [];
items.forEach((item, idx) => {
const key = String(item?.key || item?.id || "item-" + idx);
if (!key) return;
byKey.set(key, {
id: String(item?.id || ""),
key,
label: String(item?.label || item?.label_short || key),
field_type: String(item?.field_type || "string").toLowerCase(),
value_text: item?.value_text == null ? "" : String(item.value_text),
is_filled: Boolean(item?.is_filled),
source_message_id: String(msg?.id || ""),
source_message_created_at: msg?.created_at || null,
value_file: item?.value_file || null,
});
});
});
return Array.from(byKey.values()).sort((a, b) => {
const aFilled = a.is_filled ? 1 : 0;
const bFilled = b.is_filled ? 1 : 0;
if (aFilled !== bFilled) return aFilled - bFilled;
return String(a.label || a.key).localeCompare(String(b.label || b.key), "ru");
});
}, [safeMessages]);
const attachmentsByMessageId = useMemo(() => {
const map = new Map();
safeAttachments.forEach((item) => {
const messageId = String(item?.message_id || "").trim();
if (!messageId) return;
if (!map.has(messageId)) map.set(messageId, []);
map.get(messageId).push(item);
});
return map;
}, [safeAttachments]);
const localActivityCursor = useMemo(() => {
let latestTs = 0;
const pickLatest = (value) => {
if (!value) return;
const ts = new Date(value).getTime();
if (Number.isFinite(ts) && ts > latestTs) latestTs = ts;
};
safeMessages.forEach((item) => {
pickLatest(item?.updated_at);
pickLatest(item?.created_at);
});
safeAttachments.forEach((item) => {
pickLatest(item?.updated_at);
pickLatest(item?.created_at);
});
return latestTs > 0 ? new Date(latestTs).toISOString() : "";
}, [safeAttachments, safeMessages]);
const typingHintText = useMemo(() => {
const rows = Array.isArray(typingPeers) ? typingPeers : [];
if (!rows.length) return "";
const labels = rows
.map((item) => String(item?.actor_label || item?.label || "").trim())
.filter(Boolean);
if (!labels.length) return "Собеседник печатает...";
const unique = [];
labels.forEach((label) => {
if (!unique.includes(label)) unique.push(label);
});
if (unique.length === 1) return unique[0] + " печатает...";
if (unique.length === 2) return unique[0] + " и " + unique[1] + " печатают...";
return unique[0] + ", " + unique[1] + " и еще " + String(unique.length - 2) + " печатают...";
}, [typingPeers]);
const openAttachmentFromMessage = (item) => {
if (!item?.download_url) return;
const kind = detectAttachmentPreviewKind(item.file_name, item.mime_type);
if (kind === "none") {
window.open(String(item.download_url), "_blank", "noopener,noreferrer");
return;
}
openPreview(item);
};
const downloadAttachment = (item) => {
const url = String(item?.download_url || "").trim();
if (!url) return;
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
link.rel = "noreferrer";
const fileName = String(item?.file_name || "").trim();
if (fileName) link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
};
useEffect(() => {
liveCursorRef.current = localActivityCursor || "";
}, [localActivityCursor, row?.id]);
useEffect(() => {
if (!row || typeof onLiveProbe !== "function") {
setTypingPeers([]);
setLiveMode("online");
if (liveTimerRef.current) {
clearTimeout(liveTimerRef.current);
liveTimerRef.current = null;
}
liveInFlightRef.current = false;
liveFailCountRef.current = 0;
return undefined;
}
let cancelled = false;
const scheduleNext = (ms) => {
if (cancelled) return;
if (liveTimerRef.current) clearTimeout(liveTimerRef.current);
liveTimerRef.current = setTimeout(runProbe, ms);
};
const runProbe = async () => {
if (cancelled || liveInFlightRef.current) return;
liveInFlightRef.current = true;
try {
const payload = await onLiveProbe({ cursor: liveCursorRef.current });
const cursor = String(payload?.cursor || "").trim();
if (cursor) liveCursorRef.current = cursor;
setTypingPeers(Array.isArray(payload?.typing) ? payload.typing : []);
liveFailCountRef.current = 0;
setLiveMode("online");
} catch (_) {
liveFailCountRef.current += 1;
setLiveMode(liveFailCountRef.current >= 3 ? "degraded" : "online");
} finally {
liveInFlightRef.current = false;
const hidden = typeof document !== "undefined" && document.visibilityState === "hidden";
const baseInterval = hidden ? 8000 : 2500;
const failStep = Math.min(5, Math.max(0, liveFailCountRef.current));
const backoffInterval = failStep > 0 ? Math.min(30000, baseInterval * Math.pow(2, failStep - 1)) : baseInterval;
scheduleNext(backoffInterval);
}
};
runProbe();
return () => {
cancelled = true;
if (liveTimerRef.current) {
clearTimeout(liveTimerRef.current);
liveTimerRef.current = null;
}
liveInFlightRef.current = false;
liveFailCountRef.current = 0;
setTypingPeers([]);
setLiveMode("online");
};
}, [onLiveProbe, row, trackNumber]);
const typingEnabled = Boolean(
row &&
typeof onTypingSignal === "function" &&
!loading &&
!fileUploading &&
composerFocused &&
String(messageDraft || "").trim()
);
useEffect(() => {
if (typeof onTypingSignal !== "function" || !row) {
if (typingHeartbeatRef.current) {
clearInterval(typingHeartbeatRef.current);
typingHeartbeatRef.current = null;
}
typingActiveRef.current = false;
return;
}
if (typingEnabled) {
if (!typingActiveRef.current) {
typingActiveRef.current = true;
void onTypingSignal({ typing: true }).catch(() => null);
}
if (!typingHeartbeatRef.current) {
typingHeartbeatRef.current = setInterval(() => {
void onTypingSignal({ typing: true }).catch(() => null);
}, 2500);
}
return;
}
if (typingHeartbeatRef.current) {
clearInterval(typingHeartbeatRef.current);
typingHeartbeatRef.current = null;
}
if (typingActiveRef.current) {
typingActiveRef.current = false;
void onTypingSignal({ typing: false }).catch(() => null);
}
}, [onTypingSignal, row, typingEnabled]);
useEffect(
() => () => {
if (typingHeartbeatRef.current) {
clearInterval(typingHeartbeatRef.current);
typingHeartbeatRef.current = null;
}
if (typingActiveRef.current && typeof onTypingSignal === "function") {
typingActiveRef.current = false;
void onTypingSignal({ typing: false }).catch(() => null);
}
},
[onTypingSignal]
);
const newDataRequestRow = (source) => {
const item = source || {};
const label = String(item.label || "").trim();
const key = String(item.key || "").trim();
const fieldTypeRaw = String(item.field_type || item.value_type || "string").trim().toLowerCase();
const fieldType = ["string", "text", "date", "number", "file"].includes(fieldTypeRaw) ? fieldTypeRaw : "string";
return {
localId: "row-" + Math.random().toString(36).slice(2),
id: item.id ? String(item.id) : "",
topic_template_id: item.topic_template_id ? String(item.topic_template_id) : item.id ? String(item.id) : "",
key,
label: label || "Поле",
field_type: fieldType,
document_name: String(item.document_name || "").trim(),
value_text: item.value_text == null ? "" : String(item.value_text),
value_file: item.value_file || null,
is_filled: Boolean(item.is_filled),
};
};
const getRequestDataRowIdentity = (item) => {
const rowItem = item || {};
const key = String(rowItem.key || "").trim().toLowerCase();
if (key) return "key:" + key;
const tplId = String(rowItem.topic_template_id || rowItem.id || "").trim();
if (tplId) return "tpl:" + tplId;
return "label:" + String(rowItem.label || "").trim().toLowerCase();
};
const mergeRequestDataRows = (baseRows, incomingRows) => {
const rows = Array.isArray(baseRows) ? [...baseRows] : [];
const nextItems = Array.isArray(incomingRows) ? incomingRows : [];
const seen = new Set(rows.map((rowItem) => getRequestDataRowIdentity(rowItem)));
nextItems.forEach((rowItem) => {
const identity = getRequestDataRowIdentity(rowItem);
if (!identity || seen.has(identity)) return;
seen.add(identity);
rows.push(rowItem);
});
return rows;
};
const openCreateDataRequestModal = async () => {
if (!canRequestData || typeof onLoadRequestDataTemplates !== "function") return;
setDataRequestModal((prev) => ({
...prev,
open: true,
loading: true,
saving: false,
savingTemplate: false,
messageId: "",
rows: [],
error: "",
templateStatus: "",
requestTemplateQuery: "",
catalogFieldQuery: "",
selectedCatalogTemplateId: "",
selectedRequestTemplateId: "",
templateName: "",
documentName: "",
customLabel: "",
customType: "string",
}));
try {
const data = await onLoadRequestDataTemplates();
setDataRequestModal((prev) => ({
...prev,
open: true,
loading: false,
templates: Array.isArray(data?.rows) ? data.rows : [],
templateList: Array.isArray(data?.templates) ? data.templates : [],
availableDocuments: Array.isArray(data?.documents) ? data.documents : [],
documentName: "",
requestTemplateQuery: "",
catalogFieldQuery: "",
}));
} catch (error) {
setDataRequestModal((prev) => ({ ...prev, loading: false, error: error.message || "Не удалось загрузить шаблоны" }));
}
};
const openEditDataRequestModal = async (messageId) => {
if (!canRequestData || !messageId) return;
setDataRequestModal((prev) => ({
...prev,
open: true,
loading: true,
saving: false,
savingTemplate: false,
messageId: String(messageId),
rows: [],
error: "",
templateStatus: "",
requestTemplateQuery: "",
catalogFieldQuery: "",
selectedCatalogTemplateId: "",
selectedRequestTemplateId: "",
templateName: "",
}));
try {
const [batch, templates] = await Promise.all([
typeof onLoadRequestDataBatch === "function" ? onLoadRequestDataBatch(messageId) : Promise.resolve({ items: [] }),
typeof onLoadRequestDataTemplates === "function"
? onLoadRequestDataTemplates()
: Promise.resolve({ rows: [], documents: [], templates: [] }),
]);
setDataRequestModal((prev) => ({
...prev,
open: true,
loading: false,
messageId: String(messageId),
rows: Array.isArray(batch?.items) ? batch.items.map(newDataRequestRow) : [],
documentName: String(batch?.document_name || ""),
templates: Array.isArray(templates?.rows) ? templates.rows : [],
templateList: Array.isArray(templates?.templates) ? templates.templates : [],
availableDocuments: Array.isArray(templates?.documents) ? templates.documents : [],
requestTemplateQuery: "",
catalogFieldQuery: "",
}));
} catch (error) {
setDataRequestModal((prev) => ({ ...prev, loading: false, error: error.message || "Не удалось загрузить запрос" }));
}
};
const closeDataRequestModal = () => {
setDataRequestModal((prev) => ({ ...prev, open: false, error: "", saving: false, savingTemplate: false, templateStatus: "" }));
};
const findRequestTemplateByQuery = (queryValue) => {
const query = String(queryValue || "").trim().toLowerCase();
if (!query) return null;
return (
(dataRequestModal.templateList || []).find((item) => {
const id = String(item?.id || "").toLowerCase();
const name = String(item?.name || "").toLowerCase();
return query === id || query === name;
}) || null
);
};
const findCatalogFieldByQuery = (queryValue) => {
const query = String(queryValue || "").trim().toLowerCase();
if (!query) return null;
return (
(dataRequestModal.templates || []).find((item) => {
const id = String(item?.id || "").toLowerCase();
const key = String(item?.key || "").toLowerCase();
const label = String(item?.label || "").toLowerCase();
return query === id || query === key || query === label;
}) || null
);
};
const applyRequestTemplateById = async (rawTemplateId, templateNameHint) => {
if (typeof onLoadRequestDataTemplateDetails !== "function") return;
const templateId = String(rawTemplateId || "").trim();
if (!templateId) return;
setDataRequestModal((prev) => ({ ...prev, loading: true, error: "" }));
try {
const data = await onLoadRequestDataTemplateDetails(templateId);
const incomingRows = (Array.isArray(data?.items) ? data.items : []).map((item) =>
newDataRequestRow({
...item,
topic_template_id: item.topic_data_template_id || item.topic_template_id || "",
field_type: item.value_type || item.field_type,
})
);
setDataRequestModal((prev) => ({
...prev,
loading: false,
rows: mergeRequestDataRows(prev.rows, incomingRows),
selectedRequestTemplateId: String(data?.template?.id || prev.selectedRequestTemplateId || ""),
requestTemplateQuery: String(data?.template?.name || templateNameHint || prev.requestTemplateQuery || ""),
templateStatus: "",
}));
} catch (error) {
setDataRequestModal((prev) => ({ ...prev, loading: false, error: error.message || "Не удалось загрузить шаблон" }));
}
};
const applySelectedRequestTemplate = async () => {
const selectedByQuery = findRequestTemplateByQuery(dataRequestModal.requestTemplateQuery);
const templateId = String(selectedByQuery?.id || dataRequestModal.selectedRequestTemplateId || "").trim();
return applyRequestTemplateById(templateId, selectedByQuery?.name || "");
};
const refreshDataRequestCatalog = async () => {
if (typeof onLoadRequestDataTemplates !== "function") return null;
const data = await onLoadRequestDataTemplates();
setDataRequestModal((prev) => ({
...prev,
templates: Array.isArray(data?.rows) ? data.rows : [],
templateList: Array.isArray(data?.templates) ? data.templates : [],
availableDocuments: Array.isArray(data?.documents) ? data.documents : [],
selectedRequestTemplateId: prev.selectedRequestTemplateId && (Array.isArray(data?.templates) ? data.templates : []).some((item) => String(item?.id) === String(prev.selectedRequestTemplateId))
? prev.selectedRequestTemplateId
: "",
}));
return data;
};
const saveCurrentDataRequestTemplate = async () => {
if (typeof onSaveRequestDataTemplate !== "function") return;
const selectedFromQuery = findRequestTemplateByQuery(dataRequestModal.requestTemplateQuery);
const templateName = String(dataRequestModal.requestTemplateQuery || "").trim();
const rows = (dataRequestModal.rows || []).filter((row) => String(row.label || "").trim());
if (!templateName) {
setDataRequestModal((prev) => ({ ...prev, error: "Укажите название шаблона" }));
return;
}
if (!rows.length) {
setDataRequestModal((prev) => ({ ...prev, error: "Добавьте хотя бы одно поле для шаблона" }));
return;
}
setDataRequestModal((prev) => ({ ...prev, savingTemplate: true, error: "", templateStatus: "" }));
try {
const result = await onSaveRequestDataTemplate({
template_id: String(selectedFromQuery?.id || dataRequestModal.selectedRequestTemplateId || "").trim() || undefined,
name: templateName,
items: rows.map((row) => ({
topic_data_template_id: row.topic_template_id || undefined,
key: row.key || undefined,
label: row.label,
value_type: row.field_type || "string",
})),
});
const savedRows = (Array.isArray(result?.items) ? result.items : []).map((item) =>
newDataRequestRow({
...item,
topic_template_id: item.topic_data_template_id || item.topic_template_id || "",
field_type: item.value_type || item.field_type,
})
);
setDataRequestModal((prev) => ({
...prev,
savingTemplate: false,
rows: savedRows.length ? savedRows : prev.rows,
selectedRequestTemplateId: String(result?.template?.id || prev.selectedRequestTemplateId || ""),
requestTemplateQuery: String(result?.template?.name || templateName),
templateStatus: "Шаблон сохранен",
}));
await refreshDataRequestCatalog();
} catch (error) {
setDataRequestModal((prev) => ({ ...prev, savingTemplate: false, error: error.message || "Не удалось сохранить шаблон" }));
}
};
const addSelectedTemplateRow = () => {
const selectedByQuery = findCatalogFieldByQuery(dataRequestModal.catalogFieldQuery);
const templateId = String(selectedByQuery?.id || dataRequestModal.selectedCatalogTemplateId || "").trim();
const template = (dataRequestModal.templates || []).find((item) => String(item.id) === templateId);
if (!template) {
const manualLabel = String(dataRequestModal.catalogFieldQuery || "").trim();
if (!manualLabel) return;
setDataRequestModal((prev) => ({
...prev,
catalogFieldQuery: "",
templateStatus: "",
rows: [...(prev.rows || []), newDataRequestRow({ label: manualLabel, field_type: "string" })],
}));
return;
}
setDataRequestModal((prev) => {
const exists = (prev.rows || []).some((row) => String(row.key || "") === String(template.key || ""));
if (exists) return { ...prev, selectedCatalogTemplateId: "", catalogFieldQuery: "" };
return {
...prev,
selectedCatalogTemplateId: "",
catalogFieldQuery: "",
templateStatus: "",
rows: [...(prev.rows || []), newDataRequestRow({ ...template, topic_template_id: template.id, field_type: template.value_type })],
};
});
};
const updateDataRequestRow = (localId, patch) => {
setDataRequestModal((prev) => ({
...prev,
templateStatus: "",
rows: (prev.rows || []).map((row) => (row.localId === localId ? { ...row, ...(patch || {}) } : row)),
}));
};
const removeDataRequestRow = (localId) => {
setDataRequestModal((prev) => ({
...prev,
templateStatus: "",
rows: (prev.rows || []).filter((row) => row.localId !== localId),
}));
};
const moveDataRequestRow = (localId, delta) => {
const shift = Number(delta) || 0;
if (!shift) return;
setDataRequestModal((prev) => {
const rows = Array.isArray(prev.rows) ? [...prev.rows] : [];
const index = rows.findIndex((row) => row.localId === localId);
if (index < 0) return prev;
const nextIndex = index + shift;
if (nextIndex < 0 || nextIndex >= rows.length) return prev;
const [item] = rows.splice(index, 1);
rows.splice(nextIndex, 0, item);
return { ...prev, templateStatus: "", rows };
});
};
const moveDataRequestRowToIndex = (localId, targetIndexRaw) => {
const targetIndex = Number(targetIndexRaw);
if (!Number.isInteger(targetIndex)) return;
setDataRequestModal((prev) => {
const rows = Array.isArray(prev.rows) ? [...prev.rows] : [];
const fromIndex = rows.findIndex((rowItem) => rowItem.localId === localId);
if (fromIndex < 0) return prev;
const boundedIndex = Math.max(0, Math.min(rows.length - 1, targetIndex));
if (fromIndex === boundedIndex) return prev;
const [item] = rows.splice(fromIndex, 1);
rows.splice(boundedIndex, 0, item);
return { ...prev, templateStatus: "", rows };
});
};
const submitDataRequestModal = async () => {
if (typeof onSaveRequestDataBatch !== "function") return;
const rows = (dataRequestModal.rows || []).filter((row) => String(row.label || "").trim());
if (!rows.length) {
setDataRequestModal((prev) => ({ ...prev, error: "Добавьте хотя бы одно поле" }));
return;
}
setDataRequestModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
await onSaveRequestDataBatch({
message_id: dataRequestModal.messageId || undefined,
items: rows.map((row) => ({
id: row.id || undefined,
topic_template_id: row.topic_template_id || undefined,
key: row.key || undefined,
label: row.label,
field_type: row.field_type || "string",
document_name: row.document_name || undefined,
})),
});
closeDataRequestModal();
} catch (error) {
setDataRequestModal((prev) => ({ ...prev, saving: false, error: error.message || "Не удалось отправить запрос" }));
}
};
const closeClientDataModal = () => {
setClientDataModal({
open: false,
loading: false,
saving: false,
messageId: "",
items: [],
status: "",
error: "",
});
};
const openClientDataRequestModal = async (messageId) => {
if (!canFillRequestData || typeof onLoadRequestDataBatch !== "function" || !messageId) return;
setClientDataModal({
open: true,
loading: true,
saving: false,
messageId: String(messageId),
items: [],
status: "",
error: "",
});
try {
const data = await onLoadRequestDataBatch(String(messageId));
const items = Array.isArray(data?.items)
? data.items
.slice()
.sort((a, b) => Number(a?.sort_order || 0) - Number(b?.sort_order || 0))
.map((item, index) => ({
localId: "client-data-" + String(item?.id || item?.key || index),
id: String(item?.id || ""),
key: String(item?.key || ""),
label: String(item?.label || item?.key || "Поле"),
field_type: String(item?.field_type || "string").toLowerCase(),
value_text: item?.value_text == null ? "" : String(item.value_text),
value_file: item?.value_file || null,
pendingFile: null,
}))
: [];
setClientDataModal((prev) => ({ ...prev, loading: false, items }));
} catch (error) {
setClientDataModal((prev) => ({ ...prev, loading: false, error: error?.message || "Не удалось открыть запрос данных" }));
}
};
const updateClientDataItem = (localId, patch) => {
setClientDataModal((prev) => ({
...prev,
status: "",
error: "",
items: (prev.items || []).map((item) => (item.localId === localId ? { ...item, ...(patch || {}) } : item)),
}));
};
const submitClientDataModal = async (event) => {
if (event && typeof event.preventDefault === "function") event.preventDefault();
if (!canFillRequestData || typeof onSaveRequestDataValues !== "function") return;
const currentMessageId = String(clientDataModal.messageId || "").trim();
if (!currentMessageId) return;
setClientDataModal((prev) => ({ ...prev, saving: true, status: "", error: "" }));
try {
const payloadItems = [];
for (const item of clientDataModal.items || []) {
const fieldType = String(item?.field_type || "string").toLowerCase();
if (fieldType === "file") {
let attachmentId = String(item?.value_text || "").trim();
if (item?.pendingFile) {
if (typeof onUploadRequestAttachment !== "function") {
throw new Error("Загрузка файла для поля недоступна");
}
const uploadResult = await onUploadRequestAttachment(item.pendingFile, {
source: "data_request",
message_id: currentMessageId,
key: String(item?.key || ""),
});
attachmentId = String(
(uploadResult && (uploadResult.attachment_id || uploadResult.id || uploadResult.value || uploadResult)) || ""
).trim();
if (!attachmentId) throw new Error("Не удалось сохранить файл для поля запроса");
}
payloadItems.push({
id: String(item?.id || ""),
key: String(item?.key || ""),
attachment_id: attachmentId || "",
value_text: attachmentId || "",
});
continue;
}
payloadItems.push({
id: String(item?.id || ""),
key: String(item?.key || ""),
value_text: String(item?.value_text || ""),
});
}
await onSaveRequestDataValues({
message_id: currentMessageId,
items: payloadItems,
});
closeClientDataModal();
} catch (error) {
setClientDataModal((prev) => ({
...prev,
saving: false,
error: error?.message || "Не удалось сохранить данные",
}));
}
};
const handleRequestRowDragStart = (event, rowItem, rowLocked) => {
if (rowLocked || dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate) {
event.preventDefault();
return;
}
setDraggedRequestRowId(String(rowItem.localId || ""));
setDragOverRequestRowId(String(rowItem.localId || ""));
try {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(rowItem.localId || ""));
} catch (_error) {
// noop for browsers that restrict custom drag payloads
}
};
const handleRequestRowDragEnd = () => {
setDraggedRequestRowId("");
setDragOverRequestRowId("");
};
const appendStatusChangeFiles = (files) => {
const list = Array.isArray(files) ? files.filter(Boolean) : [];
if (!list.length) return;
setStatusChangeModal((prev) => {
const existing = Array.isArray(prev.files) ? prev.files : [];
const next = [...existing];
list.forEach((file) => {
const duplicate = next.some(
(item) =>
item &&
item.name === file.name &&
Number(item.size || 0) === Number(file.size || 0) &&
Number(item.lastModified || 0) === Number(file.lastModified || 0)
);
if (!duplicate) next.push(file);
});
return { ...prev, files: next };
});
};
const removeStatusChangeFile = (index) => {
setStatusChangeModal((prev) => {
const files = Array.isArray(prev.files) ? [...prev.files] : [];
files.splice(index, 1);
return { ...prev, files };
});
};
const submitStatusChange = async (event) => {
if (event && typeof event.preventDefault === "function") event.preventDefault();
if (!row?.id || typeof onChangeStatus !== "function") return;
const nextStatus = String(statusChangeModal.statusCode || "").trim();
if (!nextStatus) {
setStatusChangeModal((prev) => ({ ...prev, error: "Выберите новый статус" }));
return;
}
if (nextStatus === String(row?.status_code || "").trim()) {
setStatusChangeModal((prev) => ({ ...prev, error: "Выберите статус, отличный от текущего" }));
return;
}
setStatusChangeModal((prev) => ({ ...prev, saving: true, error: "" }));
try {
const localValue = String(statusChangeModal.importantDateAt || "").trim();
const importantDateIso = localValue ? new Date(localValue).toISOString() : "";
await onChangeStatus({
requestId: String(row.id),
statusCode: nextStatus,
importantDateAt: importantDateIso || null,
comment: statusChangeModal.comment || "",
files: statusChangeModal.files || [],
});
closeStatusChangeModal();
} catch (error) {
setStatusChangeModal((prev) => ({ ...prev, saving: false, error: error.message || "Не удалось сменить статус" }));
}
};
const chatTimelineItems = [];
let previousDate = "";
const timelineSource = [];
safeMessages.forEach((item) => {
timelineSource.push({
type: "message",
key: "msg-" + String(item?.id || Math.random()),
created_at: item?.created_at || null,
payload: item,
});
});
safeAttachments
.filter((item) => !String(item?.message_id || "").trim())
.forEach((item) => {
timelineSource.push({
type: "file",
key: "file-" + String(item?.id || Math.random()),
created_at: item?.created_at || null,
payload: item,
});
});
timelineSource.sort((a, b) => {
const aTime = new Date(a.created_at || 0).getTime();
const bTime = new Date(b.created_at || 0).getTime();
if (!Number.isFinite(aTime) && !Number.isFinite(bTime)) return 0;
if (!Number.isFinite(aTime)) return 1;
if (!Number.isFinite(bTime)) return -1;
if (aTime !== bTime) return aTime - bTime;
return String(a.key).localeCompare(String(b.key), "ru");
});
timelineSource.forEach((entry, index) => {
const dateLabel = fmtDateOnly(entry.created_at);
const normalizedDate = dateLabel && dateLabel !== "-" ? dateLabel : "Без даты";
if (normalizedDate !== previousDate) {
chatTimelineItems.push({ type: "date", key: "date-" + normalizedDate + "-" + index, label: normalizedDate });
previousDate = normalizedDate;
}
chatTimelineItems.push(entry);
});
useEffect(() => {
if (chatTab !== "chat") return;
const listNode = chatListRef.current;
if (!listNode) return;
const cursor = String(localActivityCursor || "");
if (!cursor || cursor === lastAutoScrollCursorRef.current) return;
lastAutoScrollCursorRef.current = cursor;
const raf = window.requestAnimationFrame(() => {
if (!chatListRef.current) return;
chatListRef.current.scrollTop = chatListRef.current.scrollHeight;
});
return () => window.cancelAnimationFrame(raf);
}, [chatTab, localActivityCursor]);
const routeNodes =
Array.isArray(statusRouteNodes) && statusRouteNodes.length
? statusRouteNodes
: row?.status_code
? [{ code: row.status_code, name: statusLabel(row.status_code), state: "current", note: "Текущий этап обработки заявки" }]
: [];
const AttachmentPreviewModal = AttachmentPreviewModalComponent;
const StatusLine = StatusLineComponent;
const renderRequestDataMessageItems = (payload) => {
const items = Array.isArray(payload?.request_data_items) ? payload.request_data_items : [];
const allFilled = Boolean(payload?.request_data_all_filled);
if (!items.length) return <p className="chat-message-text">Запрос</p>;
if (allFilled) {
const fileOnly = items.length === 1 && String(items[0]?.field_type || "").toLowerCase() === "file";
return <p className="chat-message-text chat-request-data-collapsed">{fileOnly ? "Файл" : "Заполнен"}</p>;
}
const visibleItems = items.slice(0, 7);
const hiddenCount = Math.max(0, items.length - visibleItems.length);
return (
<div className="chat-request-data-list">
{visibleItems.map((item, idx) => (
<div className={"chat-request-data-item" + (item?.is_filled ? " filled" : "")} key={String(item?.id || idx)}>
<span className="chat-request-data-index">
{item?.is_filled ? <span className="chat-request-data-check"></span> : null}
{String(item?.index || idx + 1) + "."}
</span>
<span className="chat-request-data-label">{String(item?.label_short || item?.label || "Поле")}</span>
</div>
))}
{hiddenCount > 0 ? <div className="chat-request-data-more">... еще {hiddenCount}</div> : null}
</div>
);
};
const formatRequestDataValue = (item) => {
const type = String(item?.field_type || "string").toLowerCase();
if (type === "date") {
const text = String(item?.value_text || "").trim();
return text ? fmtDateOnly(text) : "Не заполнено";
}
if (type === "file") {
const attachmentId = String(item?.value_text || "").trim();
const linkedAttachment = attachmentId ? attachmentById.get(attachmentId) : null;
const fileMeta = item?.value_file || (linkedAttachment ? {
attachment_id: linkedAttachment.id,
file_name: linkedAttachment.file_name,
mime_type: linkedAttachment.mime_type,
size_bytes: linkedAttachment.size_bytes,
download_url: linkedAttachment.download_url,
} : null);
return fileMeta || null;
}
const text = String(item?.value_text || "").trim();
return text || "Не заполнено";
};
const dataRequestProgress = useMemo(() => {
const rows = Array.isArray(dataRequestModal.rows) ? dataRequestModal.rows : [];
const total = rows.length;
const filled = rows.filter((rowItem) => Boolean(rowItem?.is_filled || String(rowItem?.value_text || "").trim())).length;
return { total, filled };
}, [dataRequestModal.rows]);
return (
<div className="block">
<div className="request-workspace-layout">
<div className="request-main-column">
<div className="block">
<div className="request-card-head">
<h3>Карточка</h3>
<div className="request-card-head-actions">
{canRequestData ? (
<button
type="button"
className="icon-btn request-card-status-btn"
data-tooltip="Сменить статус"
aria-label="Сменить статус"
onClick={() => openStatusChangeModal()}
disabled={loading || !row}
>
</button>
) : null}
<button
type="button"
className="icon-btn request-card-data-btn"
data-tooltip="Данные заявки"
aria-label="Данные заявки"
onClick={() => setRequestDataListOpen(true)}
disabled={loading || !row}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path d="M4 5h16v2H4V5Zm0 6h16v2H4v-2Zm0 6h10v2H4v-2Z" fill="currentColor" />
</svg>
</button>
<button
type="button"
className="icon-btn request-card-finance-btn"
data-tooltip="Финансы заявки"
aria-label="Финансы заявки"
onClick={() => setFinanceOpen(true)}
disabled={loading || !row}
>
$
</button>
</div>
</div>
{loading ? (
<p className="muted">Загрузка...</p>
) : row ? (
<>
<div className="request-card-grid request-card-grid-compact">
{showTopicStatusInCard ? (
<>
<div className="request-field">
<span className="request-field-label">Тема</span>
<span className="request-field-value">{String(row.topic_name || row.topic_code || "-")}</span>
</div>
<div className="request-field">
<span className="request-field-label">Статус</span>
<span className="request-field-value">{statusLabel(row.status_code)}</span>
</div>
</>
) : null}
<div className="request-field request-field-span-2 request-field-description">
<div className="request-field-head">
<span className="request-field-label">Описание проблемы</span>
<button
type="button"
className="icon-btn request-field-expand-btn"
data-tooltip="Развернуть описание"
aria-label="Развернуть описание"
onClick={() => setDescriptionOpen(true)}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path
d="M4 9V4h5v2H6v3H4zm10-5h6v6h-2V6h-4V4zM4 15h2v3h3v2H4v-5zm14 3v-3h2v5h-5v-2h3z"
fill="currentColor"
/>
</svg>
</button>
</div>
<span className="request-field-value">
{row.description ? String(row.description) : "Описание не заполнено"}
</span>
</div>
<div className="request-field">
<span className="request-field-label">Клиент</span>
<span
className={"request-field-value" + (clientHasPhone ? " has-tooltip request-contact-value" : "")}
data-tooltip={clientHasPhone ? clientPhone : undefined}
>
{clientLabel}
</span>
</div>
<div className="request-field">
<span className="request-field-label">Юрист</span>
<span
className={"request-field-value" + (lawyerHasPhone ? " has-tooltip request-contact-value" : "")}
data-tooltip={lawyerHasPhone ? lawyerPhone : undefined}
>
{lawyerLabel}
</span>
</div>
{canSeeCreatedUpdatedInCard ? (
<>
<div className="request-field">
<span className="request-field-label">Создана</span>
<span className="request-field-value">{fmtShortDateTime(row.created_at)}</span>
</div>
<div className="request-field">
<span className="request-field-label">Изменена</span>
<span className="request-field-value">{fmtShortDateTime(row.updated_at)}</span>
</div>
</>
) : null}
</div>
<div className="request-status-route">
<h4>Маршрут статусов</h4>
{routeNodes.length ? (
<ol className="request-route-list" id="request-status-route">
{routeNodes.map((node, index) => {
const state = String(node?.state || "pending");
const name = String(node?.name || statusLabel(node?.code));
const note = String(node?.note || "").trim();
const changedAtSource = String(node?.changed_at || "").trim() || (index === 0 ? String(row?.created_at || "").trim() : "");
const changedAt = changedAtSource ? fmtDate(changedAtSource) : "";
const className = "route-item " + (state === "current" ? "current" : state === "completed" ? "completed" : "pending");
return (
<li className={className} key={(node?.code || "node") + "-" + index}>
<span className="route-dot" />
<div className="route-body">
<b>{name}</b>
{note ? <p>{note}</p> : null}
<div className="muted route-time">Дата статуса: {changedAt || "-"}</div>
</div>
</li>
);
})}
</ol>
) : (
<p className="muted">Маршрут статусов для темы не настроен</p>
)}
</div>
</>
) : (
<p className="muted">Нет данных по заявке</p>
)}
</div>
</div>
<div className="block request-chat-block">
<div className="request-chat-head">
<h3>Коммуникация</h3>
<div className="request-chat-tabs" role="tablist" aria-label="Коммуникация">
<button
type="button"
role="tab"
aria-selected={chatTab === "chat"}
className={"tab-btn" + (chatTab === "chat" ? " active" : "")}
onClick={() => setChatTab("chat")}
>
Чат
</button>
<button
type="button"
role="tab"
aria-selected={chatTab === "files"}
className={"tab-btn" + (chatTab === "files" ? " active" : "")}
onClick={() => setChatTab("files")}
>
{"Файлы" + (safeAttachments.length ? " (" + safeAttachments.length + ")" : "")}
</button>
</div>
</div>
<div className="request-chat-live-row" aria-live="polite">
<span className={"chat-live-dot" + (liveMode === "degraded" ? " degraded" : "")} />
<span className="request-chat-live-text">{typingHintText || (liveMode === "degraded" ? "Связь нестабильна, включен backoff" : "Онлайн")}</span>
</div>
<input
id={idMap.fileInput}
ref={fileInputRef}
type="file"
multiple
onChange={onInputFiles}
disabled={loading || fileUploading}
style={{ position: "absolute", width: "1px", height: "1px", opacity: 0, pointerEvents: "none" }}
/>
{chatTab === "chat" ? (
<>
<ul className="simple-list request-modal-list request-chat-list" id={idMap.messagesList} ref={chatListRef}>
{chatTimelineItems.length ? (
chatTimelineItems.map((entry) =>
entry.type === "date" ? (
<li key={entry.key} className="chat-date-divider">
<span>{entry.label}</span>
</li>
) : entry.type === "file" ? (
<li
key={entry.key}
className={
"chat-message " +
(String(entry.payload?.responsible || "").toUpperCase().includes("КЛИЕНТ") ? "incoming" : "outgoing")
}
>
<div className="chat-message-author">{String(entry.payload?.responsible || "Система")}</div>
<div className="chat-message-bubble">
<div className="chat-message-files">
<button
type="button"
className="chat-message-file-chip"
onClick={() => openAttachmentFromMessage(entry.payload)}
title={String(entry.payload?.file_name || "Файл")}
>
<span className="chat-message-file-icon" aria-hidden="true">
📎
</span>
<span className="chat-message-file-name">{String(entry.payload?.file_name || "Файл")}</span>
</button>
</div>
<div className="chat-message-time">{fmtTimeOnly(entry.payload?.created_at)}</div>
</div>
</li>
) : (
(() => {
const messageKind = String(entry.payload?.message_kind || "");
const isRequestDataMessage = messageKind === "REQUEST_DATA";
const requestDataInteractive = isRequestDataMessage && (canRequestData || canFillRequestData);
const bubbleClass =
"chat-message-bubble" +
(isRequestDataMessage ? " chat-request-data-bubble" : "") +
(entry.payload?.request_data_all_filled ? " all-filled" : "") +
(isRequestDataMessage && canFillRequestData ? " request-data-message-btn" : "");
const itemClass =
"chat-message " +
(String(entry.payload?.author_type || "").toUpperCase() === "CLIENT" ? "incoming" : "outgoing") +
(isRequestDataMessage && canFillRequestData ? " request-data-item" + (entry.payload?.request_data_all_filled ? " done" : "") : "");
return (
<li key={entry.key} className={itemClass}>
<div className="chat-message-author">{String(entry.payload?.author_name || entry.payload?.author_type || "Система")}</div>
<div
className={bubbleClass}
onClick={
requestDataInteractive
? () =>
canRequestData
? openEditDataRequestModal(String(entry.payload?.id || ""))
: openClientDataRequestModal(String(entry.payload?.id || ""))
: undefined
}
role={requestDataInteractive ? "button" : undefined}
tabIndex={requestDataInteractive ? 0 : undefined}
onKeyDown={
requestDataInteractive
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (canRequestData) openEditDataRequestModal(String(entry.payload?.id || ""));
else openClientDataRequestModal(String(entry.payload?.id || ""));
}
}
: undefined
}
>
{String(entry.payload?.message_kind || "") === "REQUEST_DATA" ? (
<>
<div className="chat-request-data-head">Запрос</div>
{renderRequestDataMessageItems(entry.payload)}
</>
) : (
<p className="chat-message-text">{String(entry.payload?.body || "")}</p>
)}
{(() => {
if (String(entry.payload?.message_kind || "") === "REQUEST_DATA") return null;
const messageId = String(entry.payload?.id || "").trim();
if (!messageId) return null;
const messageFiles = attachmentsByMessageId.get(messageId) || [];
if (!messageFiles.length) return null;
return (
<div className="chat-message-files">
{messageFiles.map((file) => (
<button
type="button"
key={String(file.id)}
className="chat-message-file-chip"
onClick={() => openAttachmentFromMessage(file)}
title={String(file.file_name || "Файл")}
>
<span className="chat-message-file-icon" aria-hidden="true">
📎
</span>
<span className="chat-message-file-name">{String(file.file_name || "Файл")}</span>
</button>
))}
</div>
);
})()}
<div className="chat-message-time">{fmtTimeOnly(entry.payload?.created_at)}</div>
</div>
</li>
);
})()
)
)
) : (
<li className="muted">Сообщений нет</li>
)}
</ul>
<form className="stack" onSubmit={onSendMessage}>
<div
className={"field request-chat-composer-dropzone" + (dropActive ? " drag-active" : "")}
onDragOver={(event) => {
event.preventDefault();
setDropActive(true);
}}
onDragLeave={(event) => {
if (event.currentTarget.contains(event.relatedTarget)) return;
setDropActive(false);
}}
onDrop={onDropFiles}
>
<label htmlFor={idMap.messageBody}>Новое сообщение</label>
<textarea
id={idMap.messageBody}
placeholder={messagePlaceholder}
value={messageDraft}
onChange={onMessageChange}
onFocus={() => setComposerFocused(true)}
onBlur={() => setComposerFocused(false)}
disabled={loading || fileUploading}
/>
<div className="request-drop-hint muted">Перетащите файлы сюда или прикрепите скрепкой</div>
</div>
{hasPendingFiles ? (
<div className="request-pending-files">
{pendingFiles.map((file, index) => (
<div className="pending-file-chip" key={(file.name || "file") + "-" + String(file.lastModified || index)}>
<span className="pending-file-icon" aria-hidden="true">
📎
</span>
<span className="pending-file-name">{file.name}</span>
<button
type="button"
className="pending-file-remove"
aria-label={"Удалить файл " + file.name}
onClick={() => onRemoveSelectedFile(index)}
>
×
</button>
</div>
))}
<button type="button" className="btn secondary btn-sm" onClick={onClearSelectedFiles}>
Очистить вложения
</button>
</div>
) : null}
<div className="request-chat-composer-actions">
{canRequestData ? (
<button
className="btn secondary btn-sm"
type="button"
onClick={openCreateDataRequestModal}
disabled={loading || fileUploading}
>
Запросить
</button>
) : null}
<button
className="icon-btn file-action-btn composer-attach-btn"
type="button"
data-tooltip="Прикрепить файл"
aria-label="Прикрепить файл"
onClick={() => fileInputRef.current?.click()}
disabled={loading || fileUploading}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path
d="M8.6 13.8 15 7.4a3 3 0 0 1 4.2 4.2l-8.1 8.1a5 5 0 1 1-7.1-7.1l8.6-8.6a1 1 0 0 1 1.4 1.4l-8.6 8.6a3 3 0 1 0 4.2 4.2l8.1-8.1a1 1 0 0 0-1.4-1.4l-6.4 6.4a1 1 0 0 1-1.4-1.4z"
fill="currentColor"
/>
</svg>
</button>
<button
className="btn"
id={idMap.sendButton}
type="submit"
disabled={loading || fileUploading || !canSubmit}
>
Отправить
</button>
</div>
</form>
</>
) : (
<div className="request-files-tab">
<ul className="simple-list request-modal-list" id={idMap.filesList}>
{safeAttachments.length ? (
safeAttachments.map((item) => (
<li key={String(item.id)}>
<div>{item.file_name || "Файл"}</div>
<div className="muted request-modal-item-meta">
{String(item.mime_type || "application/octet-stream") + " • " + fmtBytes(item.size_bytes) + " • " + fmtDate(item.created_at)}
</div>
<div className="request-file-actions">
{item.download_url && detectAttachmentPreviewKind(item.file_name, item.mime_type) !== "none" ? (
<button
className="icon-btn file-action-btn"
type="button"
data-tooltip="Предпросмотр"
onClick={() => openPreview(item)}
aria-label="Предпросмотр"
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path
d="M12 5C6.8 5 3 9.2 2 12c1 2.8 4.8 7 10 7s9-4.2 10-7c-1-2.8-4.8-7-10-7zm0 11a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2.2A1.8 1.8 0 1 0 12 10a1.8 1.8 0 0 0 0 3.8z"
fill="currentColor"
/>
</svg>
</button>
) : null}
{item.download_url ? (
<a
className="icon-btn file-action-btn request-file-link-icon"
data-tooltip="Скачать"
aria-label={"Скачать: " + String(item.file_name || "файл")}
href={item.download_url}
target="_blank"
rel="noreferrer"
>
<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>
) : null}
</div>
</li>
))
) : (
<li className="muted">Файлов пока нет</li>
)}
</ul>
<div className="request-files-tab-actions">
<span className="muted">
{"Сообщений: " + String(safeMessages.length) + " • Общий размер файлов: " + fmtBytes(totalFilesBytes)}
</span>
</div>
</div>
)}
</div>
</div>
{StatusLine ? <StatusLine status={status} /> : null}
{AttachmentPreviewModal ? (
<AttachmentPreviewModal
open={preview.open}
title="Предпросмотр файла"
url={preview.url}
fileName={preview.fileName}
mimeType={preview.mimeType}
onClose={closePreview}
/>
) : null}
<div
className={"overlay" + (clientDataModal.open ? " open" : "")}
onClick={closeClientDataModal}
aria-hidden={clientDataModal.open ? "false" : "true"}
id={idMap.dataRequestOverlay}
>
<div className="modal request-data-summary-modal data-request-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<div>
<h3>Запрос данных</h3>
<p className="muted request-finance-subtitle">
{row?.track_number ? "Заявка " + String(row.track_number) : "Заполните данные по запросу юриста"}
</p>
</div>
<button className="close" type="button" onClick={closeClientDataModal} aria-label="Закрыть">
×
</button>
</div>
<form className="stack" onSubmit={submitClientDataModal}>
<div className="request-data-summary-list" id={idMap.dataRequestItems}>
{clientDataModal.loading ? (
<p className="muted">Загрузка...</p>
) : (clientDataModal.items || []).length ? (
(clientDataModal.items || []).map((item, index) => {
const fieldType = String(item?.field_type || "string").toLowerCase();
const fileMeta = item?.value_file;
return (
<div className="request-data-summary-row" key={String(item.localId || index)}>
<div className="request-data-summary-label">
{String(index + 1) + ". " + String(item?.label || item?.key || "Поле")}
</div>
<div className="request-data-summary-value">
{fieldType === "text" ? (
<textarea
value={String(item?.value_text || "")}
onChange={(event) =>
updateClientDataItem(item.localId, { value_text: event.target.value })
}
rows={3}
disabled={clientDataModal.saving || clientDataModal.loading}
/>
) : fieldType === "date" ? (
<input
type="date"
value={String(item?.value_text || "").slice(0, 10)}
onChange={(event) =>
updateClientDataItem(item.localId, { value_text: event.target.value })
}
disabled={clientDataModal.saving || clientDataModal.loading}
/>
) : fieldType === "number" ? (
<input
type="number"
step="any"
value={String(item?.value_text || "")}
onChange={(event) =>
updateClientDataItem(item.localId, { value_text: event.target.value })
}
disabled={clientDataModal.saving || clientDataModal.loading}
/>
) : fieldType === "file" ? (
<div className="stack">
{fileMeta && fileMeta.download_url ? (
<button
type="button"
className="chat-message-file-chip"
onClick={() => openAttachmentFromMessage(fileMeta)}
>
<span className="chat-message-file-icon" aria-hidden="true">📎</span>
<span className="chat-message-file-name">{String(fileMeta.file_name || "Файл")}</span>
</button>
) : null}
<input
type="file"
onChange={(event) =>
updateClientDataItem(item.localId, {
pendingFile: event.target.files && event.target.files[0] ? event.target.files[0] : null,
})
}
disabled={clientDataModal.saving || clientDataModal.loading}
/>
{item?.pendingFile ? (
<span className="muted">{String(item.pendingFile.name || "")}</span>
) : null}
</div>
) : (
<input
type="text"
value={String(item?.value_text || "")}
onChange={(event) =>
updateClientDataItem(item.localId, { value_text: event.target.value })
}
disabled={clientDataModal.saving || clientDataModal.loading}
/>
)}
</div>
</div>
);
})
) : (
<p className="muted">Нет полей для заполнения.</p>
)}
</div>
{clientDataModal.error ? <div className="status error">{clientDataModal.error}</div> : null}
<div className={"request-data-status" + (clientDataModal.status ? " ok" : "")} id={idMap.dataRequestStatus}>
{clientDataModal.status || ""}
</div>
<div className="modal-actions modal-actions-right">
<button
type="submit"
className="btn btn-sm request-data-submit-btn"
id={idMap.dataRequestSave}
disabled={clientDataModal.loading || clientDataModal.saving}
>
{clientDataModal.saving ? "Сохранение..." : "Сохранить"}
</button>
</div>
</form>
</div>
</div>
<div
className={"overlay" + (statusChangeModal.open ? " open" : "")}
onClick={closeStatusChangeModal}
aria-hidden={statusChangeModal.open ? "false" : "true"}
>
<div className="modal request-status-change-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<div>
<h3>Смена статуса</h3>
<p className="muted request-finance-subtitle">
{row?.track_number ? "Заявка " + String(row.track_number) : "Выберите статус и важную дату"}
</p>
</div>
<button className="close" type="button" onClick={closeStatusChangeModal} aria-label="Закрыть">
×
</button>
</div>
<input
ref={statusChangeFileInputRef}
type="file"
multiple
onChange={(event) => {
appendStatusChangeFiles(Array.from((event.target && event.target.files) || []));
event.target.value = "";
}}
style={{ position: "absolute", width: "1px", height: "1px", opacity: 0, pointerEvents: "none" }}
/>
<form className="stack" onSubmit={submitStatusChange}>
<div className="request-status-change-grid">
<div className="field">
<label htmlFor="status-change-next-status">Новый статус</label>
<select
id="status-change-next-status"
value={statusChangeModal.statusCode}
onChange={(event) => setStatusChangeModal((prev) => ({ ...prev, statusCode: event.target.value, error: "" }))}
disabled={statusChangeModal.saving || loading}
>
<option value="">Выберите статус</option>
{statusOptions
.filter((item) => item.code !== String(row?.status_code || "").trim())
.filter((item) =>
Array.isArray(statusChangeModal.allowedStatusCodes) && statusChangeModal.allowedStatusCodes.length
? statusChangeModal.allowedStatusCodes.includes(item.code)
: true
)
.map((item) => (
<option key={item.code} value={item.code}>
{item.name + " (" + item.code + ")" + (item.groupName ? " • " + item.groupName : "")}
</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="status-change-important-date">Важная дата (дедлайн)</label>
<input
id="status-change-important-date"
type="datetime-local"
value={statusChangeModal.importantDateAt}
onChange={(event) => setStatusChangeModal((prev) => ({ ...prev, importantDateAt: event.target.value, error: "" }))}
disabled={statusChangeModal.saving || loading}
/>
</div>
</div>
<div className="field">
<label htmlFor="status-change-comment">Комментарий к смене статуса</label>
<textarea
id="status-change-comment"
placeholder="Комментарий будет добавлен в историю и чат (если указан)"
value={statusChangeModal.comment}
onChange={(event) => setStatusChangeModal((prev) => ({ ...prev, comment: event.target.value }))}
disabled={statusChangeModal.saving || loading}
/>
</div>
<div className="request-status-change-files">
<div className="request-status-change-files-head">
<b>Вложения</b>
<button
type="button"
className="icon-btn file-action-btn"
data-tooltip="Прикрепить файлы"
onClick={() => statusChangeFileInputRef.current?.click()}
disabled={statusChangeModal.saving || loading}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path
d="M8.6 13.8 15 7.4a3 3 0 0 1 4.2 4.2l-8.1 8.1a5 5 0 1 1-7.1-7.1l8.6-8.6a1 1 0 0 1 1.4 1.4l-8.6 8.6a3 3 0 1 0 4.2 4.2l8.1-8.1a1 1 0 0 0-1.4-1.4l-6.4 6.4a1 1 0 0 1-1.4-1.4z"
fill="currentColor"
/>
</svg>
</button>
</div>
{Array.isArray(statusChangeModal.files) && statusChangeModal.files.length ? (
<div className="request-pending-files">
{statusChangeModal.files.map((file, index) => (
<div className="pending-file-chip" key={(file.name || "file") + "-" + String(file.lastModified || index)}>
<span className="pending-file-icon" aria-hidden="true">📎</span>
<span className="pending-file-name">{file.name}</span>
<button
type="button"
className="pending-file-remove"
aria-label={"Удалить файл " + file.name}
onClick={() => removeStatusChangeFile(index)}
>
×
</button>
</div>
))}
</div>
) : (
<p className="muted">Файлы не добавлены</p>
)}
</div>
<div className="request-status-history-block">
<div className="request-status-history-head">
<b>История статусов</b>
<span className="muted">{safeStatusHistory.length ? String(safeStatusHistory.length) + " записей" : "Нет записей"}</span>
</div>
<ol className="request-route-list request-status-history-list">
{safeStatusHistory.length ? (
safeStatusHistory.map((item, index) => {
const statusCode = String(item?.to_status || "");
const statusMeta = statusByCode.get(statusCode);
const itemClass =
"route-item request-status-history-route-item " + (index === 0 ? "current" : "completed");
return (
<li key={String(item?.id || index)} className={itemClass}>
<span className="route-dot" />
<div className="route-body">
<div className="request-status-history-row">
<b>{String(item?.to_status_name || statusMeta?.name || statusLabel(statusCode) || statusCode || "-")}</b>
{statusMeta?.isTerminal ? <span className="request-status-history-chip">Терминальный</span> : null}
</div>
<div className="muted route-time">{fmtShortDateTime(item?.changed_at)}</div>
<div className="request-status-history-meta">
<span>{"Важная дата: " + fmtShortDateTime(item?.important_date_at)}</span>
<span>{"Длительность: " + formatDuration(item?.duration_seconds)}</span>
</div>
{item?.from_status ? (
<div className="request-status-history-meta">
<span>{"Из: " + statusLabel(item.from_status)}</span>
</div>
) : null}
{String(item?.comment || "").trim() ? (
<div className="request-status-history-comment">{String(item.comment)}</div>
) : null}
</div>
</li>
);
})
) : (
<li className="muted">История изменений статусов пока пустая</li>
)}
</ol>
</div>
{statusChangeModal.error ? <div className="status error">{statusChangeModal.error}</div> : null}
<div className="modal-actions modal-actions-right">
<button
type="submit"
className="btn btn-sm request-data-submit-btn"
disabled={statusChangeModal.saving || loading}
>
{statusChangeModal.saving ? "Сохранение..." : "Отправить"}
</button>
</div>
</form>
</div>
</div>
<div
className={"overlay" + (financeOpen ? " open" : "")}
onClick={() => setFinanceOpen(false)}
aria-hidden={financeOpen ? "false" : "true"}
>
<div className="modal request-finance-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<div>
<h3>Финансы заявки</h3>
<p className="muted request-finance-subtitle">
{row?.track_number ? "Заявка " + String(row.track_number) : "Данные по заявке"}
</p>
</div>
<button className="close" type="button" onClick={() => setFinanceOpen(false)} aria-label="Закрыть">
×
</button>
</div>
<div className="request-card-grid request-finance-grid">
<div className="request-field">
<span className="request-field-label">Стоимость</span>
<span className="request-field-value">{fmtAmount(finance?.request_cost ?? row?.request_cost)}</span>
</div>
<div className="request-field">
<span className="request-field-label">Оплачено</span>
<span className="request-field-value">{fmtAmount(finance?.paid_total)}</span>
</div>
<div className="request-field">
<span className="request-field-label">Дата оплаты</span>
<span className="request-field-value">{fmtShortDateTime(finance?.last_paid_at ?? row?.paid_at)}</span>
</div>
{canSeeRate ? (
<div className="request-field">
<span className="request-field-label">Ставка</span>
<span className="request-field-value">{fmtAmount(finance?.effective_rate ?? row?.effective_rate)}</span>
</div>
) : null}
</div>
</div>
</div>
<div
className={"overlay" + (descriptionOpen ? " open" : "")}
onClick={() => setDescriptionOpen(false)}
aria-hidden={descriptionOpen ? "false" : "true"}
>
<div className="modal request-description-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<div>
<h3>{row?.track_number ? "Заявка " + String(row.track_number) : "Заявка"}</h3>
<div className="request-description-modal-headline">
<p className="muted request-finance-subtitle">
{String(row?.topic_name || row?.topic_code || "Тема не указана")}
</p>
<span className="request-description-status-chip">{statusLabel(row?.status_code)}</span>
</div>
</div>
<button className="close" type="button" onClick={() => setDescriptionOpen(false)} aria-label="Закрыть">
×
</button>
</div>
<div className="request-description-modal-body">
<div className="request-description-modal-main">
<div className="request-description-modal-title">
<span className="request-field-label">Описание проблемы</span>
</div>
<div className="request-description-modal-text">
{row?.description ? String(row.description) : "Описание не заполнено"}
</div>
</div>
<div className="request-description-modal-meta-wrap">
<div className="request-description-modal-meta">
<div className="request-description-meta-item">
<span className="request-field-label">Клиент</span>
<span
className={"request-field-value" + (clientHasPhone ? " has-tooltip request-contact-value" : "")}
data-tooltip={clientHasPhone ? clientPhone : undefined}
>
{clientLabel}
</span>
</div>
<div className="request-description-meta-item align-right">
<span className="request-field-label">Юрист</span>
<span
className={"request-field-value" + (lawyerHasPhone ? " has-tooltip request-contact-value" : "")}
data-tooltip={lawyerHasPhone ? lawyerPhone : undefined}
>
{lawyerLabel}
</span>
</div>
<div className="request-description-meta-item">
<span className="request-field-label">Создана</span>
<span className="request-field-value">{fmtShortDateTime(row?.created_at)}</span>
</div>
<div className="request-description-meta-item align-right">
<span className="request-field-label">Изменена</span>
<span className="request-field-value">{fmtShortDateTime(row?.updated_at)}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div
className={"overlay" + (dataRequestModal.open ? " open" : "")}
onClick={closeDataRequestModal}
aria-hidden={dataRequestModal.open ? "false" : "true"}
>
<div className="modal request-data-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<div>
<h3>{dataRequestModal.messageId ? "Редактирование запроса данных" : "Запрос дополнительных данных"}</h3>
<p className="muted request-finance-subtitle">
{row?.track_number ? "Заявка " + String(row.track_number) : "Выберите поля для запроса"}
</p>
</div>
<button className="close" type="button" onClick={closeDataRequestModal} aria-label="Закрыть">
×
</button>
</div>
<div className="stack">
<div className="request-data-modal-grid">
<div className="field">
<label htmlFor="request-data-request-template-select">Шаблон запроса (поиск)</label>
<div className="request-data-combobox">
<input
id="request-data-request-template-select"
value={dataRequestModal.requestTemplateQuery}
onChange={(event) =>
setDataRequestModal((prev) => ({
...prev,
requestTemplateQuery: event.target.value,
selectedRequestTemplateId: "",
templateStatus: "",
error: "",
}))
}
onFocus={() => setRequestTemplateSuggestOpen(true)}
onBlur={() => window.setTimeout(() => setRequestTemplateSuggestOpen(false), 120)}
disabled={dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate}
placeholder="Введите название шаблона"
/>
{requestTemplateBadge ? (
<span className={"request-data-template-badge " + requestTemplateBadge.kind}>{requestTemplateBadge.label}</span>
) : null}
{requestTemplateSuggestOpen && filteredRequestTemplates.length ? (
<div className="request-data-suggest-list" role="listbox" aria-label="Шаблоны запроса">
{filteredRequestTemplates.map((tpl) => (
<button
key={String(tpl.id)}
type="button"
className="request-data-suggest-item"
onMouseDown={(event) => {
event.preventDefault();
setDataRequestModal((prev) => ({
...prev,
requestTemplateQuery: String(tpl.name || ""),
selectedRequestTemplateId: String(tpl.id || ""),
error: "",
templateStatus: "",
}));
setRequestTemplateSuggestOpen(false);
void applyRequestTemplateById(String(tpl.id || ""), String(tpl.name || ""));
}}
>
<span>{String(tpl.name || "Шаблон")}</span>
</button>
))}
</div>
) : null}
</div>
</div>
<div className="request-data-modal-actions-inline">
<button
type="button"
className="icon-btn"
data-tooltip={
!canSaveSelectedRequestTemplate
? "Чужой шаблон недоступен для изменения"
: requestTemplateActionMode === "save"
? "Перезаписать шаблон"
: requestTemplateActionMode === "create"
? "Создать шаблон"
: "Введите название шаблона"
}
onClick={saveCurrentDataRequestTemplate}
disabled={
!canSaveSelectedRequestTemplate ||
dataRequestModal.loading ||
dataRequestModal.saving ||
dataRequestModal.savingTemplate
}
>
{dataRequestModal.savingTemplate ? "…" : requestTemplateActionMode === "create" ? "✚" : "💾"}
</button>
</div>
</div>
{dataRequestModal.templateStatus ? <div className="status ok">{dataRequestModal.templateStatus}</div> : null}
{canRequestData && dataRequestModal.messageId ? (
<div className="request-data-progress-line">
<span className="request-data-progress-chip">
{"Заполнено клиентом: " + String(dataRequestProgress.filled) + " / " + String(dataRequestProgress.total)}
</span>
</div>
) : null}
<div className="request-data-modal-grid">
<div className="field">
<label htmlFor="request-data-template-select">Поле данных (поиск по справочнику)</label>
<div className="request-data-combobox">
<input
id="request-data-template-select"
value={dataRequestModal.catalogFieldQuery}
onChange={(event) =>
setDataRequestModal((prev) => ({
...prev,
catalogFieldQuery: event.target.value,
selectedCatalogTemplateId: "",
templateStatus: "",
error: "",
}))
}
onFocus={() => setCatalogFieldSuggestOpen(true)}
onBlur={() => window.setTimeout(() => setCatalogFieldSuggestOpen(false), 120)}
disabled={dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate}
placeholder="Начните вводить наименование поля"
autoComplete="off"
/>
{catalogFieldSuggestOpen && filteredCatalogFields.length ? (
<div className="request-data-suggest-list" role="listbox" aria-label="Поля данных">
{filteredCatalogFields.map((tpl) => (
<button
key={String(tpl.id)}
type="button"
className="request-data-suggest-item"
onMouseDown={(event) => {
event.preventDefault();
setDataRequestModal((prev) => ({
...prev,
catalogFieldQuery: String(tpl.label || tpl.key || ""),
selectedCatalogTemplateId: String(tpl.id || ""),
error: "",
templateStatus: "",
}));
setCatalogFieldSuggestOpen(false);
}}
>
<span>{String(tpl.label || tpl.key)}</span>
<small>{String(tpl.value_type || "string")}</small>
</button>
))}
</div>
) : null}
</div>
</div>
<div className="request-data-modal-actions-inline">
<button
type="button"
className="icon-btn"
data-tooltip={catalogFieldActionMode === "add" ? "Добавить поле из справочника" : "Создать новое поле"}
onClick={addSelectedTemplateRow}
disabled={
!String(dataRequestModal.catalogFieldQuery || "").trim() && !selectedCatalogFieldCandidate ||
dataRequestModal.loading ||
dataRequestModal.saving ||
dataRequestModal.savingTemplate
}
>
{catalogFieldActionMode === "add" ? "+" : "✚"}
</button>
</div>
</div>
<div className="request-data-rows">
{(dataRequestModal.rows || []).length ? (
(dataRequestModal.rows || []).map((rowItem, idx) => (
<div
className={
"request-data-row" +
(String(draggedRequestRowId) === String(rowItem.localId) ? " dragging" : "") +
(String(dragOverRequestRowId) === String(rowItem.localId) && String(draggedRequestRowId) !== String(rowItem.localId) ? " drag-over" : "") +
(viewerRoleCode === "LAWYER" && rowItem?.is_filled ? " row-locked" : "")
}
key={rowItem.localId}
onDragOver={(event) => {
if (!draggedRequestRowId) return;
event.preventDefault();
if (viewerRoleCode === "LAWYER" && rowItem?.is_filled) return;
setDragOverRequestRowId(String(rowItem.localId || ""));
}}
onDrop={(event) => {
if (!draggedRequestRowId) return;
event.preventDefault();
if (viewerRoleCode === "LAWYER" && rowItem?.is_filled) return;
moveDataRequestRowToIndex(draggedRequestRowId, idx);
handleRequestRowDragEnd();
}}
>
<button
type="button"
className="icon-btn request-data-row-index-handle"
data-tooltip={
viewerRoleCode === "LAWYER" && rowItem?.is_filled
? "Заполненное поле: перемещение недоступно"
: "Перетащите для изменения порядка"
}
draggable={!(viewerRoleCode === "LAWYER" && rowItem?.is_filled)}
onDragStart={(event) => handleRequestRowDragStart(event, rowItem, viewerRoleCode === "LAWYER" && rowItem?.is_filled)}
onDragEnd={handleRequestRowDragEnd}
disabled={
dataRequestModal.loading ||
dataRequestModal.saving ||
dataRequestModal.savingTemplate ||
(viewerRoleCode === "LAWYER" && rowItem?.is_filled)
}
aria-label={"Порядок поля " + String(idx + 1)}
>
<span>{idx + 1}</span>
</button>
<div className="field">
<label>Наименование</label>
<input
value={rowItem.label}
onChange={(event) => updateDataRequestRow(rowItem.localId, { label: event.target.value })}
disabled={
dataRequestModal.loading ||
dataRequestModal.saving ||
dataRequestModal.savingTemplate ||
(viewerRoleCode === "LAWYER" && rowItem?.is_filled)
}
/>
</div>
<div className="field">
<label>Тип</label>
<select
value={rowItem.field_type || "string"}
onChange={(event) => updateDataRequestRow(rowItem.localId, { field_type: event.target.value })}
disabled={
dataRequestModal.loading ||
dataRequestModal.saving ||
dataRequestModal.savingTemplate ||
(viewerRoleCode === "LAWYER" && rowItem?.is_filled)
}
>
{requestDataTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="request-data-row-controls">
<button
type="button"
className="icon-btn danger request-data-row-action-btn"
data-tooltip={
viewerRoleCode === "LAWYER" && rowItem?.is_filled
? "Юрист не может удалить заполненное поле"
: "Удалить поле"
}
onClick={() => removeDataRequestRow(rowItem.localId)}
disabled={
dataRequestModal.loading ||
dataRequestModal.saving ||
dataRequestModal.savingTemplate ||
(viewerRoleCode === "LAWYER" && rowItem?.is_filled)
}
>
×
</button>
</div>
{canRequestData && (rowItem?.is_filled || String(rowItem?.value_text || "").trim()) ? (
<div className="request-data-row-client-value">
<span className="request-data-row-client-label">Заполнено клиентом:</span>
{String(rowItem?.field_type || "").toLowerCase() === "file" ? (
rowItem?.value_file && rowItem.value_file.download_url ? (
<button
type="button"
className="chat-message-file-chip"
onClick={() => openAttachmentFromMessage(rowItem.value_file)}
>
<span className="chat-message-file-icon" aria-hidden="true">📎</span>
<span className="chat-message-file-name">{String(rowItem.value_file.file_name || "Файл")}</span>
</button>
) : (
<span className="muted">Файл добавлен</span>
)
) : (
<span className="request-data-row-client-text">
{String(rowItem?.field_type || "").toLowerCase() === "date"
? fmtDateOnly(rowItem?.value_text)
: String(rowItem?.value_text || "").trim().slice(0, 140)}
</span>
)}
</div>
) : null}
</div>
))
) : (
<div className="muted">Поля для запроса еще не добавлены</div>
)}
</div>
</div>
{dataRequestModal.error ? <div className="status error">{dataRequestModal.error}</div> : null}
<div className="modal-actions modal-actions-right">
<button
type="button"
className="btn btn-sm request-data-submit-btn"
onClick={submitDataRequestModal}
disabled={dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate}
>
{dataRequestModal.saving ? "Сохранение..." : "Отправить"}
</button>
</div>
</div>
</div>
<div
className={"overlay" + (requestDataListOpen ? " open" : "")}
onClick={() => setRequestDataListOpen(false)}
aria-hidden={requestDataListOpen ? "false" : "true"}
>
<div className="modal request-data-summary-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<div>
<h3>Данные заявки</h3>
<p className="muted request-finance-subtitle">{row?.track_number ? "Заявка " + String(row.track_number) : ""}</p>
</div>
<button className="close" type="button" onClick={() => setRequestDataListOpen(false)} aria-label="Закрыть">
×
</button>
</div>
<div className="request-data-summary-list">
{requestDataListItems.length ? (
requestDataListItems.map((item) => {
const value = formatRequestDataValue(item);
const isFile = String(item?.field_type || "").toLowerCase() === "file";
return (
<div className="request-data-summary-row" key={String(item.id || item.key)}>
<div className="request-data-summary-label">{String(item.label || humanizeKey(item.key))}</div>
<div className="request-data-summary-value">
{isFile ? (
value && typeof value === "object" ? (
<div className="request-data-summary-file">
<button type="button" className="chat-message-file-chip" onClick={() => downloadAttachment(value)}>
<span className="chat-message-file-icon" aria-hidden="true">📎</span>
<span className="chat-message-file-name">{String(value.file_name || "Файл")}</span>
</button>
</div>
) : (
<span className="muted">Не заполнено</span>
)
) : String(value || "Не заполнено")}
</div>
</div>
);
})
) : (
<p className="muted">Дополнительные данные по заявке отсутствуют</p>
)}
</div>
</div>
</div>
</div>
);
}
export default RequestWorkspace;