Law/app/web/admin/features/requests/RequestWorkspace.jsx
2026-03-17 10:57:57 +03:00

2858 lines
128 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,
invoiceStatusLabel,
statusLabel,
} from "../../shared/utils.js";
export function RequestWorkspace({
viewerRole,
viewerUserId,
viewerUserEmail,
viewerUserName,
loading,
trackNumber,
requestData,
financeSummary,
invoices,
statusRouteNodes,
statusHistory,
availableStatuses,
currentImportantDateAt,
pendingStatusChangePreset,
messages,
messagesHasMore,
messagesLoadingMore,
attachments,
messageDraft,
selectedFiles,
fileUploading,
status,
onMessageChange,
onSendMessage,
onLoadOlderMessages,
onFilesSelect,
onRemoveSelectedFile,
onClearSelectedFiles,
onLoadRequestDataTemplates,
onLoadRequestDataBatch,
onLoadRequestDataTemplateDetails,
onSaveRequestDataTemplate,
onSaveRequestDataBatch,
onIssueInvoice,
onDownloadInvoicePdf,
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 [financeIssueForm, setFinanceIssueForm] = useState({
open: false,
saving: false,
amount: "",
serviceDescription: "",
payerDisplayName: "",
error: "",
});
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 showContactsInCard = viewerRoleCode !== "CLIENT";
const safeMessages = Array.isArray(messages) ? messages : [];
const safeAttachments = Array.isArray(attachments) ? attachments : [];
const safeInvoices = Array.isArray(invoices) ? invoices : [];
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 || "").trim() || humanizeKey(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 formatMoneyInput = (value) => {
const amount = Number(value);
if (!Number.isFinite(amount) || amount <= 0) return "";
return String(Math.round((amount + Number.EPSILON) * 100) / 100);
};
const openFinanceIssueForm = () => {
const defaultAmount =
finance?.request_cost ??
row?.request_cost ??
row?.invoice_amount ??
finance?.effective_rate ??
row?.effective_rate ??
"";
setFinanceIssueForm({
open: true,
saving: false,
amount: formatMoneyInput(defaultAmount),
serviceDescription: String(row?.topic_name || row?.topic_code || "Юридические услуги"),
payerDisplayName: String(row?.client_name || "").trim() || "Клиент",
error: "",
});
};
const closeFinanceIssueForm = () => {
setFinanceIssueForm((prev) => ({ ...prev, open: false, saving: false, error: "" }));
};
const closeFinanceModal = () => {
setFinanceOpen(false);
closeFinanceIssueForm();
};
const submitFinanceIssueForm = async (event) => {
if (event && typeof event.preventDefault === "function") event.preventDefault();
if (!row?.id || typeof onIssueInvoice !== "function") return;
const normalizedAmount = Number(String(financeIssueForm.amount || "").replace(",", "."));
if (!Number.isFinite(normalizedAmount) || normalizedAmount <= 0) {
setFinanceIssueForm((prev) => ({ ...prev, error: "Укажите корректную сумму счета" }));
return;
}
setFinanceIssueForm((prev) => ({ ...prev, saving: true, error: "" }));
try {
await onIssueInvoice({
requestId: String(row.id),
amount: normalizedAmount,
serviceDescription: String(financeIssueForm.serviceDescription || ""),
payerDisplayName: String(financeIssueForm.payerDisplayName || ""),
});
setFinanceIssueForm((prev) => ({ ...prev, open: false, saving: false, error: "" }));
} catch (error) {
setFinanceIssueForm((prev) => ({ ...prev, saving: false, error: error?.message || "Не удалось выставить счет" }));
}
};
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 baseRouteNodes =
Array.isArray(statusRouteNodes) && statusRouteNodes.length
? statusRouteNodes
: row?.status_code
? [{ code: row.status_code, name: String(row?.status_name || statusLabel(row.status_code) || row.status_code), state: "current", note: "Текущий этап обработки заявки" }]
: [];
const upcomingImportantDate = useMemo(() => {
const source = String(currentImportantDateAt || row?.important_date_at || "").trim();
if (!source) return "";
const timestamp = new Date(source).getTime();
if (!Number.isFinite(timestamp) || timestamp <= Date.now()) return "";
return new Date(timestamp).toISOString();
}, [currentImportantDateAt, row?.important_date_at]);
const routeNodes = useMemo(() => {
if ((viewerRoleCode !== "CLIENT" && viewerRoleCode !== "LAWYER") || !upcomingImportantDate) return baseRouteNodes;
if (!Array.isArray(baseRouteNodes) || !baseRouteNodes.length) {
return [
{
code: "__IMPORTANT_DATE__",
name: "Важная дата",
state: "pending",
changed_at: upcomingImportantDate,
note: "Контрольный срок",
},
];
}
const hasVirtualNode = baseRouteNodes.some((node) => String(node?.code || "").trim() === "__IMPORTANT_DATE__");
if (hasVirtualNode) return baseRouteNodes;
const currentIndex = baseRouteNodes.findIndex((node) => String(node?.state || "").trim().toLowerCase() === "current");
const virtualNode = {
code: "__IMPORTANT_DATE__",
name: "Важная дата",
state: "pending",
changed_at: upcomingImportantDate,
note: "Контрольный срок",
};
if (currentIndex < 0) return [...baseRouteNodes, virtualNode];
const next = [...baseRouteNodes];
next.splice(currentIndex + 1, 0, virtualNode);
return next;
}, [baseRouteNodes, upcomingImportantDate, viewerRoleCode]);
const routeNodesForDisplay = useMemo(() => {
if (!Array.isArray(routeNodes) || !routeNodes.length) return [];
const important = [];
const current = [];
const completed = [];
const pending = [];
routeNodes.forEach((node) => {
const code = String(node?.code || "").trim();
const state = String(node?.state || "pending").trim().toLowerCase();
if (code === "__IMPORTANT_DATE__") {
important.push(node);
return;
}
if (state === "current") {
current.push(node);
return;
}
if (state === "completed") {
completed.push(node);
return;
}
pending.push(node);
});
return [...important, ...current, ...completed.reverse(), ...pending];
}, [routeNodes]);
const AttachmentPreviewModal = AttachmentPreviewModalComponent;
const StatusLine = StatusLineComponent;
const resolveMessageReceiptState = (payload) => {
const authorType = String(payload?.author_type || "").trim().toUpperCase();
const isClientAuthor = authorType === "CLIENT";
const deliveredAt = isClientAuthor ? payload?.delivered_to_staff_at : payload?.delivered_to_client_at;
const readAt = isClientAuthor ? payload?.read_by_staff_at : payload?.read_by_client_at;
if (readAt) return { state: "read", label: "Прочитано" };
if (deliveredAt) return { state: "delivered", label: "Доставлено" };
return { state: "sent", label: "Отправлено" };
};
const isOutgoingForViewer = (payload) => {
const authorType = String(payload?.author_type || "").trim().toUpperCase();
if (!authorType) return false;
if (viewerRoleCode === "CLIENT") return authorType === "CLIENT";
if (authorType === "CLIENT") return false;
const authorAdminUserId = String(payload?.author_admin_user_id || "").trim();
const currentViewerUserId = String(viewerUserId || "").trim();
if (authorAdminUserId && currentViewerUserId) return authorAdminUserId === currentViewerUserId;
const authorName = String(payload?.author_name || "").trim().toLowerCase();
const viewerName = String(viewerUserName || "").trim().toLowerCase();
const viewerEmail = String(viewerUserEmail || "").trim().toLowerCase();
if (authorName && ((viewerName && authorName === viewerName) || (viewerEmail && authorName === viewerEmail))) {
return true;
}
return !viewerName && !viewerEmail ? authorType !== "CLIENT" : false;
};
const renderMessageMeta = (payload) => {
const timeLabel = fmtTimeOnly(payload?.created_at);
if (!isOutgoingForViewer(payload)) return <div className="chat-message-time">{timeLabel}</div>;
const receipt = resolveMessageReceiptState(payload);
return (
<div className="chat-message-meta">
<div className="chat-message-time">{timeLabel}</div>
<span className={"chat-message-status " + receipt.state} title={receipt.label} aria-label={receipt.label}>
<span className="chat-message-status-check first" aria-hidden="true">
</span>
{receipt.state !== "sent" ? (
<span className="chat-message-status-check second" aria-hidden="true">
</span>
) : null}
</span>
</div>
);
};
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 resolveServiceMessageContent = (payload) => {
const messageKind = String(payload?.message_kind || "");
if (messageKind === "REQUEST_DATA") return null;
const bodyRaw = String(payload?.body || "").replace(/\r/g, "").trim();
if (!bodyRaw) return null;
const lines = bodyRaw.split("\n");
const firstLine = String(lines[0] || "").trim();
const restLines = lines.slice(1);
const normalizeDetail = (value) => String(value || "").trim();
const withTail = (firstDetail) =>
[normalizeDetail(firstDetail), ...restLines.map((line) => normalizeDetail(line)).filter(Boolean)].filter(Boolean).join("\n");
if (firstLine === "Счет на оплату" || firstLine.startsWith("Счет на оплату:")) {
return {
title: "Счет на оплату",
text: withTail(firstLine.startsWith("Счет на оплату:") ? firstLine.slice("Счет на оплату:".length) : ""),
};
}
if (firstLine.startsWith("Изменился статус:") || firstLine.startsWith("Смена статуса:")) {
const source = firstLine.startsWith("Изменился статус:") ? firstLine : firstLine.slice("Смена статуса:".length);
const detail = firstLine.startsWith("Изменился статус:") ? source.slice("Изменился статус:".length) : source;
return {
title: "Изменился статус",
text: withTail(detail),
};
}
if (firstLine.startsWith("Назначен юрист:")) {
return {
title: "Назначен юрист",
text: withTail(firstLine.slice("Назначен юрист:".length)),
};
}
if (firstLine.startsWith("Переназначено:")) {
return {
title: "Смена юриста",
text: withTail(firstLine.slice("Переназначено:".length)),
};
}
if (firstLine.startsWith("Смена юриста:")) {
return {
title: "Смена юриста",
text: withTail(firstLine.slice("Смена юриста:".length)),
};
}
if (firstLine.startsWith("Назначение юриста:")) {
return {
title: "Назначен юрист",
text: withTail(firstLine.slice("Назначение юриста:".length)),
};
}
if (firstLine.startsWith("Назначение:")) {
return {
title: "Назначен юрист",
text: withTail(firstLine.slice("Назначение:".length)),
};
}
if (firstLine.startsWith("Переназначение:")) {
return {
title: "Смена юриста",
text: withTail(firstLine.slice("Переназначение:".length)),
};
}
return null;
};
const resolveStatusDisplayName = (code, explicitName) => {
const explicit = String(explicitName || "").trim();
if (explicit) return explicit;
const normalizedCode = String(code || "").trim();
if (!normalizedCode) return "-";
const optionName = String(statusByCode.get(normalizedCode)?.name || "").trim();
if (optionName) return optionName;
const legacyName = String(statusLabel(normalizedCode) || "").trim();
if (legacyName && legacyName !== normalizedCode) return legacyName;
return humanizeKey(normalizedCode);
};
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 currentStatusName = resolveStatusDisplayName(row?.status_code, row?.status_name || "");
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>
<div className="request-card-head-spacer" aria-hidden="true" />
{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">{currentStatusName}</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>
{showContactsInCard ? (
<>
<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>
</>
) : null}
{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>
{routeNodesForDisplay.length ? (
<ol className="request-route-list" id="request-status-route">
{routeNodesForDisplay.map((node, index) => {
const state = String(node?.state || "pending");
const code = String(node?.code || "").trim();
const rawName = String(node?.name || "").trim();
const name = resolveStatusDisplayName(code, rawName && rawName !== code ? rawName : "");
const note = String(node?.note || "").trim();
const isImportantDateNode = code === "__IMPORTANT_DATE__";
const changedAtSource =
String(node?.changed_at || "").trim() ||
(isImportantDateNode ? String(currentImportantDateAt || row?.important_date_at || "").trim() : "");
const changedAt = changedAtSource ? fmtDate(changedAtSource) : "";
const className =
"route-item " +
(state === "current" ? "current" : state === "completed" ? "completed" : "pending") +
(isImportantDateNode ? " important-date" : "");
return (
<li className={className} key={(node?.code || "node") + "-" + index}>
<span className="route-dot" />
<div className="route-body">
<b>{name}</b>
{isImportantDateNode ? (
<p>{"Контрольный срок: " + (changedAt || "-")}</p>
) : (
<>
{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" ? (
<>
{messagesHasMore ? (
<div className="request-chat-history-actions">
<button
type="button"
className="btn secondary"
onClick={onLoadOlderMessages}
disabled={loading || fileUploading || messagesLoadingMore}
>
{messagesLoadingMore ? "Загрузка истории..." : "Показать предыдущие сообщения"}
</button>
</div>
) : null}
<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 serviceMessageContent = resolveServiceMessageContent(entry.payload);
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 isOutgoing = isOutgoingForViewer(entry.payload);
const itemClass =
"chat-message " +
(isOutgoing ? "outgoing" : "incoming") +
(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)}
</>
) : (
<>
{serviceMessageContent?.title ? <div className="chat-service-head">{serviceMessageContent.title}</div> : null}
{serviceMessageContent ? (
serviceMessageContent.text ? <p className="chat-message-text">{serviceMessageContent.text}</p> : null
) : (
<p className="chat-message-text">
{entry.payload?.body_loaded === false ? "Загрузка сообщения..." : 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>
);
})()}
{renderMessageMeta(entry.payload)}
</div>
</li>
);
})()
)
)
) : (
<li className="muted chat-empty-state">Сообщений нет</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.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>{resolveStatusDisplayName(statusCode, item?.to_status_name || statusMeta?.name || "")}</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>
{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={closeFinanceModal}
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={closeFinanceModal} 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>
{typeof onIssueInvoice === "function" ? (
<div className="request-finance-actions">
{!financeIssueForm.open ? (
<button
type="button"
className="btn btn-sm"
onClick={openFinanceIssueForm}
disabled={loading || !row}
>
Выставить счет
</button>
) : (
<form className="stack request-finance-issue-form" onSubmit={submitFinanceIssueForm}>
<div className="request-finance-issue-grid">
<div className="field">
<label htmlFor="request-finance-invoice-amount">Сумма</label>
<input
id="request-finance-invoice-amount"
type="number"
min="0.01"
step="0.01"
value={financeIssueForm.amount}
onChange={(event) => setFinanceIssueForm((prev) => ({ ...prev, amount: event.target.value, error: "" }))}
disabled={financeIssueForm.saving || loading}
placeholder="0.00"
/>
</div>
<div className="field">
<label htmlFor="request-finance-invoice-payer">Плательщик</label>
<input
id="request-finance-invoice-payer"
type="text"
value={financeIssueForm.payerDisplayName}
onChange={(event) =>
setFinanceIssueForm((prev) => ({ ...prev, payerDisplayName: event.target.value, error: "" }))
}
disabled={financeIssueForm.saving || loading}
placeholder="ФИО / компания"
/>
</div>
</div>
<div className="field">
<label htmlFor="request-finance-invoice-service">Услуга</label>
<input
id="request-finance-invoice-service"
type="text"
value={financeIssueForm.serviceDescription}
onChange={(event) =>
setFinanceIssueForm((prev) => ({ ...prev, serviceDescription: event.target.value, error: "" }))
}
disabled={financeIssueForm.saving || loading}
placeholder="Юридические услуги"
/>
</div>
{financeIssueForm.error ? <div className="status error">{financeIssueForm.error}</div> : null}
<div className="modal-actions modal-actions-right request-finance-actions-inline">
<button type="button" className="btn secondary btn-sm" onClick={closeFinanceIssueForm} disabled={financeIssueForm.saving}>
Отмена
</button>
<button type="submit" className="btn btn-sm" disabled={financeIssueForm.saving || loading}>
{financeIssueForm.saving ? "Выставление..." : "Выставить"}
</button>
</div>
</form>
)}
</div>
) : null}
<div className="request-finance-invoices">
<div className="request-finance-invoices-head">
<h4>Счета</h4>
<span className="muted">{safeInvoices.length ? String(safeInvoices.length) + " шт." : "Нет выставленных счетов"}</span>
</div>
{safeInvoices.length ? (
<div className="request-finance-invoice-list">
{safeInvoices.map((item) => (
<div className="request-finance-invoice-row" key={String(item?.id || item?.invoice_number || item?.issued_at || "-")}>
<div className="request-finance-invoice-meta">
<div className="request-finance-invoice-number">
<code>{String(item?.invoice_number || "-")}</code>
</div>
<div className="request-finance-invoice-details">
<span>{invoiceStatusLabel(item?.status)}</span>
<span>{fmtAmount(item?.amount) + " " + String(item?.currency || "RUB")}</span>
<span>{"Создан: " + fmtDate(item?.issued_at)}</span>
<span>{"Оплачен: " + fmtDate(item?.paid_at)}</span>
</div>
</div>
{typeof onDownloadInvoicePdf === "function" ? (
<button
type="button"
className="icon-btn request-finance-invoice-download-btn"
onClick={() => onDownloadInvoicePdf(item)}
disabled={loading}
aria-label="Скачать счет PDF"
data-tooltip="Скачать PDF"
>
</button>
) : null}
</div>
))}
</div>
) : (
<p className="muted request-finance-empty">Счета по заявке пока не выставлялись</p>
)}
</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">{currentStatusName}</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"
name="request_template_search_nohistory"
type="text"
value={dataRequestModal.requestTemplateQuery}
onChange={(event) =>
setDataRequestModal((prev) => ({
...prev,
requestTemplateQuery: event.target.value,
selectedRequestTemplateId: "",
templateStatus: "",
error: "",
}))
}
onFocus={(event) => {
event.currentTarget.removeAttribute("readonly");
setRequestTemplateSuggestOpen(true);
}}
onBlur={(event) => {
event.currentTarget.setAttribute("readonly", "readonly");
window.setTimeout(() => setRequestTemplateSuggestOpen(false), 120);
}}
disabled={dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate}
placeholder="Введите название шаблона"
readOnly
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
aria-autocomplete="list"
data-1p-ignore="true"
data-lpignore="true"
/>
{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"
name="request_field_search_nohistory"
type="text"
value={dataRequestModal.catalogFieldQuery}
onChange={(event) =>
setDataRequestModal((prev) => ({
...prev,
catalogFieldQuery: event.target.value,
selectedCatalogTemplateId: "",
templateStatus: "",
error: "",
}))
}
onFocus={(event) => {
event.currentTarget.removeAttribute("readonly");
setCatalogFieldSuggestOpen(true);
}}
onBlur={(event) => {
event.currentTarget.setAttribute("readonly", "readonly");
window.setTimeout(() => setCatalogFieldSuggestOpen(false), 120);
}}
disabled={dataRequestModal.loading || dataRequestModal.saving || dataRequestModal.savingTemplate}
placeholder="Начните вводить наименование поля"
readOnly
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
aria-autocomplete="list"
data-1p-ignore="true"
data-lpignore="true"
/>
{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;