- Создание и редактирование цитат. + {trackNumber ? "Заявка: " + trackNumber : "Выберите нового юриста"}
Выберите справочник слева, таблица откроется справа.
+Выберите справочник в дереве слева.
{row.field_key || "-"}
+ {row.key || "-"}
+ Введите номер заявки, подтвердите доступ по OTP и отслеживайте статус, переписку и файлы в одном окне.
+Если вы пришли на сайт по рекомендации, укажите имя рекомендателя при отправке заявки. @@ -747,6 +917,25 @@ const status = document.getElementById("form-status"); const quoteText = document.getElementById("quote-text"); const quoteMeta = document.getElementById("quote-meta"); + const cabinetTrackInput = document.getElementById("cabinet-track"); + const cabinetOpenButton = document.getElementById("cabinet-open"); + const cabinetStatus = document.getElementById("cabinet-status"); + const cabinetSummary = document.getElementById("cabinet-summary"); + const cabinetRequestStatus = document.getElementById("cabinet-request-status"); + const cabinetRequestTopic = document.getElementById("cabinet-request-topic"); + const cabinetRequestCreated = document.getElementById("cabinet-request-created"); + const cabinetRequestUpdated = document.getElementById("cabinet-request-updated"); + const cabinetMessages = document.getElementById("cabinet-messages"); + const cabinetFiles = document.getElementById("cabinet-files"); + const cabinetTimeline = document.getElementById("cabinet-timeline"); + const cabinetChatForm = document.getElementById("cabinet-chat-form"); + const cabinetChatBody = document.getElementById("cabinet-chat-body"); + const cabinetChatSend = document.getElementById("cabinet-chat-send"); + const cabinetFileInput = document.getElementById("cabinet-file-input"); + const cabinetFileUpload = document.getElementById("cabinet-file-upload"); + + let activeTrack = ""; + let activeRequestId = ""; function openModal() { modal.classList.add("open"); @@ -771,6 +960,136 @@ if (event.key === "Escape" && modal.classList.contains("open")) closeModal(); }); + function formatDate(value) { + if (!value) return "-"; + try { + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) return value; + return dt.toLocaleString("ru-RU"); + } catch (_) { + return value; + } + } + + function setStatus(el, message, kind) { + el.className = "status"; + if (kind === "ok") el.classList.add("ok"); + if (kind === "error") el.classList.add("error"); + el.textContent = message; + } + + async function parseJsonSafe(response) { + try { + return await response.json(); + } catch (_) { + return null; + } + } + + function apiErrorDetail(data, fallbackMessage) { + if (data && typeof data.detail === "string" && data.detail.trim()) return data.detail; + return fallbackMessage; + } + + function setCabinetEnabled(enabled) { + cabinetChatBody.disabled = !enabled; + cabinetChatSend.disabled = !enabled; + cabinetFileInput.disabled = !enabled; + cabinetFileUpload.disabled = !enabled; + } + + function clearList(node, emptyMessage) { + node.innerHTML = ""; + const li = document.createElement("li"); + li.className = "simple-item"; + const p = document.createElement("p"); + p.textContent = emptyMessage; + li.appendChild(p); + node.appendChild(li); + } + + function renderMessages(items) { + cabinetMessages.innerHTML = ""; + if (!Array.isArray(items) || items.length === 0) { + clearList(cabinetMessages, "Сообщений пока нет."); + return; + } + items.forEach((item) => { + const li = document.createElement("li"); + li.className = "simple-item"; + + const time = document.createElement("time"); + time.textContent = formatDate(item.created_at); + li.appendChild(time); + + const p = document.createElement("p"); + const author = item.author_name || item.author_type || "Участник"; + p.textContent = author + ": " + (item.body || ""); + li.appendChild(p); + cabinetMessages.appendChild(li); + }); + } + + function renderFiles(items) { + cabinetFiles.innerHTML = ""; + if (!Array.isArray(items) || items.length === 0) { + clearList(cabinetFiles, "Файлы пока не загружены."); + return; + } + items.forEach((item) => { + const li = document.createElement("li"); + li.className = "simple-item"; + + const time = document.createElement("time"); + time.textContent = formatDate(item.created_at); + li.appendChild(time); + + const p = document.createElement("p"); + const sizeKb = Math.max(1, Math.round(Number(item.size_bytes || 0) / 1024)); + p.textContent = item.file_name + " (" + sizeKb + " КБ)"; + li.appendChild(p); + + const link = document.createElement("a"); + link.href = item.download_url; + link.textContent = "Открыть / скачать"; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.style.color = "#f6d7a8"; + li.appendChild(link); + cabinetFiles.appendChild(li); + }); + } + + function renderTimeline(items) { + cabinetTimeline.innerHTML = ""; + if (!Array.isArray(items) || items.length === 0) { + clearList(cabinetTimeline, "История пока пуста."); + return; + } + items.forEach((item) => { + const li = document.createElement("li"); + li.className = "simple-item"; + + const time = document.createElement("time"); + time.textContent = formatDate(item.created_at); + li.appendChild(time); + + const p = document.createElement("p"); + if (item.type === "status_change") { + p.textContent = "Статус: " + (item.payload?.from_status || "NEW") + " -> " + (item.payload?.to_status || "-"); + } else if (item.type === "message") { + const author = item.payload?.author_name || item.payload?.author_type || "Участник"; + p.textContent = "Сообщение от " + author + ": " + (item.payload?.body || ""); + } else if (item.type === "attachment") { + p.textContent = "Файл: " + (item.payload?.file_name || "вложение"); + } else { + p.textContent = "Событие"; + } + li.appendChild(p); + cabinetTimeline.appendChild(li); + }); + } + async function loadQuotes() { try { const response = await fetch("/api/public/quotes?limit=8&order=random"); @@ -792,10 +1111,201 @@ } } + async function fetchRequestByTrack(trackNumber) { + const response = await fetch("/api/public/requests/" + encodeURIComponent(trackNumber)); + const data = await parseJsonSafe(response); + return { response, data }; + } + + async function ensureViewAccess(trackNumber) { + let { response, data } = await fetchRequestByTrack(trackNumber); + if (response.ok) return data; + + if (response.status !== 401 && response.status !== 403) { + throw new Error(apiErrorDetail(data, "Не удалось открыть заявку")); + } + + setStatus(cabinetStatus, "Отправляем OTP-код...", null); + const sendResponse = await fetch("/api/public/otp/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + purpose: "VIEW_REQUEST", + track_number: trackNumber + }) + }); + const sendData = await parseJsonSafe(sendResponse); + if (!sendResponse.ok) { + throw new Error(apiErrorDetail(sendData, "Не удалось отправить OTP")); + } + + const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):"); + if (!code) { + throw new Error("Код OTP не введен"); + } + + setStatus(cabinetStatus, "Проверяем OTP...", null); + const verifyResponse = await fetch("/api/public/otp/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + purpose: "VIEW_REQUEST", + track_number: trackNumber, + code: String(code).trim() + }) + }); + const verifyData = await parseJsonSafe(verifyResponse); + if (!verifyResponse.ok) { + throw new Error(apiErrorDetail(verifyData, "OTP не подтвержден")); + } + + ({ response, data } = await fetchRequestByTrack(trackNumber)); + if (!response.ok) { + throw new Error(apiErrorDetail(data, "Нет доступа к заявке")); + } + return data; + } + + async function refreshCabinetData() { + if (!activeTrack) return; + + const [messagesRes, filesRes, timelineRes] = await Promise.all([ + fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages"), + fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/attachments"), + fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline") + ]); + + const messagesData = await parseJsonSafe(messagesRes); + const filesData = await parseJsonSafe(filesRes); + const timelineData = await parseJsonSafe(timelineRes); + + if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения")); + if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы")); + if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю")); + + renderMessages(messagesData); + renderFiles(filesData); + renderTimeline(timelineData); + } + + async function openCabinetByTrack() { + const trackNumber = String(cabinetTrackInput.value || "").trim().toUpperCase(); + if (!trackNumber) { + setStatus(cabinetStatus, "Введите номер заявки.", "error"); + return; + } + + try { + setStatus(cabinetStatus, "Открываем кабинет...", null); + const requestData = await ensureViewAccess(trackNumber); + activeTrack = trackNumber; + activeRequestId = requestData.id; + + cabinetRequestStatus.textContent = requestData.status_code || "-"; + cabinetRequestTopic.textContent = requestData.topic_code || "Не указана"; + cabinetRequestCreated.textContent = formatDate(requestData.created_at); + cabinetRequestUpdated.textContent = formatDate(requestData.updated_at); + cabinetSummary.hidden = false; + setCabinetEnabled(true); + + await refreshCabinetData(); + setStatus(cabinetStatus, "Кабинет открыт: " + trackNumber, "ok"); + } catch (error) { + setStatus(cabinetStatus, error?.message || "Не удалось открыть кабинет", "error"); + } + } + + cabinetOpenButton.addEventListener("click", () => { + openCabinetByTrack(); + }); + + cabinetChatForm.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!activeTrack) { + setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error"); + return; + } + + const body = String(cabinetChatBody.value || "").trim(); + if (!body) return; + + try { + setStatus(cabinetStatus, "Отправляем сообщение...", null); + const response = await fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body }) + }); + const data = await parseJsonSafe(response); + if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить сообщение")); + cabinetChatBody.value = ""; + await refreshCabinetData(); + setStatus(cabinetStatus, "Сообщение отправлено.", "ok"); + } catch (error) { + setStatus(cabinetStatus, error?.message || "Ошибка отправки сообщения", "error"); + } + }); + + cabinetFileUpload.addEventListener("click", async () => { + if (!activeTrack || !activeRequestId) { + setStatus(cabinetStatus, "Сначала откройте кабинет по номеру заявки.", "error"); + return; + } + const file = cabinetFileInput.files && cabinetFileInput.files[0]; + if (!file) { + setStatus(cabinetStatus, "Выберите файл для загрузки.", "error"); + return; + } + + try { + setStatus(cabinetStatus, "Подготавливаем загрузку файла...", null); + const initResponse = await fetch("/api/public/uploads/init", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file_name: file.name, + mime_type: file.type || "application/octet-stream", + size_bytes: file.size, + scope: "REQUEST_ATTACHMENT", + request_id: activeRequestId + }) + }); + const initData = await parseJsonSafe(initResponse); + if (!initResponse.ok) throw new Error(apiErrorDetail(initData, "Не удалось начать загрузку")); + + const putResponse = await fetch(initData.presigned_url, { + method: "PUT", + headers: { "Content-Type": file.type || "application/octet-stream" }, + body: file + }); + if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище"); + + const completeResponse = await fetch("/api/public/uploads/complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key: initData.key, + file_name: file.name, + mime_type: file.type || "application/octet-stream", + size_bytes: file.size, + scope: "REQUEST_ATTACHMENT", + request_id: activeRequestId + }) + }); + const completeData = await parseJsonSafe(completeResponse); + if (!completeResponse.ok) throw new Error(apiErrorDetail(completeData, "Не удалось завершить загрузку")); + + cabinetFileInput.value = ""; + await refreshCabinetData(); + setStatus(cabinetStatus, "Файл загружен.", "ok"); + } catch (error) { + setStatus(cabinetStatus, error?.message || "Ошибка загрузки файла", "error"); + } + }); + form.addEventListener("submit", async (event) => { event.preventDefault(); - status.className = "status"; - status.textContent = "Отправляем заявку..."; + setStatus(status, "Отправляем заявку...", null); const payload = { client_name: document.getElementById("name").value.trim(), @@ -808,6 +1318,33 @@ }; try { + setStatus(status, "Отправляем OTP-код...", null); + const otpSend = await fetch("/api/public/otp/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + purpose: "CREATE_REQUEST", + client_phone: payload.client_phone + }) + }); + if (!otpSend.ok) throw new Error("otp send failed"); + + const code = window.prompt("Введите OTP-код из SMS (в dev-режиме смотрите backend console):"); + if (!code) throw new Error("otp code required"); + + setStatus(status, "Проверяем OTP...", null); + const otpVerify = await fetch("/api/public/otp/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + purpose: "CREATE_REQUEST", + client_phone: payload.client_phone, + code: String(code).trim() + }) + }); + if (!otpVerify.ok) throw new Error("otp verify failed"); + + setStatus(status, "Создаем заявку...", null); const response = await fetch("/api/public/requests", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -816,17 +1353,20 @@ if (!response.ok) throw new Error("create request failed"); const data = await response.json(); - status.className = "status ok"; - status.textContent = "Заявка принята. Номер: " + data.track_number; + setStatus(status, "Заявка принята. Номер: " + data.track_number, "ok"); + cabinetTrackInput.value = data.track_number; form.reset(); setTimeout(closeModal, 1200); } catch (error) { - status.className = "status error"; - status.textContent = "Не удалось отправить заявку. Повторите попытку позже."; + setStatus(status, "Не удалось отправить заявку. Повторите попытку позже.", "error"); } }); loadQuotes(); + setCabinetEnabled(false); + clearList(cabinetMessages, "Сообщений пока нет."); + clearList(cabinetFiles, "Файлы пока не загружены."); + clearList(cabinetTimeline, "История пока пуста."); })();