1194 lines
41 KiB
JavaScript
1194 lines
41 KiB
JavaScript
(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<string, string>} последний известный «отпечаток» карточки по номеру тикета (для точки без has_unread с API) */
|
||
ticketSeenFingerprint: {},
|
||
};
|
||
|
||
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, ">")
|
||
.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 ? '<span class="chat-file-bubble__ticks" aria-hidden="true">✓✓</span>' : "";
|
||
return `<div class="${wrapCls}">
|
||
<div class="${bubbleCls}" role="button" tabindex="0" data-f7-att-file="${a.id}" data-f7-filename="${escapeHtml(a.filename)}" data-f7-mime="${escapeHtml(a.mime_type || "")}">
|
||
<div class="chat-file-bubble__row">
|
||
<div class="chat-file-bubble__icon chat-file-bubble__icon--${escapeHtml(slug)}"><span class="chat-file-bubble__icon-text">${escapeHtml(label)}</span></div>
|
||
<div class="chat-file-bubble__body">
|
||
<div class="chat-file-bubble__name">${escapeHtml(a.filename)}</div>
|
||
<div class="chat-file-bubble__size">${formatBytes(a.size_bytes)}</div>
|
||
</div>
|
||
</div>
|
||
<div class="chat-file-bubble__foot">
|
||
<span class="chat-file-bubble__time">${escapeHtml(time)}</span>
|
||
${ticks}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
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 `<div class="f7support-att" data-f7-att-preview="${a.id}"><span style="color:#666;font-size:0.9em;">Загрузка…</span></div>`;
|
||
}
|
||
return nonImageAttachmentHtml(m, a);
|
||
})
|
||
.join("");
|
||
return `<div class="f7support-msg" data-f7-msg-id="${m.id}" style="margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid #eee;">
|
||
<div><b>${escapeHtml(m.author)}</b> (${escapeHtml(m.author_role)}): ${escapeHtml(m.text)}</div>
|
||
${attHtml ? `<div style="margin-top:6px;">${attHtml}</div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
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();
|
||
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;
|
||
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 = `
|
||
<div class="f7support-wrap">
|
||
<h2>f7support</h2>
|
||
<button type="button" id="create-ticket-btn">Создать токен</button>
|
||
<p id="error-box" class="f7support-error"></p>
|
||
<div class="f7support-main">
|
||
<h3>Мои обращения</h3>
|
||
<div class="f7-board" id="f7-ticket-board">
|
||
<div class="f7-column">
|
||
<h4 class="f7-column-title">Новые</h4>
|
||
<ul id="ticket-col-new" class="f7-column-list"></ul>
|
||
</div>
|
||
<div class="f7-column">
|
||
<h4 class="f7-column-title">В работе</h4>
|
||
<ul id="ticket-col-progress" class="f7-column-list"></ul>
|
||
</div>
|
||
<div class="f7-column">
|
||
<h4 class="f7-column-title">Архив</h4>
|
||
<ul id="ticket-col-closed" class="f7-column-list"></ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="create-ticket-modal" class="f7support-modal" aria-hidden="true">
|
||
<div class="f7support-modal-panel">
|
||
<div class="f7support-modal-head">
|
||
<h3>Создание токена</h3>
|
||
<button type="button" id="close-modal-btn" class="f7support-icon-btn" aria-label="Закрыть">✕</button>
|
||
</div>
|
||
<input id="subject-input" maxlength="255" placeholder="Тема" class="f7support-input" />
|
||
<textarea id="body-input" maxlength="4000" placeholder="Опишите проблему (до 4000 символов)" class="f7support-textarea"></textarea>
|
||
<button type="button" id="submit-ticket-btn">Отправить</button>
|
||
</div>
|
||
</div>
|
||
<div id="chat-modal" class="f7support-modal" aria-hidden="true">
|
||
<div class="f7support-modal-panel f7support-chat-panel">
|
||
<div class="f7support-modal-head">
|
||
<h3 id="chat-modal-title">Чат</h3>
|
||
<button type="button" id="chat-modal-close-btn" class="f7support-icon-btn" aria-label="Закрыть чат">✕</button>
|
||
</div>
|
||
<div id="message-list" class="f7support-message-list f7support-dnd-target" title="Перетащите файл сюда"></div>
|
||
<div class="f7support-composer">
|
||
<button type="button" id="attachment-clip-btn" class="f7support-clip-btn" title="Прикрепить файл" aria-label="Прикрепить файл">📎</button>
|
||
<input type="file" id="attachment-input" class="f7support-hidden-file" />
|
||
<textarea id="message-input" maxlength="4000" placeholder="Сообщение (до 4000 символов). Файл — скрепка или перетаскивание." class="f7support-textarea f7support-dnd-target"></textarea>
|
||
</div>
|
||
<div id="pending-attachment-row" class="f7support-pending-row" hidden>
|
||
<span id="pending-attachment-label"></span>
|
||
<button type="button" id="pending-attachment-clear" class="f7support-icon-btn" aria-label="Убрать файл">×</button>
|
||
</div>
|
||
<div class="f7support-chat-actions">
|
||
<button type="button" id="send-message-btn">Отправить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<style>
|
||
.f7support-wrap { padding: 16px; font-family: sans-serif; max-width: min(1680px, 100%); width: 100%; box-sizing: border-box; }
|
||
.f7support-error { color: #b00020; min-height: 1.25em; margin: 8px 0; }
|
||
.f7support-main { margin-top: 12px; width: 100%; }
|
||
.f7-board {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(300px, 1fr));
|
||
gap: 16px;
|
||
margin-top: 12px;
|
||
align-items: stretch;
|
||
width: 100%;
|
||
}
|
||
@media (max-width: 1100px) {
|
||
.f7-board { grid-template-columns: 1fr; }
|
||
}
|
||
.f7-column {
|
||
border: 1px solid #ddd;
|
||
border-radius: 8px;
|
||
background: #fafafa;
|
||
padding: 12px;
|
||
min-height: min(52vh, 560px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.f7-column-title { margin: 0 0 8px 0; font-size: 1rem; font-weight: 600; }
|
||
.f7-column-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
flex: 1;
|
||
min-height: min(42vh, 420px);
|
||
max-height: min(72vh, 820px);
|
||
overflow-y: auto;
|
||
}
|
||
.f7-ticket-card {
|
||
border: 1px solid #e0e0e0;
|
||
border-radius: 8px;
|
||
margin-bottom: 10px;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
}
|
||
.f7-ticket-card:hover { filter: brightness(0.985); border-color: #c8c8c8; }
|
||
.f7-ticket-card--active { outline: 2px solid #1a4fa3; }
|
||
.f7-ticket-card__head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 10px;
|
||
background: #f5f5f5;
|
||
margin: 8px;
|
||
padding: 8px 10px;
|
||
border-radius: 6px;
|
||
}
|
||
.f7-ticket-card__title { font-size: 0.92rem; color: #333; }
|
||
.f7-ticket-card__title b { font-weight: 700; }
|
||
.f7-ticket-card__num { font-weight: 400; color: #777; margin-left: 4px; }
|
||
.f7-ticket-card__status {
|
||
flex-shrink: 0;
|
||
font-size: 11px;
|
||
padding: 3px 10px;
|
||
border-radius: 999px;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
}
|
||
.f7-ticket-card__status--new { background: #2d9cdb; }
|
||
.f7-ticket-card__status--progress { background: #f2994a; }
|
||
.f7-ticket-card__status--closed { background: #9e9e9e; }
|
||
.f7-ticket-card__body {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
padding: 4px 14px 6px;
|
||
}
|
||
.f7-ticket-card__preview {
|
||
flex: 1;
|
||
margin: 0;
|
||
color: #666;
|
||
font-size: 0.88rem;
|
||
line-height: 1.4;
|
||
min-height: 2.8em;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 3;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
.f7-unread-dot {
|
||
flex-shrink: 0;
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
background: #6fcf97;
|
||
margin-top: 5px;
|
||
}
|
||
.f7-ticket-card__foot {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
padding: 0 14px 10px;
|
||
}
|
||
.f7-ticket-card__time {
|
||
font-size: 0.75rem;
|
||
color: #333;
|
||
background: #f0f0f0;
|
||
padding: 2px 10px;
|
||
border-radius: 999px;
|
||
}
|
||
.f7support-modal {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 2000;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(0,0,0,0.45);
|
||
padding: 16px;
|
||
box-sizing: border-box;
|
||
}
|
||
.f7support-modal.f7support-modal-visible { display: flex; }
|
||
.f7support-modal-panel {
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
max-width: 640px;
|
||
width: 100%;
|
||
max-height: 90vh;
|
||
overflow: auto;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||
}
|
||
.f7support-chat-panel {
|
||
max-width: 720px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-height: 88vh;
|
||
}
|
||
.f7support-modal-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
.f7support-modal-head h3 { margin: 0; font-size: 1.1rem; }
|
||
.f7support-icon-btn {
|
||
border: none;
|
||
background: transparent;
|
||
font-size: 1.25rem;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
padding: 4px 8px;
|
||
color: #444;
|
||
}
|
||
.f7support-icon-btn:hover { color: #000; background: #eee; border-radius: 4px; }
|
||
.f7support-input, .f7support-textarea {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
margin-top: 8px;
|
||
}
|
||
.f7support-textarea { min-height: 72px; resize: vertical; }
|
||
.f7support-message-list {
|
||
flex: 1;
|
||
min-height: 200px;
|
||
max-height: 45vh;
|
||
overflow: auto;
|
||
border: 1px solid #ccc;
|
||
border-radius: 6px;
|
||
padding: 8px;
|
||
margin-bottom: 8px;
|
||
background: #fff;
|
||
}
|
||
.f7support-message-list.f7support-dnd-dragover { outline: 2px dashed #1a4fa3; background: #f4f8ff; }
|
||
.f7support-composer {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.f7support-composer .f7support-textarea { flex: 1; margin-top: 0; min-height: 72px; }
|
||
.f7support-clip-btn {
|
||
flex-shrink: 0;
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 1px solid #ccc;
|
||
border-radius: 8px;
|
||
background: #fafafa;
|
||
cursor: pointer;
|
||
font-size: 1.2rem;
|
||
line-height: 1;
|
||
padding: 0;
|
||
}
|
||
.f7support-clip-btn:hover { background: #eee; }
|
||
.f7support-hidden-file { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||
.f7support-pending-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 8px;
|
||
background: #f5f5f5;
|
||
border-radius: 6px;
|
||
margin-bottom: 8px;
|
||
font-size: 0.9rem;
|
||
}
|
||
.chat-file-bubble-wrap {
|
||
display: flex;
|
||
width: 100%;
|
||
margin-top: 8px;
|
||
}
|
||
.chat-file-bubble-wrap--out { justify-content: flex-end; }
|
||
.chat-file-bubble-wrap--in { justify-content: flex-start; }
|
||
.chat-file-bubble {
|
||
max-width: min(100%, 340px);
|
||
padding: 10px 12px 8px;
|
||
color: #fff;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
box-shadow: 0 2px 10px rgba(0,0,0,0.12);
|
||
position: relative;
|
||
transition: filter 0.15s ease, transform 0.15s ease;
|
||
}
|
||
.chat-file-bubble:hover { filter: brightness(1.05); }
|
||
.chat-file-bubble:active { transform: scale(0.99); }
|
||
.chat-file-bubble--out {
|
||
background: linear-gradient(165deg, #c4b5e8 0%, #9b86d8 45%, #8b7cc8 100%);
|
||
border-radius: 14px 14px 6px 14px;
|
||
}
|
||
.chat-file-bubble--out::after {
|
||
content: "";
|
||
position: absolute;
|
||
right: -4px;
|
||
bottom: 2px;
|
||
width: 14px;
|
||
height: 14px;
|
||
background: #8b7cc8;
|
||
border-radius: 0 0 10px 0;
|
||
clip-path: polygon(0 100%, 100% 0, 100% 100%);
|
||
}
|
||
.chat-file-bubble--in {
|
||
background: linear-gradient(165deg, #6b7280 0%, #4b5563 100%);
|
||
border-radius: 14px 14px 14px 6px;
|
||
}
|
||
.chat-file-bubble--in::after {
|
||
content: "";
|
||
position: absolute;
|
||
left: -4px;
|
||
bottom: 2px;
|
||
width: 14px;
|
||
height: 14px;
|
||
background: #4b5563;
|
||
border-radius: 0 0 0 10px;
|
||
clip-path: polygon(0 0, 0 100%, 100% 100%);
|
||
}
|
||
.chat-file-bubble__row { display: flex; gap: 10px; align-items: flex-start; }
|
||
.chat-file-bubble__icon {
|
||
flex-shrink: 0;
|
||
width: 44px;
|
||
height: 52px;
|
||
border-radius: 8px;
|
||
background: linear-gradient(180deg, #3b9eff 0%, #1e6fd9 100%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
box-shadow: inset 0 -2px 0 rgba(0,0,0,0.12);
|
||
}
|
||
.chat-file-bubble__icon::before {
|
||
content: "";
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
width: 0;
|
||
height: 0;
|
||
border-style: solid;
|
||
border-width: 0 14px 14px 0;
|
||
border-color: transparent rgba(255,255,255,0.35) transparent transparent;
|
||
}
|
||
.chat-file-bubble__icon-text {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
text-transform: lowercase;
|
||
color: #fff;
|
||
letter-spacing: 0.02em;
|
||
max-width: 36px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.chat-file-bubble__body { flex: 1; min-width: 0; }
|
||
.chat-file-bubble__name {
|
||
font-weight: 600;
|
||
font-size: 0.92rem;
|
||
line-height: 1.25;
|
||
word-break: break-word;
|
||
}
|
||
.chat-file-bubble__size {
|
||
font-size: 0.8rem;
|
||
opacity: 0.92;
|
||
margin-top: 4px;
|
||
}
|
||
.chat-file-bubble__foot {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-top: 8px;
|
||
font-size: 0.72rem;
|
||
opacity: 0.92;
|
||
}
|
||
.chat-file-bubble__ticks { font-size: 0.78rem; letter-spacing: -3px; opacity: 0.95; }
|
||
.f7support-chat-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 4px; }
|
||
.f7support-dnd-target.f7support-dnd-dragover { outline: 2px dashed #1a4fa3; }
|
||
.chat-inline-img-wrap {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
max-width: 100%;
|
||
margin: 4px 0;
|
||
}
|
||
.chat-inline-img {
|
||
display: block;
|
||
max-width: min(100%, min(520px, 92vw));
|
||
max-height: min(280px, 42vh);
|
||
width: auto;
|
||
height: auto;
|
||
object-fit: contain;
|
||
border-radius: 8px;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||
}
|
||
</style>
|
||
`;
|
||
|
||
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 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) : "";
|
||
return `${act}\u0001${prev}`;
|
||
}
|
||
|
||
/** Для открытого чата учитываем последнее сообщение, чтобы отпечаток не отставал от превью в списке. */
|
||
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) {
|
||
if (ticket && ticket._f7_board_unread) 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);
|
||
}
|
||
|
||
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);
|
||
}
|
||
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"));
|
||
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();
|
||
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
|
||
? '<span class="f7-unread-dot" title="Новое сообщение" aria-label="Новое сообщение"></span>'
|
||
: "";
|
||
const tn = escapeHtml(ticket.ticket_number);
|
||
const st = escapeHtml(ticket.status);
|
||
const stCls = clientTicketStatusClass(ticket.status);
|
||
return `<li class="f7-ticket-card" data-ticket-number="${escapeHtml(ticket.ticket_number)}">
|
||
<div class="f7-ticket-card__head">
|
||
<span class="f7-ticket-card__title"><b>Тема</b><span class="f7-ticket-card__num">#${tn}</span></span>
|
||
<span class="f7-ticket-card__status ${stCls}">${st}</span>
|
||
</div>
|
||
<div class="f7-ticket-card__body">
|
||
<p class="f7-ticket-card__preview">${preview || "—"}</p>
|
||
${unread}
|
||
</div>
|
||
<div class="f7-ticket-card__foot">
|
||
<span class="f7-ticket-card__time">${time}</span>
|
||
</div>
|
||
</li>`;
|
||
}
|
||
|
||
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);
|
||
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();
|
||
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();
|
||
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 = () => {
|
||
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();
|
||
});
|
||
});
|
||
})();
|