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 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
Запрос
; if (allFilled) { const fileOnly = items.length === 1 && String(items[0]?.field_type || "").toLowerCase() === "file"; return{fileOnly ? "Файл" : "Заполнен"}
; } const visibleItems = items.slice(0, 7); const hiddenCount = Math.max(0, items.length - visibleItems.length); return (Загрузка...
) : row ? ( <>{note}
: null} {changedAt && state !== "pending" ?Маршрут статусов для темы не настроен
)}Нет данных по заявке
)}{String(entry.payload?.body || "")}
)} {(() => { 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 ({row?.track_number ? "Заявка " + String(row.track_number) : "Заполните данные по запросу юриста"}
{row?.track_number ? "Заявка " + String(row.track_number) : "Выберите статус и важную дату"}
{row?.track_number ? "Заявка " + String(row.track_number) : "Данные по заявке"}
{String(row?.topic_name || row?.topic_code || "Тема не указана")}
{statusLabel(row?.status_code)}{row?.track_number ? "Заявка " + String(row.track_number) : "Выберите поля для запроса"}
{row?.track_number ? "Заявка " + String(row.track_number) : ""}
Дополнительные данные по заявке отсутствуют
)}