mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
946 lines
41 KiB
JavaScript
946 lines
41 KiB
JavaScript
import { RequestWorkspace } from "./admin/features/requests/RequestWorkspace.jsx";
|
||
import { createRequestModalState } from "./admin/shared/state.js";
|
||
import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./admin/shared/utils.js";
|
||
|
||
(function () {
|
||
const { useCallback, useEffect, useMemo, useRef, useState } = React;
|
||
|
||
function StatusLine({ status }) {
|
||
return <p className={"status" + (status?.kind ? " " + status.kind : "")}>{status?.message || ""}</p>;
|
||
}
|
||
|
||
function Overlay({ open, id, onClose, children }) {
|
||
return (
|
||
<div className={"overlay" + (open ? " open" : "")} id={id} onClick={onClose}>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GlobalTooltipLayer() {
|
||
const [tooltip, setTooltip] = useState({ open: false, text: "", x: 0, y: 0, maxWidth: 320 });
|
||
const activeRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
const getTarget = (node) => {
|
||
if (!(node instanceof Element)) return null;
|
||
const el = node.closest("[data-tooltip]");
|
||
if (!el) return null;
|
||
const text = String(el.getAttribute("data-tooltip") || "").trim();
|
||
return text ? el : null;
|
||
};
|
||
|
||
const reposition = (el) => {
|
||
if (!(el instanceof Element)) return;
|
||
const text = String(el.getAttribute("data-tooltip") || "").trim();
|
||
if (!text) return;
|
||
const rect = el.getBoundingClientRect();
|
||
const vw = window.innerWidth || 0;
|
||
const maxWidth = Math.min(360, Math.max(140, vw - 24));
|
||
const approxWidth = Math.min(maxWidth, Math.max(80, text.length * 7.1 + 22));
|
||
const centerX = rect.left + rect.width / 2;
|
||
const x = Math.max(12 + approxWidth / 2, Math.min(vw - 12 - approxWidth / 2, centerX));
|
||
const y = Math.max(8, rect.top - 8);
|
||
setTooltip({ open: true, text, x, y, maxWidth });
|
||
};
|
||
|
||
const open = (node) => {
|
||
const target = getTarget(node);
|
||
if (!target) return;
|
||
activeRef.current = target;
|
||
reposition(target);
|
||
};
|
||
|
||
const closeIfNeeded = (related) => {
|
||
const current = activeRef.current;
|
||
if (!current) return;
|
||
if (related instanceof Element) {
|
||
if (related === current || current.contains(related)) return;
|
||
const nextTarget = getTarget(related);
|
||
if (nextTarget === current) return;
|
||
}
|
||
activeRef.current = null;
|
||
setTooltip((prev) => ({ ...prev, open: false }));
|
||
};
|
||
|
||
const onMouseOver = (event) => open(event.target);
|
||
const onFocusIn = (event) => open(event.target);
|
||
const onMouseOut = (event) => closeIfNeeded(event.relatedTarget);
|
||
const onFocusOut = (event) => closeIfNeeded(event.relatedTarget);
|
||
const onUpdatePosition = () => {
|
||
if (activeRef.current) reposition(activeRef.current);
|
||
};
|
||
|
||
document.addEventListener("mouseover", onMouseOver, true);
|
||
document.addEventListener("focusin", onFocusIn, true);
|
||
document.addEventListener("mouseout", onMouseOut, true);
|
||
document.addEventListener("focusout", onFocusOut, true);
|
||
window.addEventListener("scroll", onUpdatePosition, true);
|
||
window.addEventListener("resize", onUpdatePosition);
|
||
|
||
return () => {
|
||
document.removeEventListener("mouseover", onMouseOver, true);
|
||
document.removeEventListener("focusin", onFocusIn, true);
|
||
document.removeEventListener("mouseout", onMouseOut, true);
|
||
document.removeEventListener("focusout", onFocusOut, true);
|
||
window.removeEventListener("scroll", onUpdatePosition, true);
|
||
window.removeEventListener("resize", onUpdatePosition);
|
||
};
|
||
}, []);
|
||
|
||
return (
|
||
<div
|
||
className={"global-tooltip-layer" + (tooltip.open ? " open" : "")}
|
||
style={{ left: tooltip.x + "px", top: tooltip.y + "px", maxWidth: tooltip.maxWidth + "px" }}
|
||
role="tooltip"
|
||
aria-hidden={tooltip.open ? "false" : "true"}
|
||
>
|
||
{tooltip.text}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AttachmentPreviewModal({ open, title, url, fileName, mimeType, onClose }) {
|
||
const [resolvedUrl, setResolvedUrl] = useState("");
|
||
const [resolvedText, setResolvedText] = useState("");
|
||
const [resolvedKind, setResolvedKind] = useState("");
|
||
const [hint, setHint] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
const decodeTextPreview = (arrayBuffer) => {
|
||
const bytes = new Uint8Array(arrayBuffer || new ArrayBuffer(0));
|
||
const sampleLength = Math.min(bytes.length, 4096);
|
||
let suspicious = 0;
|
||
for (let i = 0; i < sampleLength; i += 1) {
|
||
const byte = bytes[i];
|
||
if (byte === 0) suspicious += 4;
|
||
else if (byte < 9 || (byte > 13 && byte < 32)) suspicious += 1;
|
||
}
|
||
if (sampleLength && suspicious / sampleLength > 0.08) return null;
|
||
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes).replace(/\u0000/g, "");
|
||
return text.length > 200000 ? text.slice(0, 200000) + "\n\n[Текст обрезан для предпросмотра]" : text;
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!open || !url) {
|
||
setResolvedUrl("");
|
||
setResolvedText("");
|
||
setResolvedKind("");
|
||
setHint("");
|
||
setLoading(false);
|
||
setError("");
|
||
return;
|
||
}
|
||
const kind = detectAttachmentPreviewKind(fileName, mimeType);
|
||
setResolvedKind(kind);
|
||
setResolvedText("");
|
||
setHint("");
|
||
if (kind === "none") {
|
||
setResolvedUrl("");
|
||
setLoading(false);
|
||
setError("");
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
let objectUrl = "";
|
||
setLoading(true);
|
||
setError("");
|
||
setResolvedUrl("");
|
||
|
||
(async () => {
|
||
try {
|
||
const response = await fetch(url, { credentials: "same-origin" });
|
||
if (!response.ok) throw new Error("Не удалось загрузить файл для предпросмотра");
|
||
const buffer = await response.arrayBuffer();
|
||
if (cancelled) return;
|
||
|
||
if (kind === "pdf") {
|
||
const header = new Uint8Array(buffer.slice(0, 5));
|
||
const isPdf =
|
||
header.length >= 5 &&
|
||
header[0] === 0x25 &&
|
||
header[1] === 0x50 &&
|
||
header[2] === 0x44 &&
|
||
header[3] === 0x46 &&
|
||
header[4] === 0x2d;
|
||
if (isPdf) {
|
||
setResolvedUrl(String(url));
|
||
setResolvedKind("pdf");
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
const textPreview = decodeTextPreview(buffer);
|
||
if (textPreview != null) {
|
||
setResolvedUrl("");
|
||
setResolvedText(textPreview);
|
||
setResolvedKind("text");
|
||
setHint("Файл помечен как PDF, но не является валидным PDF. Показан текстовый предпросмотр.");
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
throw new Error("Файл помечен как PDF, но не является валидным PDF-документом.");
|
||
}
|
||
|
||
if (kind === "text") {
|
||
const textPreview = decodeTextPreview(buffer);
|
||
if (textPreview == null) throw new Error("Не удалось распознать текстовый файл для предпросмотра.");
|
||
setResolvedUrl("");
|
||
setResolvedText(textPreview);
|
||
setResolvedKind("text");
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
const blob = new Blob([buffer], { type: response.headers.get("content-type") || mimeType || "application/octet-stream" });
|
||
objectUrl = URL.createObjectURL(blob);
|
||
if (cancelled) {
|
||
URL.revokeObjectURL(objectUrl);
|
||
return;
|
||
}
|
||
setResolvedUrl(objectUrl);
|
||
setResolvedKind(kind);
|
||
setLoading(false);
|
||
} catch (err) {
|
||
if (cancelled) return;
|
||
setError(err instanceof Error ? err.message : "Не удалось открыть предпросмотр");
|
||
setLoading(false);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||
};
|
||
}, [fileName, mimeType, open, url]);
|
||
|
||
if (!open || !url) return null;
|
||
const kind = resolvedKind || detectAttachmentPreviewKind(fileName, mimeType);
|
||
return (
|
||
<Overlay open={open} id="file-preview-overlay" onClose={(event) => event.target.id === "file-preview-overlay" && onClose()}>
|
||
<div className="modal request-preview-modal" onClick={(event) => event.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<h3>{title || fileName || "Предпросмотр файла"}</h3>
|
||
<div className="request-preview-head-actions">
|
||
<a className="icon-btn file-action-btn request-preview-download-icon" href={url} target="_blank" rel="noreferrer" aria-label="Скачать файл" data-tooltip="Скачать">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||
<path
|
||
d="M12 3a1 1 0 0 1 1 1v8.17l2.58-2.58a1 1 0 1 1 1.42 1.42l-4.3 4.3a1 1 0 0 1-1.4 0l-4.3-4.3a1 1 0 0 1 1.42-1.42L11 12.17V4a1 1 0 0 1 1-1zm-7 14a1 1 0 0 1 1 1v1h12v-1a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1z"
|
||
fill="currentColor"
|
||
/>
|
||
</svg>
|
||
</a>
|
||
<button className="close" type="button" id="file-preview-close" onClick={onClose}>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="request-preview-body" id="file-preview-body">
|
||
{loading ? <p className="request-preview-note">Загрузка предпросмотра...</p> : null}
|
||
{!loading && !error && hint ? <p className="request-preview-note">{hint}</p> : null}
|
||
{error ? <p className="request-preview-note">{error}</p> : null}
|
||
{!loading && !error && kind === "image" && resolvedUrl ? (
|
||
<img className="request-preview-image" src={resolvedUrl} alt={fileName || "attachment"} />
|
||
) : null}
|
||
{!loading && !error && kind === "video" && resolvedUrl ? (
|
||
<video className="request-preview-video" src={resolvedUrl} controls preload="metadata" />
|
||
) : null}
|
||
{!loading && !error && kind === "pdf" && resolvedUrl ? (
|
||
<iframe className="request-preview-frame" src={resolvedUrl} title={fileName || "preview"} />
|
||
) : null}
|
||
{!loading && !error && kind === "text" ? (
|
||
<pre className="request-preview-text">{resolvedText || "Файл пуст."}</pre>
|
||
) : null}
|
||
{kind === "none" ? <p className="request-preview-note">Для этого типа файла доступно только открытие или скачивание.</p> : null}
|
||
</div>
|
||
</div>
|
||
</Overlay>
|
||
);
|
||
}
|
||
|
||
function ClientHelpModal({
|
||
open,
|
||
status,
|
||
loadingType,
|
||
lawyerChangeReason,
|
||
curatorBlocked,
|
||
lawyerChangeBlocked,
|
||
onClose,
|
||
onReasonChange,
|
||
onSubmitCurator,
|
||
onSubmitLawyerChange,
|
||
}) {
|
||
return (
|
||
<div className={"overlay" + (open ? " open" : "")} id="client-help-overlay" onClick={(event) => event.target.id === "client-help-overlay" && onClose()}>
|
||
<div className="modal client-help-modal" onClick={(event) => event.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<h3 id="client-help-title">Помощь по заявке</h3>
|
||
</div>
|
||
<button className="close" type="button" id="client-help-close" onClick={onClose} aria-label="Закрыть">
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="client-help-stack">
|
||
<section className="client-help-block">
|
||
<p className="client-help-description">
|
||
Если нужна дополнительная поддержка по делу, можно обратиться к куратору. Запрос отправляется администратору платформы.
|
||
</p>
|
||
<button
|
||
className="btn secondary btn-sm"
|
||
id="cabinet-curator-request-open"
|
||
type="button"
|
||
disabled={curatorBlocked || loadingType === "CURATOR_CONTACT"}
|
||
onClick={onSubmitCurator}
|
||
>
|
||
{loadingType === "CURATOR_CONTACT" ? "Отправка..." : "Обратиться к куратору"}
|
||
</button>
|
||
</section>
|
||
<section className="client-help-block">
|
||
<p className="client-help-description">
|
||
Если текущий юрист не устраивает, можно запросить его смену. Укажите причину для администратора.
|
||
</p>
|
||
<div className="field">
|
||
<label htmlFor="service-request-body">Причина смены юриста</label>
|
||
<textarea
|
||
id="service-request-body"
|
||
value={lawyerChangeReason}
|
||
onChange={onReasonChange}
|
||
maxLength={4000}
|
||
placeholder="Опишите причину"
|
||
disabled={lawyerChangeBlocked || loadingType === "LAWYER_CHANGE_REQUEST"}
|
||
/>
|
||
</div>
|
||
<button
|
||
className="btn secondary btn-sm"
|
||
id="cabinet-lawyer-change-open"
|
||
type="button"
|
||
disabled={lawyerChangeBlocked || loadingType === "LAWYER_CHANGE_REQUEST"}
|
||
onClick={onSubmitLawyerChange}
|
||
>
|
||
{loadingType === "LAWYER_CHANGE_REQUEST" ? "Отправка..." : "Запросить смену"}
|
||
</button>
|
||
</section>
|
||
<StatusLine status={status} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function App() {
|
||
const [requestModal, setRequestModal] = useState(createRequestModalState());
|
||
const [requestsList, setRequestsList] = useState([]);
|
||
const [activeTrack, setActiveTrack] = useState("");
|
||
const [status, setStatus] = useState({ message: "", kind: "" });
|
||
const [serviceRequests, setServiceRequests] = useState([]);
|
||
const [clientHelpModal, setClientHelpModal] = useState({
|
||
open: false,
|
||
loadingType: "",
|
||
lawyerChangeReason: "",
|
||
status: { message: "", kind: "" },
|
||
});
|
||
|
||
const setPageStatus = useCallback((message, kind) => {
|
||
setStatus({ message: String(message || ""), kind: kind || "" });
|
||
}, []);
|
||
|
||
const apiError = (data, fallback) => {
|
||
if (data && typeof data.detail === "string" && data.detail.trim()) return data.detail;
|
||
return fallback;
|
||
};
|
||
|
||
const parseJsonSafe = async (response) => {
|
||
try {
|
||
return await response.json();
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const apiJson = useCallback(async (url, options, fallbackMessage) => {
|
||
const response = await fetch(url, options || undefined);
|
||
const data = await parseJsonSafe(response);
|
||
if (response.status === 401 || response.status === 403) {
|
||
window.location.href = "/";
|
||
throw new Error("Нет доступа");
|
||
}
|
||
if (!response.ok) throw new Error(apiError(data, fallbackMessage || "Ошибка запроса"));
|
||
return data;
|
||
}, []);
|
||
|
||
const buildStorageUploadError = useCallback(async (response, fallbackMessage) => {
|
||
const base = String(fallbackMessage || "Ошибка передачи файла в хранилище");
|
||
const status = Number(response?.status || 0);
|
||
const statusText = String(response?.statusText || "").trim();
|
||
let details = "";
|
||
try {
|
||
details = String((await response.text()) || "").replace(/\s+/g, " ").trim();
|
||
} catch (_) {
|
||
details = "";
|
||
}
|
||
if (details.length > 180) details = details.slice(0, 180) + "...";
|
||
const parts = [];
|
||
if (status > 0) parts.push("HTTP " + status + (statusText ? " " + statusText : ""));
|
||
if (details) parts.push(details);
|
||
return parts.length ? base + " (" + parts.join("; ") + ")" : base;
|
||
}, []);
|
||
|
||
const uploadPublicRequestAttachment = useCallback(async (file, extra = {}) => {
|
||
const requestId = String(requestModal.requestId || "").trim();
|
||
if (!requestId) throw new Error("Не выбрана заявка");
|
||
const mimeType = String(file?.type || "application/octet-stream");
|
||
const initData = await apiJson(
|
||
"/api/public/uploads/init",
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
file_name: file.name,
|
||
mime_type: mimeType,
|
||
size_bytes: file.size,
|
||
scope: "REQUEST_ATTACHMENT",
|
||
request_id: requestId,
|
||
}),
|
||
},
|
||
"Не удалось начать загрузку файла"
|
||
);
|
||
const putResponse = await fetch(initData.presigned_url, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": mimeType },
|
||
body: file,
|
||
});
|
||
if (!putResponse.ok) throw new Error(await buildStorageUploadError(putResponse, "Ошибка передачи файла в хранилище"));
|
||
const completeData = await apiJson(
|
||
"/api/public/uploads/complete",
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
key: initData.key,
|
||
file_name: file.name,
|
||
mime_type: mimeType,
|
||
size_bytes: file.size,
|
||
scope: "REQUEST_ATTACHMENT",
|
||
request_id: requestId,
|
||
message_id: extra?.message_id || null,
|
||
}),
|
||
},
|
||
"Не удалось завершить загрузку файла"
|
||
);
|
||
return completeData;
|
||
}, [apiJson, buildStorageUploadError, requestModal.requestId]);
|
||
|
||
const loadRequestWorkspace = useCallback(
|
||
async (trackNumber, showLoading) => {
|
||
const track = String(trackNumber || "").trim().toUpperCase();
|
||
if (!track) return;
|
||
if (showLoading) {
|
||
setRequestModal((prev) => ({ ...prev, loading: true }));
|
||
}
|
||
const [requestData, messagesData, attachmentsData, invoicesData, statusRouteData, serviceRequestsData] = await Promise.all([
|
||
apiJson("/api/public/requests/" + encodeURIComponent(track), null, "Не удалось открыть заявку"),
|
||
apiJson("/api/public/chat/requests/" + encodeURIComponent(track) + "/messages", null, "Не удалось загрузить сообщения"),
|
||
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/attachments", null, "Не удалось загрузить файлы"),
|
||
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/invoices", null, "Не удалось загрузить счета"),
|
||
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/status-route", null, "Не удалось загрузить маршрут статусов"),
|
||
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/service-requests", null, "Не удалось загрузить обращения"),
|
||
]);
|
||
|
||
const invoices = Array.isArray(invoicesData) ? invoicesData : [];
|
||
const paidInvoices = invoices.filter((item) => String(item?.status || "").toUpperCase() === "PAID");
|
||
const paidTotal = paidInvoices.reduce((acc, item) => {
|
||
const amount = Number(item?.amount || 0);
|
||
return Number.isFinite(amount) ? acc + amount : acc;
|
||
}, 0);
|
||
const lastPaidAt = paidInvoices.reduce((latest, item) => {
|
||
const raw = String(item?.paid_at || "").trim();
|
||
if (!raw) return latest;
|
||
if (!latest) return raw;
|
||
const currentTs = new Date(raw).getTime();
|
||
const latestTs = new Date(latest).getTime();
|
||
return Number.isFinite(currentTs) && currentTs > latestTs ? raw : latest;
|
||
}, "");
|
||
|
||
setActiveTrack(track);
|
||
setServiceRequests(Array.isArray(serviceRequestsData) ? serviceRequestsData : []);
|
||
setRequestModal((prev) => ({
|
||
...prev,
|
||
loading: false,
|
||
requestId: String(requestData?.id || ""),
|
||
trackNumber: String(requestData?.track_number || track),
|
||
requestData: requestData || null,
|
||
financeSummary: {
|
||
request_cost: requestData?.request_cost ?? null,
|
||
effective_rate: requestData?.effective_rate ?? null,
|
||
paid_total: Math.round((paidTotal + Number.EPSILON) * 100) / 100,
|
||
last_paid_at: lastPaidAt || requestData?.paid_at || null,
|
||
},
|
||
statusRouteNodes: Array.isArray(statusRouteData?.nodes) ? statusRouteData.nodes : [],
|
||
statusHistory: Array.isArray(statusRouteData?.history) ? statusRouteData.history : [],
|
||
availableStatuses: [],
|
||
currentImportantDateAt: String(statusRouteData?.current_important_date_at || requestData?.important_date_at || ""),
|
||
messages: Array.isArray(messagesData) ? messagesData : [],
|
||
attachments: Array.isArray(attachmentsData) ? attachmentsData : [],
|
||
fileUploading: false,
|
||
}));
|
||
},
|
||
[apiJson]
|
||
);
|
||
|
||
const loadMyRequests = useCallback(
|
||
async (preferredTrack) => {
|
||
const data = await apiJson("/api/public/requests/my", null, "Не удалось загрузить список заявок");
|
||
const rows = Array.isArray(data?.rows) ? data.rows : [];
|
||
setRequestsList(rows);
|
||
if (!rows.length) {
|
||
setRequestModal((prev) => ({
|
||
...prev,
|
||
loading: false,
|
||
requestId: null,
|
||
requestData: null,
|
||
trackNumber: "",
|
||
financeSummary: null,
|
||
statusRouteNodes: [],
|
||
statusHistory: [],
|
||
messages: [],
|
||
attachments: [],
|
||
fileUploading: false,
|
||
selectedFiles: [],
|
||
messageDraft: "",
|
||
}));
|
||
setServiceRequests([]);
|
||
setPageStatus("По вашему номеру пока нет заявок.", "");
|
||
return;
|
||
}
|
||
const tracks = rows.map((row) => String(row.track_number || "").trim()).filter(Boolean);
|
||
const selected = tracks.includes(String(preferredTrack || "").trim().toUpperCase())
|
||
? String(preferredTrack || "").trim().toUpperCase()
|
||
: tracks[0];
|
||
await loadRequestWorkspace(selected, true);
|
||
setPageStatus("Открыта заявка: " + selected, "ok");
|
||
},
|
||
[apiJson, loadRequestWorkspace, setPageStatus]
|
||
);
|
||
|
||
const updateMessageDraft = useCallback((event) => {
|
||
const value = event?.target?.value || "";
|
||
setRequestModal((prev) => ({ ...prev, messageDraft: value }));
|
||
}, []);
|
||
|
||
const appendFiles = useCallback((files) => {
|
||
const list = Array.isArray(files) ? files.filter(Boolean) : [];
|
||
if (!list.length) return;
|
||
setRequestModal((prev) => {
|
||
const existing = Array.isArray(prev.selectedFiles) ? prev.selectedFiles : [];
|
||
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, selectedFiles: next };
|
||
});
|
||
}, []);
|
||
|
||
const removeFile = useCallback((index) => {
|
||
setRequestModal((prev) => {
|
||
const files = Array.isArray(prev.selectedFiles) ? [...prev.selectedFiles] : [];
|
||
files.splice(index, 1);
|
||
return { ...prev, selectedFiles: files };
|
||
});
|
||
}, []);
|
||
|
||
const clearFiles = useCallback(() => {
|
||
setRequestModal((prev) => ({ ...prev, selectedFiles: [] }));
|
||
}, []);
|
||
|
||
const submitMessage = useCallback(
|
||
async (event) => {
|
||
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
||
const track = String(activeTrack || "").trim();
|
||
const requestId = String(requestModal.requestId || "").trim();
|
||
if (!track || !requestId) {
|
||
setPageStatus("Сначала выберите заявку.", "error");
|
||
return;
|
||
}
|
||
const body = String(requestModal.messageDraft || "").trim();
|
||
const files = Array.isArray(requestModal.selectedFiles) ? requestModal.selectedFiles : [];
|
||
if (!body && !files.length) return;
|
||
try {
|
||
setRequestModal((prev) => ({ ...prev, fileUploading: true }));
|
||
setPageStatus(files.length ? "Отправка сообщения и файлов..." : "Отправка сообщения...", "");
|
||
|
||
let messageId = null;
|
||
if (body) {
|
||
const messageData = await apiJson(
|
||
"/api/public/chat/requests/" + encodeURIComponent(track) + "/messages",
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ body }),
|
||
},
|
||
"Не удалось отправить сообщение"
|
||
);
|
||
messageId = String(messageData?.id || "").trim() || null;
|
||
}
|
||
for (const file of files) {
|
||
await uploadPublicRequestAttachment(file, { message_id: messageId });
|
||
}
|
||
setRequestModal((prev) => ({ ...prev, messageDraft: "", selectedFiles: [], fileUploading: false }));
|
||
await loadRequestWorkspace(track, false);
|
||
if (body && files.length) setPageStatus("Сообщение и файлы отправлены.", "ok");
|
||
else if (files.length) setPageStatus(files.length === 1 ? "Файл загружен." : "Файлы загружены.", "ok");
|
||
else setPageStatus("Сообщение отправлено.", "ok");
|
||
} catch (error) {
|
||
setRequestModal((prev) => ({ ...prev, fileUploading: false }));
|
||
setPageStatus(error?.message || "Ошибка отправки сообщения", "error");
|
||
}
|
||
},
|
||
[activeTrack, apiJson, loadRequestWorkspace, requestModal.messageDraft, requestModal.requestId, requestModal.selectedFiles, setPageStatus, uploadPublicRequestAttachment]
|
||
);
|
||
|
||
const loadRequestDataBatch = useCallback(
|
||
async (messageId) => {
|
||
const track = String(activeTrack || "").trim();
|
||
if (!track || !messageId) throw new Error("Не выбрана заявка");
|
||
return apiJson(
|
||
"/api/public/chat/requests/" + encodeURIComponent(track) + "/data-requests/" + encodeURIComponent(String(messageId)),
|
||
null,
|
||
"Не удалось открыть запрос данных"
|
||
);
|
||
},
|
||
[activeTrack, apiJson]
|
||
);
|
||
|
||
const saveRequestDataValues = useCallback(
|
||
async ({ message_id, items }) => {
|
||
const track = String(activeTrack || "").trim();
|
||
const messageId = String(message_id || "").trim();
|
||
if (!track || !messageId) throw new Error("Не выбрана заявка");
|
||
await apiJson(
|
||
"/api/public/chat/requests/" + encodeURIComponent(track) + "/data-requests/" + encodeURIComponent(messageId),
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ items: Array.isArray(items) ? items : [] }),
|
||
},
|
||
"Не удалось сохранить данные"
|
||
);
|
||
await loadRequestWorkspace(track, false);
|
||
},
|
||
[activeTrack, apiJson, loadRequestWorkspace]
|
||
);
|
||
|
||
const probeLiveState = useCallback(
|
||
async ({ cursor } = {}) => {
|
||
const track = String(activeTrack || "").trim();
|
||
if (!track) return { has_updates: false, typing: [], cursor: null };
|
||
const query = cursor ? "?cursor=" + encodeURIComponent(String(cursor)) : "";
|
||
const payload = await apiJson(
|
||
"/api/public/chat/requests/" + encodeURIComponent(track) + "/live" + query,
|
||
null,
|
||
"Не удалось получить live-обновления чата"
|
||
);
|
||
if (payload && payload.has_updates) {
|
||
await loadRequestWorkspace(track, false);
|
||
}
|
||
return payload || { has_updates: false, typing: [], cursor: null };
|
||
},
|
||
[activeTrack, apiJson, loadRequestWorkspace]
|
||
);
|
||
|
||
const setTypingSignal = useCallback(
|
||
async ({ typing } = {}) => {
|
||
const track = String(activeTrack || "").trim();
|
||
if (!track) return { status: "skipped", typing: false };
|
||
return apiJson(
|
||
"/api/public/chat/requests/" + encodeURIComponent(track) + "/typing",
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ typing: Boolean(typing) }),
|
||
},
|
||
"Не удалось обновить статус набора"
|
||
);
|
||
},
|
||
[activeTrack, apiJson]
|
||
);
|
||
|
||
const openClientHelpModal = useCallback(() => {
|
||
setClientHelpModal((prev) => ({
|
||
...prev,
|
||
open: true,
|
||
status: { message: "", kind: "" },
|
||
}));
|
||
}, []);
|
||
|
||
const closeClientHelpModal = useCallback(() => {
|
||
setClientHelpModal((prev) => ({
|
||
...prev,
|
||
open: false,
|
||
loadingType: "",
|
||
status: { message: "", kind: "" },
|
||
}));
|
||
}, []);
|
||
|
||
const normalizedCurrentLawyerId = String(requestModal.requestData?.assigned_lawyer_id || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
|
||
const serviceRequestState = useMemo(() => {
|
||
const rows = Array.isArray(serviceRequests) ? serviceRequests : [];
|
||
const curatorRows = rows.filter((item) => String(item?.type || "").toUpperCase() === "CURATOR_CONTACT");
|
||
const lawyerRows = rows.filter((item) => String(item?.type || "").toUpperCase() === "LAWYER_CHANGE_REQUEST");
|
||
const latestLawyerChange = lawyerRows[0] || null;
|
||
const hasCuratorRequest = curatorRows.length > 0;
|
||
|
||
let lawyerChangeDisabledByState = false;
|
||
if (latestLawyerChange) {
|
||
const hasLawyerSnapshot = Object.prototype.hasOwnProperty.call(latestLawyerChange, "assigned_lawyer_id");
|
||
if (hasLawyerSnapshot) {
|
||
const requestedForLawyer = String(latestLawyerChange?.assigned_lawyer_id || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
lawyerChangeDisabledByState = requestedForLawyer === normalizedCurrentLawyerId;
|
||
} else {
|
||
const statusCode = String(latestLawyerChange?.status || "").toUpperCase();
|
||
lawyerChangeDisabledByState = statusCode === "NEW" || statusCode === "IN_PROGRESS";
|
||
}
|
||
}
|
||
|
||
return {
|
||
hasCuratorRequest,
|
||
lawyerChangeDisabledByState,
|
||
};
|
||
}, [normalizedCurrentLawyerId, serviceRequests]);
|
||
|
||
const canInteract = Boolean(requestModal.requestData && !requestModal.loading);
|
||
const curatorBlocked = !canInteract || serviceRequestState.hasCuratorRequest;
|
||
const lawyerChangeBlocked = !canInteract || serviceRequestState.lawyerChangeDisabledByState;
|
||
|
||
const submitServiceRequest = useCallback(
|
||
async ({ type, body }) => {
|
||
const track = String(activeTrack || "").trim();
|
||
const requestType = String(type || "").trim().toUpperCase();
|
||
const message = String(body || "").trim();
|
||
if (!track) {
|
||
setClientHelpModal((prev) => ({ ...prev, status: { message: "Сначала выберите заявку.", kind: "error" } }));
|
||
return;
|
||
}
|
||
if (!requestType) {
|
||
setClientHelpModal((prev) => ({ ...prev, status: { message: "Не указан тип запроса.", kind: "error" } }));
|
||
return;
|
||
}
|
||
if (message.length < 3) {
|
||
setClientHelpModal((prev) => ({ ...prev, status: { message: "Сообщение должно содержать минимум 3 символа.", kind: "error" } }));
|
||
return;
|
||
}
|
||
try {
|
||
setClientHelpModal((prev) => ({ ...prev, loadingType: requestType, status: { message: "", kind: "" } }));
|
||
await apiJson(
|
||
"/api/public/requests/" + encodeURIComponent(track) + "/service-requests",
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ type: requestType, body: message }),
|
||
},
|
||
"Не удалось отправить обращение"
|
||
);
|
||
await loadRequestWorkspace(track, false);
|
||
setPageStatus("Обращение отправлено.", "ok");
|
||
setClientHelpModal((prev) => ({
|
||
...prev,
|
||
loadingType: "",
|
||
status: { message: "Обращение отправлено.", kind: "ok" },
|
||
lawyerChangeReason: requestType === "LAWYER_CHANGE_REQUEST" ? "" : prev.lawyerChangeReason,
|
||
}));
|
||
} catch (error) {
|
||
setClientHelpModal((prev) => ({
|
||
...prev,
|
||
loadingType: "",
|
||
status: { message: error?.message || "Не удалось отправить обращение", kind: "error" },
|
||
}));
|
||
}
|
||
},
|
||
[activeTrack, apiJson, loadRequestWorkspace, setPageStatus]
|
||
);
|
||
|
||
useEffect(() => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const preferredTrack = String(params.get("track") || "").trim().toUpperCase();
|
||
void loadMyRequests(preferredTrack).catch((error) => {
|
||
setPageStatus(error?.message || "Не удалось открыть страницу клиента", "error");
|
||
});
|
||
}, [loadMyRequests, setPageStatus]);
|
||
|
||
const summary = requestModal.requestData || null;
|
||
|
||
return (
|
||
<div className="client-page-shell">
|
||
<main className="main client-main">
|
||
<div className="topbar client-topbar">
|
||
<div>
|
||
<div className="client-title-row">
|
||
<img className="brand-mark" src="/brand-mark.svg" alt="" width="24" height="24" />
|
||
<h1>Кабинет клиента</h1>
|
||
</div>
|
||
<p className="muted">Работа с заявками: статусы, чат, файлы и обращения.</p>
|
||
</div>
|
||
<button
|
||
className="icon-btn workspace-head-icon"
|
||
id="cabinet-help-open"
|
||
type="button"
|
||
data-tooltip="Помощь и обращения"
|
||
aria-label="Помощь и обращения"
|
||
disabled={!canInteract}
|
||
onClick={openClientHelpModal}
|
||
>
|
||
?
|
||
</button>
|
||
</div>
|
||
|
||
<section className="section active client-section">
|
||
<div className="section-head">
|
||
<div>
|
||
<h2>Мои заявки</h2>
|
||
</div>
|
||
</div>
|
||
<div className="client-request-toolbar">
|
||
<div className="field grow">
|
||
<label htmlFor="client-request-select">Номер заявки</label>
|
||
<select
|
||
id="client-request-select"
|
||
value={activeTrack}
|
||
onChange={(event) => {
|
||
const track = String(event.target.value || "").trim();
|
||
if (!track) return;
|
||
void loadRequestWorkspace(track, true)
|
||
.then(() => setPageStatus("Открыта заявка: " + track, "ok"))
|
||
.catch((error) => setPageStatus(error?.message || "Не удалось открыть заявку", "error"));
|
||
}}
|
||
disabled={requestModal.loading || !requestsList.length}
|
||
>
|
||
{requestsList.map((row) => (
|
||
<option value={String(row.track_number || "")} key={String(row.id || row.track_number || "")}>
|
||
{String(row.track_number || "Без номера") +
|
||
" • " +
|
||
String(row.status_code || "-") +
|
||
(Number(row?.viewer_unread_total || 0) > 0 ? " • непрочитано: " + String(row.viewer_unread_total) : "")}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<button
|
||
className="btn secondary"
|
||
id="client-refresh"
|
||
type="button"
|
||
onClick={() => {
|
||
void loadMyRequests(activeTrack).catch((error) => setPageStatus(error?.message || "Не удалось обновить список", "error"));
|
||
}}
|
||
>
|
||
Обновить
|
||
</button>
|
||
</div>
|
||
|
||
<div className="client-summary" id="cabinet-summary" hidden={!summary}>
|
||
<div className="client-summary-row">
|
||
<div className="client-summary-chips">
|
||
<span className="client-summary-chip client-summary-chip-status">
|
||
Статус: <span id="cabinet-request-status">{summary ? statusLabel(summary.status_code) : "-"}</span>
|
||
</span>
|
||
<span className="client-summary-chip client-summary-chip-topic">
|
||
Тема: <span id="cabinet-request-topic">{summary ? String(summary.topic_name || summary.topic_code || "-") : "-"}</span>
|
||
</span>
|
||
</div>
|
||
<div className="client-summary-dates">
|
||
<span>
|
||
Создана: <b id="cabinet-request-created">{summary ? fmtShortDateTime(summary.created_at) : "-"}</b>
|
||
</span>
|
||
<span>
|
||
Обновлена: <b id="cabinet-request-updated">{summary ? fmtShortDateTime(summary.updated_at) : "-"}</b>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<RequestWorkspace
|
||
viewerRole="CLIENT"
|
||
viewerUserId=""
|
||
loading={requestModal.loading}
|
||
trackNumber={requestModal.trackNumber}
|
||
requestData={requestModal.requestData}
|
||
financeSummary={requestModal.financeSummary}
|
||
statusRouteNodes={requestModal.statusRouteNodes || []}
|
||
statusHistory={requestModal.statusHistory || []}
|
||
availableStatuses={[]}
|
||
currentImportantDateAt={requestModal.currentImportantDateAt || ""}
|
||
pendingStatusChangePreset={null}
|
||
messages={requestModal.messages || []}
|
||
attachments={requestModal.attachments || []}
|
||
messageDraft={requestModal.messageDraft || ""}
|
||
selectedFiles={requestModal.selectedFiles || []}
|
||
fileUploading={Boolean(requestModal.fileUploading)}
|
||
status={status}
|
||
onMessageChange={updateMessageDraft}
|
||
onSendMessage={submitMessage}
|
||
onFilesSelect={appendFiles}
|
||
onRemoveSelectedFile={removeFile}
|
||
onClearSelectedFiles={clearFiles}
|
||
onLoadRequestDataBatch={loadRequestDataBatch}
|
||
onSaveRequestDataValues={saveRequestDataValues}
|
||
onUploadRequestAttachment={uploadPublicRequestAttachment}
|
||
onChangeStatus={() => Promise.resolve(null)}
|
||
onLiveProbe={probeLiveState}
|
||
onTypingSignal={setTypingSignal}
|
||
AttachmentPreviewModalComponent={AttachmentPreviewModal}
|
||
StatusLineComponent={StatusLine}
|
||
domIds={{
|
||
messagesList: "cabinet-messages",
|
||
filesList: "cabinet-files",
|
||
messageBody: "cabinet-chat-body",
|
||
sendButton: "cabinet-chat-send",
|
||
fileInput: "cabinet-file-input",
|
||
dataRequestOverlay: "data-request-overlay",
|
||
dataRequestItems: "data-request-items",
|
||
dataRequestStatus: "data-request-status",
|
||
dataRequestSave: "data-request-save",
|
||
}}
|
||
/>
|
||
</section>
|
||
<p className="status" id="client-page-status">{status.message}</p>
|
||
</main>
|
||
<ClientHelpModal
|
||
open={clientHelpModal.open}
|
||
status={clientHelpModal.status}
|
||
loadingType={clientHelpModal.loadingType}
|
||
lawyerChangeReason={clientHelpModal.lawyerChangeReason}
|
||
curatorBlocked={curatorBlocked}
|
||
lawyerChangeBlocked={lawyerChangeBlocked}
|
||
onClose={closeClientHelpModal}
|
||
onReasonChange={(event) =>
|
||
setClientHelpModal((prev) => ({
|
||
...prev,
|
||
lawyerChangeReason: event.target.value,
|
||
status: { message: "", kind: "" },
|
||
}))
|
||
}
|
||
onSubmitCurator={() => submitServiceRequest({ type: "CURATOR_CONTACT", body: "Прошу подключить куратора к текущей заявке." })}
|
||
onSubmitLawyerChange={() => submitServiceRequest({ type: "LAWYER_CHANGE_REQUEST", body: clientHelpModal.lawyerChangeReason })}
|
||
/>
|
||
<GlobalTooltipLayer />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const root = document.getElementById("client-root");
|
||
if (root) {
|
||
ReactDOM.createRoot(root).render(<App />);
|
||
}
|
||
})();
|