'use strict'; const byId = (id) => document.getElementById(id); const exampleField = byId('example'); const actorField = byId('actor'); const messageField = byId('message'); const intake = byId('intake-form'); const submit = byId('submit'); const detail = byId('ticket-detail'); const ticketRows = byId('ticket-rows'); const regularFilter = byId('regular-filter'); const spamFilter = byId('spam-filter'); const panelView = byId('panel-view'); const exitView = byId('panel-exit-view'); const exitButton = byId('panel-exit'); const returnButton = byId('panel-return'); const clearButton = byId('clear-buffer'); let config; let examples = []; let tickets = []; let selectedId = null; let attempt = null; let queueRefresh = Promise.resolve(); let folder = 'regular'; let currentDetail = null; let generation = 0; let exited = false; let clearing = false; let queueTimer; function isCurrent(expected) { return !exited && generation === expected; } function invalidatePanel() { generation += 1; window.clearTimeout(queueTimer); queueRefresh = Promise.resolve(); } function element(tag, content = '', className = '') { const node = document.createElement(tag); node.textContent = content; node.className = className; return node; } function option(value, title) { const node = element('option', title); node.value = value; return node; } function fillSelect(select, values, selected) { select.replaceChildren(...Object.entries(values).map(([value, title]) => option(value, title))); select.value = selected; } function feedback(id, text = '', isError = false) { const status = byId(id); status.textContent = text; status.dataset.error = isError; } async function api(url, method = 'GET', body) { const options = { method }; if (body !== undefined) { options.headers = { 'Content-Type': 'application/json' }; options.body = JSON.stringify(body); } let response; try { response = await fetch(url, options); } catch { throw new Error('Сервер недоступен. Проверьте, что прототип запущен.'); } let data; try { data = await response.json(); } catch { throw new Error('Сервер вернул ответ в неожиданном формате. Проверьте, что прототип запущен.'); } if (!response.ok) throw new Error(data.error || 'Не удалось выполнить действие.'); return data; } function modeLabel(ticket) { if (ticket.mode === 'demo') return 'Демо · правила'; return ticket.provider === 'yandex' ? 'LLM · Yandex AI Studio' : 'LLM · OpenAI'; } function setIntakeDisabled(disabled) { for (const field of [exampleField, actorField, messageField, submit]) field.disabled = disabled; intake.setAttribute('aria-busy', String(disabled)); } exampleField.addEventListener('change', () => { if (exampleField.value === '') return; const example = examples[Number(exampleField.value)]; actorField.value = example.actor; messageField.value = example.text; feedback('intake-feedback'); }); intake.addEventListener('submit', async (event) => { event.preventDefault(); if (exited || clearing) return; const expected = generation; const actor = actorField.value; const text = messageField.value.trim(); if (!text) { feedback('intake-feedback', 'Введите текст обращения.', true); messageField.focus(); return; } if (!attempt || attempt.actor !== actor || attempt.text !== text) { attempt = { actor, text, requestId: crypto.randomUUID() }; } setIntakeDisabled(true); submit.textContent = 'Обрабатываем…'; feedback('intake-feedback'); try { const { ticket } = await api('/api/tickets', 'POST', attempt); if (!isCurrent(expected)) return; attempt = null; exampleField.value = ''; actorField.value = ''; messageField.value = ''; selectedId = ticket.id; folder = ticketFolder(ticket); feedback('intake-feedback', `Обращение №${ticket.id} записано.`); await refreshQueue().catch((error) => { if (isCurrent(expected)) feedback('queue-feedback', error.message, true); }); } catch (error) { if (isCurrent(expected)) feedback('intake-feedback', error.message, true); } finally { if (isCurrent(expected)) { setIntakeDisabled(false); submit.textContent = 'Обработать и записать'; } } }); const dateFormat = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'short', timeStyle: 'short' }); function ticketFolder(ticket) { return ticket.queue === 'spam' || ticket.category === 'spam' ? 'spam' : 'regular'; } function ticketChat(ticket, draft) { const section = element('section', '', 'ticket-chat'); const heading = element('h4', 'Переписка'); const scrollWindow = element('div', '', 'chat-window'); const messages = element('ol', '', 'chat-log'); messages.setAttribute('role', 'log'); messages.setAttribute('aria-label', 'Переписка'); const chatStatus = element('p', 'Загрузка переписки…', 'note'); scrollWindow.append(messages); scrollWindow.hidden = true; section.append(heading, scrollWindow, chatStatus); const replyForm = element('form'); const replyLabel = element('label', 'Ответ в чат'); replyLabel.htmlFor = 'chat-reply'; const reply = element('textarea'); reply.id = replyLabel.htmlFor; reply.rows = 3; reply.maxLength = 2500; reply.required = true; const copyDraft = element('button', 'Вставить черновик', 'secondary'); copyDraft.type = 'button'; copyDraft.addEventListener('click', () => { reply.value = draft.value; reply.focus(); }); const send = element('button', 'Отправить в чат'); send.type = 'submit'; const actions = element('div', '', 'reply-actions'); actions.append(send, copyDraft); const replyStatus = element('p', '', 'feedback'); replyStatus.setAttribute('role', 'status'); replyForm.append(replyLabel, reply, actions); section.append(replyForm, replyStatus); const endpoint = `/api/tickets/${ticket.id}/messages`; const authorLabels = { customer: 'Пользователь', staff: 'Сотрудник', moderator: 'Модерация', operator: 'Поддержка' }; let renderedCount = 0; let refreshPending; let replyAttempt; function updateCategory(category) { replyForm.hidden = category === 'spam'; } function renderMessages(items) { if (!section.isConnected || items.length < renderedCount) return; const wasAtBottom = scrollWindow.scrollHeight - scrollWindow.scrollTop - scrollWindow.clientHeight < 40; const fragment = document.createDocumentFragment(); for (const item of items.slice(renderedCount)) { const bubble = element('li'); bubble.dataset.role = item.role; const meta = element('div', '', 'chat-meta'); const time = element('time', dateFormat.format(new Date(item.createdAt))); time.dateTime = item.createdAt; meta.append(element('strong', authorLabels[item.role] || 'Участник'), time); bubble.append(meta, element('p', item.text)); fragment.append(bubble); } messages.append(fragment); renderedCount = items.length; scrollWindow.hidden = renderedCount === 0; chatStatus.hidden = renderedCount > 0; chatStatus.textContent = 'Сообщений пока нет.'; if (wasAtBottom) scrollWindow.scrollTop = scrollWindow.scrollHeight; } function refresh() { if (refreshPending) return refreshPending; refreshPending = api(endpoint).then(({ messages: items }) => renderMessages(items)).catch(() => { if (!renderedCount) chatStatus.textContent = 'Не удалось загрузить переписку.'; }).finally(() => { refreshPending = null; }); return refreshPending; } replyForm.addEventListener('submit', async (event) => { event.preventDefault(); const text = reply.value.trim(); if (!text || send.disabled || replyForm.hidden || clearing || exited) return; const expected = generation; if (replyAttempt?.text !== text) replyAttempt = { requestId: crypto.randomUUID(), text }; send.disabled = true; copyDraft.disabled = true; reply.disabled = true; replyStatus.textContent = ''; try { const { messages: items } = await api(endpoint, 'POST', replyAttempt); if (!isCurrent(expected) || !section.isConnected) return; renderMessages(items); scrollWindow.scrollTop = scrollWindow.scrollHeight; replyAttempt = null; reply.value = ''; replyStatus.textContent = 'Ответ отправлен.'; replyStatus.dataset.error = false; await refreshQueue(true).catch(() => {}); } catch (error) { if (isCurrent(expected) && section.isConnected) { replyStatus.textContent = error.message; replyStatus.dataset.error = true; } } finally { send.disabled = false; copyDraft.disabled = false; reply.disabled = false; } }); updateCategory(ticket.category); return { section, refresh, updateCategory }; } function renderDetail(ticket) { currentDetail = null; detail.replaceChildren(); detail.hidden = !ticket; if (!ticket) return; const title = element('h3', `Обращение №${ticket.id}`); const created = dateFormat.format(new Date(ticket.createdAt)); const meta = element('p', `${ticket.channel} · ${config.actors[ticket.actor]} · ${created} · ${modeLabel(ticket)}`, 'detail-meta'); const text = element('p', ticket.text, 'detail-text'); const reason = element('p', `Первичная оценка. ${ticket.reason}`, 'note'); detail.append(title, meta); if (ticket.externalId) detail.append(element('p', `ID в источнике ${ticket.externalId}`, 'detail-meta source-id')); text.hidden = ticket.hasConversation; const review = element('p', 'Нужна проверка деталей', 'note review'); review.hidden = !ticket.needsReview || ticket.status !== 'new'; detail.append(text, reason, review); const form = element('form'); const controls = element('div', '', 'controls'); const category = element('select'); category.id = 'edit-category'; fillSelect(category, config.categories, ticket.category); const status = element('select'); status.id = 'edit-status'; fillSelect(status, config.statuses, ticket.status); const categoryLabel = element('label', 'Категория'); categoryLabel.htmlFor = category.id; const statusLabel = element('label', 'Статус'); statusLabel.htmlFor = status.id; const categoryControl = element('div'); categoryControl.append(categoryLabel, category); const statusControl = element('div'); statusControl.append(statusLabel, status); controls.append(categoryControl, statusControl); form.append(controls); const impact = element('input'); impact.type = 'checkbox'; impact.checked = ticket.studentImpact; if (ticket.actor === 'staff') { const impactLabel = element('label', '', 'checkbox'); impactLabel.append(impact, element('span', 'Сбой уже затрагивает учеников')); form.append(impactLabel); } const draft = element('textarea'); draft.id = 'edit-draft'; draft.rows = 4; draft.maxLength = 2500; draft.required = true; draft.value = ticket.draft; const draftLabel = element('label'); draftLabel.htmlFor = draft.id; const updateDraftLabel = () => { draftLabel.textContent = category.value === 'spam' ? 'Рекомендация оператору' : 'Черновик ответа'; }; category.addEventListener('change', updateDraftLabel); updateDraftLabel(); const save = element('button', 'Сохранить правку'); save.type = 'submit'; form.append(draftLabel, draft, save); const thread = ticket.hasConversation ? ticketChat(ticket, draft) : null; if (thread) detail.append(thread.section); detail.append(form); const fieldValues = () => ({ category: category.value, status: status.value, studentImpact: impact.checked, draft: draft.value, }); let savedValues = fieldValues(); let saving = false; function applyFields(updated) { category.value = updated.category; status.value = updated.status; impact.checked = updated.studentImpact; draft.value = updated.draft; savedValues = fieldValues(); updateDraftLabel(); } function updateDetail(updated) { if (!updated || updated.id !== ticket.id) return; reason.textContent = `Первичная оценка. ${updated.reason}`; review.hidden = !updated.needsReview || updated.status !== 'new'; thread?.updateCategory(updated.category); const dirty = Object.entries(fieldValues()).some(([key, value]) => value !== savedValues[key]); if (!saving && !dirty && !form.contains(document.activeElement)) applyFields(updated); } currentDetail = { update: updateDetail, refresh: thread?.refresh }; thread?.refresh(); form.addEventListener('submit', async (event) => { event.preventDefault(); if (saving || clearing || exited) return; const expected = generation; const patch = Object.fromEntries(Object.entries(fieldValues()).filter(([key, value]) => value !== savedValues[key])); if (!Object.keys(patch).length) return; const fields = [category, status, impact, draft, save]; saving = true; for (const field of fields) field.disabled = true; feedback('queue-feedback'); try { const { ticket: updated } = await api(`/api/tickets/${ticket.id}`, 'PATCH', patch); if (!isCurrent(expected) || !form.isConnected) return; applyFields(updated); if (selectedId === ticket.id) { folder = ticketFolder(updated); tickets = tickets.map((existing) => existing.id === updated.id ? updated : existing); renderQueue(true); } feedback('queue-feedback', `Правки к обращению №${ticket.id} сохранены.`); await refreshQueue(true).catch(() => { if (isCurrent(expected)) { feedback('queue-feedback', `Правки к обращению №${ticket.id} сохранены. Обновите страницу, чтобы загрузить очередь.`); } }); } catch (error) { if (isCurrent(expected) && form.isConnected) feedback('queue-feedback', error.message, true); } finally { saving = false; for (const field of fields) field.disabled = false; } }); } function renderQueue(preserveDetail = false) { const selected = tickets.find((ticket) => ticket.id === selectedId); if (!selected) selectedId = null; const visibleTickets = tickets.filter((ticket) => ticketFolder(ticket) === folder); const focusedId = ticketRows.contains(document.activeElement) ? document.activeElement.dataset.ticket : null; const fragment = document.createDocumentFragment(); for (const ticket of visibleTickets) { const row = element('tr'); const summary = element('td'); const preview = ticket.text.replace(/\s+/gu, ' '); const short = preview.length > 94 ? `${preview.slice(0, 93)}…` : preview; const show = element('button', `№${ticket.id} · ${short}`, 'ticket-button'); show.type = 'button'; show.setAttribute('aria-expanded', String(ticket.id === selectedId)); show.setAttribute('aria-controls', detail.id); show.addEventListener('click', () => { selectedId = selectedId === ticket.id ? null : ticket.id; feedback('queue-feedback'); renderQueue(); ticketRows.querySelector(`[data-ticket="${ticket.id}"]`)?.focus(); }); show.dataset.ticket = ticket.id; summary.append(show, element('span', `${ticket.channel} · ${config.actors[ticket.actor]} · ${config.topics[ticket.topic]}`, 'ticket-meta')); const category = element('td'); const badge = element('span', config.categories[ticket.category], 'tag'); badge.dataset.category = ticket.category; category.append(badge); row.append(summary, category, element('td', config.queues[ticket.queue]), element('td', config.statuses[ticket.status])); fragment.append(row); } ticketRows.replaceChildren(fragment); byId('empty').hidden = visibleTickets.length > 0; byId('empty-title').textContent = folder === 'spam' ? 'Папка спама пуста' : 'Обращений пока нет'; byId('empty-note').hidden = folder === 'spam'; byId('table-container').hidden = visibleTickets.length === 0; byId('counter').textContent = `Всего ${visibleTickets.length} · новых ${visibleTickets.filter((ticket) => ticket.status === 'new').length}`; regularFilter.setAttribute('aria-pressed', String(folder === 'regular')); spamFilter.setAttribute('aria-pressed', String(folder === 'spam')); const spamCount = tickets.length - tickets.filter((ticket) => ticketFolder(ticket) === 'regular').length; spamFilter.textContent = spamCount ? `Спам (${spamCount})` : 'Спам'; if (focusedId) ticketRows.querySelector(`[data-ticket="${focusedId}"]`)?.focus(); if (preserveDetail && selected) currentDetail?.update(selected); else renderDetail(selected); } function openFolder(next) { if (!config || folder === next) return; folder = next; selectedId = null; feedback('queue-feedback'); renderQueue(); } regularFilter.addEventListener('click', () => openFolder('regular')); spamFilter.addEventListener('click', () => openFolder('spam')); function refreshQueue(polling = false) { const expected = generation; const refresh = queueRefresh.catch(() => {}).then(async () => { if (!isCurrent(expected)) return; const { tickets: nextTickets } = await api('/api/tickets'); if (!isCurrent(expected)) return; const changed = nextTickets.length !== tickets.length || nextTickets.some((ticket, index) => ( ticket.id !== tickets[index].id || ticket.updatedAt !== tickets[index].updatedAt )); tickets = nextTickets; if (!polling || changed) renderQueue(polling); else currentDetail?.update(tickets.find((ticket) => ticket.id === selectedId)); }); queueRefresh = refresh; return refresh; } async function pollQueue() { const expected = generation; await Promise.allSettled([refreshQueue(true), currentDetail?.refresh?.()]); if (isCurrent(expected) && !clearing) scheduleQueuePoll(); } function scheduleQueuePoll() { window.clearTimeout(queueTimer); queueTimer = window.setTimeout(pollQueue, 4000); } function resetPanel() { attempt = null; selectedId = null; folder = 'regular'; tickets = []; exampleField.value = ''; actorField.value = ''; messageField.value = ''; submit.textContent = 'Обработать и записать'; feedback('intake-feedback'); feedback('queue-feedback'); renderQueue(); } clearButton.addEventListener('click', async () => { if (exited || clearing || !window.confirm('Удалить все обращения и историю обоих чатов? Это действие нельзя отменить.')) return; clearing = true; window.clearTimeout(queueTimer); clearButton.disabled = true; exitButton.disabled = true; panelView.inert = true; feedback('queue-feedback'); try { await api('/api/tickets/clear', 'POST', {}); invalidatePanel(); resetPanel(); setIntakeDisabled(false); feedback('queue-feedback', 'Буфер очищен. Обращения и история чатов удалены.'); await refreshQueue().catch(() => {}); } catch (error) { feedback('queue-feedback', error.message, true); } finally { clearing = false; clearButton.disabled = false; exitButton.disabled = false; panelView.inert = false; clearButton.focus(); scheduleQueuePoll(); } }); exitButton.addEventListener('click', () => { if (clearing) return; exited = true; invalidatePanel(); resetPanel(); panelView.hidden = true; exitView.hidden = false; exitButton.hidden = true; byId('mode').hidden = true; returnButton.focus(); }); returnButton.addEventListener('click', () => { if (!exited) return; exited = false; panelView.hidden = false; exitView.hidden = true; exitButton.hidden = false; byId('mode').hidden = false; initialize(); exitButton.focus(); }); async function initialize() { const expected = generation; clearButton.disabled = true; setIntakeDisabled(true); byId('counter').textContent = 'Загрузка обращений'; try { const [settings, sampleData] = await Promise.all([api('/api/config'), api('/api/examples')]); if (!isCurrent(expected)) return; config = settings; examples = sampleData.examples; byId('mode').textContent = modeLabel(config); fillSelect(actorField, { '': 'Выберите тип автора', ...config.actors }, ''); messageField.maxLength = config.maxTextLength; exampleField.replaceChildren(option('', 'Выбрать из списка'), ...examples.map((example, index) => option(String(index), example.title))); setIntakeDisabled(false); await refreshQueue(); if (isCurrent(expected)) { clearButton.disabled = false; scheduleQueuePoll(); } } catch (error) { if (!isCurrent(expected)) return; byId('counter').textContent = 'Очередь недоступна'; feedback('queue-feedback', error.message, true); } } initialize();