(function () { const root = document.getElementById("f7support-app"); if (!root) return; const username = root.dataset.username; const serverAddress = root.dataset.serverAddress; const apiBase = root.dataset.supportApiBase; const supportWsBaseOverride = (root.dataset.supportWsBase || "").trim(); /** Включается в NC: `occ config:app:set f7support client_read_receipts --value=1` после выката API `POST .../read`. */ const clientReadReceiptsEnabled = root.dataset.clientReadReceipts === "1"; const ticketsPollMsRaw = parseInt( String(root.dataset.ticketsPollMs || root.dataset.messagesPollMs || "3000").trim(), 10 ); const ticketsPollMsParsed = Number.isFinite(ticketsPollMsRaw) && ticketsPollMsRaw >= 0 ? ticketsPollMsRaw : 3000; /** Интервал опроса GET /api/client/tickets (мс). По умолчанию 3000 (3 с), если атрибут не задан. 0 — выключить. Иначе 3000…120000. */ const ticketsPollIntervalMs = ticketsPollMsParsed === 0 ? 0 : Math.min(120000, Math.max(3000, ticketsPollMsParsed)); const RASTER_IMAGE_EXT = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff", "heic", "heif"]); const RASTER_IMAGE_MIME = new Set([ "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "image/tiff", "image/x-tiff", "image/heic", "image/heif", ]); function isRasterImageAtt(a) { const m = (a.mime_type || "").toLowerCase().split(";")[0].trim(); if (RASTER_IMAGE_MIME.has(m)) return true; const parts = (a.filename || "").split("."); const ext = parts.length > 1 ? parts.pop().toLowerCase() : ""; return RASTER_IMAGE_EXT.has(ext); } function openOrDownloadBlob(blob, filename, mime) { const m = (mime || blob.type || "").toLowerCase().split(";")[0].trim(); const url = URL.createObjectURL(blob); const tryInline = RASTER_IMAGE_MIME.has(m) || m === "application/pdf" || (m.startsWith("text/") && m !== "text/html"); if (tryInline) { const w = window.open(url, "_blank", "noopener,noreferrer"); if (w) { setTimeout(() => URL.revokeObjectURL(url), 180000); return; } } const a = document.createElement("a"); a.href = url; a.download = filename || "file"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 120000); } /** Сервер без маршрута `POST .../read` (404) — не дёргать повторно, меньше шума в консоли. */ let skipClientReadReceiptPost = false; const state = { tickets: [], currentTicket: null, messages: [], chatModalOpen: false, blobUrls: [], ticketSocket: null, wsReconnectTimer: null, ticketsPollTimer: null, pendingFile: null, /** @type {Record} последний известный «отпечаток» карточки по номеру тикета (для точки без has_unread с API) */ ticketSeenFingerprint: {}, /** @type {Record} сигнатура последнего сообщения (id+время) по тикету — список тикетов часто не меняет preview/activity */ ticketLastMsgSig: Object.create(null), /** @type {Record} новое сообщение по сравнению с последним опросом /messages */ ticketUnreadByLastMsg: Object.create(null), /** Смещение пачки опросов /messages (не грузим все тикеты за один раз) */ msgSigPollIndex: 0, }; function clientIdentityHeaders() { return { "X-F7cloud-User": username, "X-F7cloud-Server": serverAddress, }; } function revokeAttachmentUrls() { for (const u of state.blobUrls) { try { URL.revokeObjectURL(u); } catch (_) {} } state.blobUrls = []; } function escapeHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function formatBytes(n) { const v = Number(n) || 0; if (v < 1024) return `${v} Б`; if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} КБ`; return `${(v / (1024 * 1024)).toFixed(1)} МБ`; } function formatChatTime(iso) { try { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", hour12: false }); } catch (_) { return ""; } } function formatTicketCardTime(iso) { try { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; const day = d.getDate(); const month = d.toLocaleString("ru-RU", { month: "long" }); const hm = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", hour12: false }); return `${day} ${month}, ${hm}`; } catch (_) { return ""; } } function fileExtBadge(filename) { let ext = (filename || "").split(".").pop() || ""; ext = ext.toLowerCase().replace(/[^a-z0-9]/g, ""); if (!ext) ext = "file"; const label = ext.length <= 5 ? ext : ext.slice(0, 4) + "…"; const slug = ext.replace(/[^a-z0-9]/g, "").slice(0, 12) || "file"; return { label, slug }; } function nonImageAttachmentHtml(m, a) { const outgoing = m.author_role === "client"; const wrapCls = outgoing ? "chat-file-bubble-wrap chat-file-bubble-wrap--out" : "chat-file-bubble-wrap chat-file-bubble-wrap--in"; const bubbleCls = outgoing ? "chat-file-bubble chat-file-bubble--out" : "chat-file-bubble chat-file-bubble--in"; const { label, slug } = fileExtBadge(a.filename); const time = formatChatTime(m.created_at); const ticks = outgoing ? '' : ""; return `
${escapeHtml(label)}
${escapeHtml(a.filename)}
${formatBytes(a.size_bytes)}
${escapeHtml(time)} ${ticks}
`; } function wsUrlForTicket(ticketNumber) { const tail = `/tickets/${encodeURIComponent(ticketNumber)}`; if (supportWsBaseOverride) { try { const raw = supportWsBaseOverride.replace(/\/$/, ""); const u = new URL(raw.includes("://") ? raw : `wss://${raw}`); const wsProto = u.protocol === "https:" ? "wss:" : u.protocol === "http:" ? "ws:" : u.protocol; let path = (u.pathname || "").replace(/\/$/, ""); if (path === "" || path === "/") { path = "/ws"; } return `${wsProto}//${u.host}${path}${tail}`; } catch (_) { return null; } } try { const u = new URL(apiBase); const wsProto = u.protocol === "https:" ? "wss:" : "ws:"; return `${wsProto}//${u.host}/ws${tail}`; } catch (_) { return null; } } function disconnectTicketSocket() { if (state.wsReconnectTimer) { clearTimeout(state.wsReconnectTimer); state.wsReconnectTimer = null; } if (state.ticketSocket) { try { state.ticketSocket.close(); } catch (_) {} state.ticketSocket = null; } } function connectTicketSocket(ticketNumber) { disconnectTicketSocket(); const url = wsUrlForTicket(ticketNumber); if (!url) return; let socket; try { socket = new WebSocket(url); } catch (_) { return; } state.ticketSocket = socket; socket.onmessage = (event) => { try { const payload = JSON.parse(event.data); if (!payload || !payload.data) return; if (!state.chatModalOpen || state.currentTicket !== ticketNumber) return; if (payload.event === "new_message" || payload.event === "message_updated") { applyIncomingMessage(payload.data); fetchTickets().catch(() => {}); } } catch (_) {} }; socket.onclose = () => { state.ticketSocket = null; if (state.chatModalOpen && state.currentTicket === ticketNumber) { state.wsReconnectTimer = setTimeout(() => connectTicketSocket(ticketNumber), 2500); } }; socket.onerror = () => { try { socket.close(); } catch (_) {} }; } function messageBlockHtml(m) { const atts = m.attachments || []; const attHtml = atts .map((a) => { if (isRasterImageAtt(a)) { return `
Загрузка…
`; } return nonImageAttachmentHtml(m, a); }) .join(""); return `
${escapeHtml(m.author)} (${escapeHtml(m.author_role)}): ${escapeHtml(m.text)}
${attHtml ? `
${attHtml}
` : ""}
`; } function applyIncomingMessage(msg) { const id = msg.id; const idx = state.messages.findIndex((m) => m.id === id); if (idx === -1) state.messages.push(msg); else state.messages[idx] = msg; state.messages.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at))); const el = messageList.querySelector(`[data-f7-msg-id="${id}"]`); const html = messageBlockHtml(msg); if (el) { el.outerHTML = html; } else { messageList.insertAdjacentHTML("beforeend", html); } const node = messageList.querySelector(`[data-f7-msg-id="${id}"]`); if (node) hydrateClientAttachments(node); scrollChatToBottom(); syncCurrentTicketLastMsgSigFromState(); if (clientReadReceiptsEnabled && msg.author_role === "support" && state.currentTicket) { markClientTicketRead(state.currentTicket).catch(() => {}); } } async function markClientTicketRead(ticketNumber) { if (!clientReadReceiptsEnabled || skipClientReadReceiptPost || !ticketNumber) return; let res; try { res = await fetch(`${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/read`, { method: "POST", headers: clientIdentityHeaders(), }); } catch { return; } if (res.status === 404) { skipClientReadReceiptPost = true; return; } if (!res.ok) return; const row = state.tickets.find((x) => x.ticket_number === ticketNumber); if (row) row.has_unread = false; delete state.ticketUnreadByLastMsg[String(ticketNumber)]; renderTickets(); reapplyActiveTicketCard(ticketNumber); } function reapplyActiveTicketCard(ticketNumber) { const tnSel = String(ticketNumber).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const card = document.querySelector(`.f7-ticket-card[data-ticket-number="${tnSel}"]`); if (card) card.classList.add("f7-ticket-card--active"); } root.innerHTML = `

f7support

Мои обращения

Новые

    В работе

      Архив

        `; const messageList = document.getElementById("message-list"); const chatModalTitle = document.getElementById("chat-modal-title"); const errorBox = document.getElementById("error-box"); const createModal = document.getElementById("create-ticket-modal"); const chatModal = document.getElementById("chat-modal"); let clientChatDndBound = false; bindClientChatDnDOnce(); function scrollChatToBottom() { const ml = document.getElementById("message-list"); if (!ml) return; const run = () => { try { ml.scrollTop = ml.scrollHeight; } catch (_) {} }; run(); requestAnimationFrame(() => { run(); requestAnimationFrame(run); }); window.setTimeout(run, 80); window.setTimeout(run, 320); } function lastMessageSignatureFromList(msgs) { if (!Array.isArray(msgs) || msgs.length === 0) return null; const last = msgs[msgs.length - 1]; return `${last.id ?? ""}\u0002${last.created_at ?? ""}`; } function syncCurrentTicketLastMsgSigFromState() { if (!state.currentTicket) return; const tn = String(state.currentTicket); const sig = lastMessageSignatureFromList(state.messages); if (sig == null) return; state.ticketLastMsgSig[tn] = sig; delete state.ticketUnreadByLastMsg[tn]; } async function refreshLastMsgSigOne(tn, opts) { const forceBaseline = opts && opts.forceBaseline === true; const key = String(tn); try { const r = await fetch(`${apiBase}/api/client/tickets/${encodeURIComponent(key)}/messages`, { headers: clientIdentityHeaders(), }); if (!r.ok) return; const msgs = await r.json(); const sig = lastMessageSignatureFromList(msgs); if (sig == null) return; const prev = state.ticketLastMsgSig[key]; state.ticketLastMsgSig[key] = sig; if (!forceBaseline && prev != null && sig !== prev) { state.ticketUnreadByLastMsg[key] = true; } } catch (_) {} } async function refreshLastMessageSignaturesStaggered() { const cur = state.chatModalOpen && state.currentTicket != null ? String(state.currentTicket) : null; const openTickets = state.tickets.filter((t) => { const tn = String(t.ticket_number); if (cur && tn === cur) return false; if (String(t.status || "") === "Закрыт") return false; return true; }); if (!openTickets.length) return; const BATCH = 6; const n = openTickets.length; const start = state.msgSigPollIndex % n; const chunk = []; for (let i = 0; i < Math.min(BATCH, n); i++) { chunk.push(String(openTickets[(start + i) % n].ticket_number)); } state.msgSigPollIndex = (state.msgSigPollIndex + chunk.length) % n; await Promise.all(chunk.map((tn) => refreshLastMsgSigOne(tn, null))); } function ticketBoardFingerprint(t) { const act = String(t.activity_at || t.updated_at || t.last_activity_at || t.created_at || ""); const prev = t.preview_text != null ? String(t.preview_text) : ""; const cnt = t.messages_count ?? t.message_count ?? t.msg_count ?? t.replies_count ?? t.reply_count ?? ""; return `${act}\u0001${prev}\u0001${cnt}`; } /** Для открытого чата учитываем последнее сообщение, чтобы отпечаток не отставал от превью в списке. */ function effectiveBoardFingerprint(t) { const base = ticketBoardFingerprint(t); const tn = String(t.ticket_number); if (state.chatModalOpen && state.currentTicket != null && String(state.currentTicket) === tn) { const msgs = state.messages; if (msgs && msgs.length) { const last = msgs[msgs.length - 1]; return `${base}\u0002${last.id ?? ""}\u0003${last.created_at ?? ""}`; } } return base; } function updateBoardUnreadFingerprints() { for (const t of state.tickets) { const tn = String(t.ticket_number); const fp = effectiveBoardFingerprint(t); const seenKey = tn; if (!Object.prototype.hasOwnProperty.call(state.ticketSeenFingerprint, seenKey)) { state.ticketSeenFingerprint[seenKey] = fp; t._f7_board_unread = false; continue; } if (state.chatModalOpen && state.currentTicket != null && String(state.currentTicket) === tn) { state.ticketSeenFingerprint[seenKey] = fp; t._f7_board_unread = false; continue; } t._f7_board_unread = fp !== state.ticketSeenFingerprint[seenKey]; } } function ticketHasUnread(ticket) { const tn = String(ticket.ticket_number); if (ticket && ticket._f7_board_unread) return true; if (state.ticketUnreadByLastMsg[tn]) return true; const raw = ticket?.has_unread ?? ticket?.hasUnread ?? ticket?.unread ?? ticket?.is_unread ?? ticket?.unread_count; if (typeof raw === "number" && raw > 0) return true; return raw === true || raw === 1 || raw === "1" || raw === "true" || raw === "yes"; } function scheduleTicketsBoardPolling() { if (state.ticketsPollTimer) { clearInterval(state.ticketsPollTimer); state.ticketsPollTimer = null; } if (ticketsPollIntervalMs === 0) return; state.ticketsPollTimer = window.setInterval(() => { if (document.visibilityState === "hidden") return; fetchTickets().catch(() => {}); }, ticketsPollIntervalMs); } function showError(message) { errorBox.textContent = message || ""; } function setModalVisible(el, visible) { el.classList.toggle("f7support-modal-visible", visible); el.setAttribute("aria-hidden", visible ? "false" : "true"); } function updatePendingFileUi() { const row = document.getElementById("pending-attachment-row"); const label = document.getElementById("pending-attachment-label"); if (!row || !label) return; if (state.pendingFile) { label.textContent = `Вложение: ${state.pendingFile.name}`; row.hidden = false; } else { row.hidden = true; label.textContent = ""; } } function setPendingFile(file) { if (!file) return; state.pendingFile = file; updatePendingFileUi(); } function clearPendingFile() { state.pendingFile = null; const inp = document.getElementById("attachment-input"); if (inp) inp.value = ""; updatePendingFileUi(); } function bindClientChatDnDOnce() { if (clientChatDndBound) return; clientChatDndBound = true; document.querySelectorAll(".f7support-dnd-target").forEach((el) => { el.addEventListener("dragover", (e) => { e.preventDefault(); e.stopPropagation(); el.classList.add("f7support-dnd-dragover"); }); el.addEventListener("dragleave", (e) => { e.preventDefault(); el.classList.remove("f7support-dnd-dragover"); }); el.addEventListener("drop", (e) => { e.preventDefault(); e.stopPropagation(); el.classList.remove("f7support-dnd-dragover"); const f = e.dataTransfer?.files?.[0]; if (f) setPendingFile(f); }); }); } function openChatModal(ticket) { state.chatModalOpen = true; chatModalTitle.textContent = `Чат #${ticket.ticket_number} — ${ticket.subject}`; setModalVisible(chatModal, true); } async function closeChatModal() { const closingTn = state.currentTicket; if (closingTn != null) { const row = state.tickets.find((x) => String(x.ticket_number) === String(closingTn)); if (row) state.ticketSeenFingerprint[String(closingTn)] = ticketBoardFingerprint(row); delete state.ticketUnreadByLastMsg[String(closingTn)]; await refreshLastMsgSigOne(String(closingTn), { forceBaseline: true }); } disconnectTicketSocket(); revokeAttachmentUrls(); state.chatModalOpen = false; state.currentTicket = null; state.messages = []; messageList.innerHTML = ""; const msgInput = document.getElementById("message-input"); if (msgInput) msgInput.value = ""; clearPendingFile(); setModalVisible(chatModal, false); showError(""); document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active")); await fetchTickets().catch(() => {}); } async function fetchTickets() { const response = await fetch(`${apiBase}/api/client/tickets`, { headers: { "X-F7cloud-User": username, "X-F7cloud-Server": serverAddress, }, }); if (!response.ok) throw new Error("Не удалось загрузить список обращений"); state.tickets = await response.json(); await refreshLastMessageSignaturesStaggered(); updateBoardUnreadFingerprints(); renderTickets(); } function partitionClientTickets(list) { const buckets = { newT: [], progress: [], closed: [] }; for (const t of list) { const s = String(t.status || ""); if (s === "Закрыт") buckets.closed.push(t); else if (s === "В работе") buckets.progress.push(t); else if (s === "Новый") buckets.newT.push(t); else buckets.newT.push(t); } const byDate = (a, b) => String(b.activity_at || b.created_at || "").localeCompare(String(a.activity_at || a.created_at || "")); buckets.newT.sort(byDate); buckets.progress.sort(byDate); buckets.closed.sort(byDate); return buckets; } function clientTicketStatusClass(status) { const s = String(status || ""); if (s === "Новый") return "f7-ticket-card__status--new"; if (s === "В работе") return "f7-ticket-card__status--progress"; return "f7-ticket-card__status--closed"; } function renderTicketCardHtml(ticket) { const previewRaw = ticket.preview_text != null ? ticket.preview_text : ""; const preview = escapeHtml(previewRaw); const activity = ticket.activity_at || ticket.created_at; const time = escapeHtml(formatTicketCardTime(activity)); const hasUnread = ticketHasUnread(ticket); const unread = hasUnread ? '' : ""; const tn = escapeHtml(ticket.ticket_number); const subj = escapeHtml(ticket.subject || "—"); const st = escapeHtml(ticket.status); const stCls = clientTicketStatusClass(ticket.status); return `
      • Тема: ${subj} #${tn} ${st}

        ${preview || "—"}

        ${unread}
        ${time}
      • `; } async function tryOpenTicket(ticketNumber) { const ticket = state.tickets.find((t) => t.ticket_number === ticketNumber); if (!ticket) return; if (state.chatModalOpen) { if (state.currentTicket === ticketNumber) return; showError("Чтобы открыть другое обращение, сначала закройте окно чата (крестик справа сверху)."); return; } state.currentTicket = ticketNumber; document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active")); const tnSel = String(ticketNumber).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const card = document.querySelector(`.f7-ticket-card[data-ticket-number="${tnSel}"]`); if (card) card.classList.add("f7-ticket-card--active"); openChatModal(ticket); delete state.ticketUnreadByLastMsg[String(ticketNumber)]; try { await fetchMessages(); await fetchTickets(); const row = state.tickets.find((x) => x.ticket_number === ticketNumber); if (row) row.has_unread = false; renderTickets(); reapplyActiveTicketCard(ticketNumber); void markClientTicketRead(ticket.ticket_number); connectTicketSocket(ticket.ticket_number); } catch (e) { showError(e.message || "Не удалось загрузить сообщения"); disconnectTicketSocket(); await closeChatModal(); } } function renderTickets() { const { newT, progress, closed } = partitionClientTickets(state.tickets); const cols = [ document.getElementById("ticket-col-new"), document.getElementById("ticket-col-progress"), document.getElementById("ticket-col-closed"), ]; const data = [newT, progress, closed]; cols.forEach((ul, i) => { if (!ul) return; ul.innerHTML = data[i].map(renderTicketCardHtml).join(""); ul.querySelectorAll(".f7-ticket-card").forEach((li) => { li.onclick = () => tryOpenTicket(li.getAttribute("data-ticket-number")); }); }); } async function fetchMessages() { if (!state.currentTicket) return; const response = await fetch(`${apiBase}/api/client/tickets/${state.currentTicket}/messages`, { headers: clientIdentityHeaders(), }); if (!response.ok) throw new Error("Не удалось загрузить сообщения"); state.messages = await response.json(); syncCurrentTicketLastMsgSigFromState(); renderMessages(); } async function fetchClientAttachmentBlob(attachmentId) { const url = `${apiBase}/api/client/tickets/${state.currentTicket}/attachments/${attachmentId}`; const res = await fetch(url, { headers: clientIdentityHeaders() }); if (!res.ok) throw new Error("attachment"); return res.blob(); } function hydrateClientAttachments(scope) { const root = scope || messageList; if (!root || !state.currentTicket) return; root.querySelectorAll("[data-f7-att-preview]").forEach((wrap) => { const id = wrap.getAttribute("data-f7-att-preview"); (async () => { try { const blob = await fetchClientAttachmentBlob(id); const objUrl = URL.createObjectURL(blob); state.blobUrls.push(objUrl); const frame = document.createElement("div"); frame.className = "chat-inline-img-wrap"; const img = document.createElement("img"); img.className = "chat-inline-img"; img.src = objUrl; img.alt = ""; img.loading = "lazy"; img.addEventListener("load", () => scrollChatToBottom(), { once: true }); frame.appendChild(img); wrap.replaceChildren(frame); } catch (_) { wrap.textContent = "Не удалось показать файл"; } })(); }); root.querySelectorAll("[data-f7-att-file]").forEach((row) => { const handler = async () => { const id = row.getAttribute("data-f7-att-file"); const fn = row.getAttribute("data-f7-filename") || "file"; const mime = row.getAttribute("data-f7-mime") || ""; try { const blob = await fetchClientAttachmentBlob(id); openOrDownloadBlob(blob, fn, mime); } catch (_) { showError("Не удалось открыть файл"); } }; row.onclick = handler; row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handler(); } }; }); } function renderMessages() { revokeAttachmentUrls(); messageList.innerHTML = state.messages.map((m) => messageBlockHtml(m)).join(""); hydrateClientAttachments(messageList); scrollChatToBottom(); } async function submitTicketWithRetry(payload) { const maxAttempts = 3; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const duplicate = attempt > 1 ? 1 : 0; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 180000); try { const response = await fetch(`${apiBase}/api/client/tickets`, { method: "POST", headers: { "Content-Type": "application/json", ...clientIdentityHeaders(), }, body: JSON.stringify({ ...payload, duplicate }), signal: controller.signal, }); clearTimeout(timeout); if (!response.ok) throw new Error("Send failed"); return await response.json(); } catch (e) { clearTimeout(timeout); if (attempt === maxAttempts) { throw new Error("Сервер поддержки недоступен, пожалуйста обратитесь к администратору."); } } } throw new Error("Unknown error"); } document.getElementById("create-ticket-btn").onclick = () => { setModalVisible(createModal, true); showError(""); }; document.getElementById("close-modal-btn").onclick = () => { document.getElementById("subject-input").value = ""; document.getElementById("body-input").value = ""; setModalVisible(createModal, false); }; document.getElementById("chat-modal-close-btn").onclick = () => { void closeChatModal(); }; document.getElementById("attachment-clip-btn").onclick = () => { document.getElementById("attachment-input")?.click(); }; document.getElementById("attachment-input").onchange = (e) => { const f = e.target.files?.[0]; if (f) setPendingFile(f); }; document.getElementById("pending-attachment-clear").onclick = () => { clearPendingFile(); }; const messageInputEl = document.getElementById("message-input"); messageInputEl.addEventListener("paste", (e) => { const files = e.clipboardData?.files; if (files && files[0]) { e.preventDefault(); setPendingFile(files[0]); } }); document.getElementById("submit-ticket-btn").onclick = async () => { try { const subject = document.getElementById("subject-input").value.trim(); const body = document.getElementById("body-input").value.trim(); if (!subject || !body) { showError("Тема и обращение обязательны"); return; } const result = await submitTicketWithRetry({ server_address: serverAddress, username, subject, body, }); setModalVisible(createModal, false); showError(`Токен создан: ${result.ticket_number}`); document.getElementById("subject-input").value = ""; document.getElementById("body-input").value = ""; await fetchTickets(); } catch (e) { showError(e.message || "Ошибка отправки"); } }; document.getElementById("send-message-btn").onclick = async () => { if (!state.currentTicket) { showError("Сначала выберите токен"); return; } let text = document.getElementById("message-input").value.trim(); const file = state.pendingFile; if (!text && !file) { showError("Введите сообщение или прикрепите файл"); return; } if (!text && file) text = `📎 ${file.name}`; const response = await fetch(`${apiBase}/api/client/tickets/${state.currentTicket}/messages`, { method: "POST", headers: { "Content-Type": "application/json", ...clientIdentityHeaders(), }, body: JSON.stringify({ text }), }); if (!response.ok) { showError("Не удалось отправить сообщение"); return; } const created = await response.json(); if (file) { const form = new FormData(); form.append("message_id", String(created.id)); form.append("file", file); const attRes = await fetch(`${apiBase}/api/client/tickets/${state.currentTicket}/attachments`, { method: "POST", headers: clientIdentityHeaders(), body: form, }); if (!attRes.ok) { const errText = await attRes.text().catch(() => ""); showError(errText || "Не удалось отправить файл"); return; } clearPendingFile(); } document.getElementById("message-input").value = ""; await fetchMessages(); }; queueMicrotask(() => { fetchTickets() .catch((e) => showError(e.message)) .finally(() => { scheduleTicketsBoardPolling(); }); }); })();