(async function() { const API = location.origin; const PAGE_SIZE = 20; let page = 1, total = 0, searchTimer, groupsLoaded, feedbacks = [], shortsListCache = null, shortsFetchTimer = null, lastFbAutoText = ''; const topicReportCache = new Map(); function hdr() { const t = sessionStorage.getItem('authToken'); return t ? { 'X-Auth-Token': t } : {}; } function fmtDate(d) { if (!d) return ''; if (window.datetime && datetime.fmtShortIso) return datetime.fmtShortIso(d); return d; } function fmtDay(d) { if (!d) return ''; try { const dt = new Date(d + 'T12:00:00'); return dt.toLocaleDateString('ru-RU', { weekday: 'short' }); } catch (e) { return ''; } } function escapeHtml(s) { return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } function aiBadge(s) { switch (s) { case 'pending': return 'Ожидает'; case 'processing': return 'В обработке'; case 'done': return 'Готово'; case 'error': return 'Ошибка'; case 'skipped': return 'Пропущен'; default: return 'Без ИИ'; } } function fbItemHtml(f) { const text = (f.text||'').trim(); const del = f.deleted_at ? ' style="opacity:.5"' : ''; const day = fmtDay(f.feedback_date); return `
${escapeHtml(f.group_name||'')} ${fmtDate(f.feedback_date)}${day ? ` · ${day}` : ''} ${f.deleted_at ? 'Удалён' : ''} ${aiBadge(f.ai_status)} ${f.sent_at ? 'Отправлено' : ''}
${f.ai_status === 'error' ? `` : ''} ${f.deleted_at ? '' : ``} ${f.deleted_at ? `` : ''}
${f.resident ? `
${escapeHtml(f.resident)}
` : ''} ${f.topic ? `
${escapeHtml(f.topic)}
` : ''} ${text ? `
${escapeHtml(text)}
` : ''}
`; } function ensureModal() { let m = document.getElementById('feedbackModal'); if (m) return m; m = document.createElement('div'); m.className = 'modal-overlay'; m.id = 'feedbackModal'; document.body.appendChild(m); return m; } function openModal({id}) { const m = document.getElementById('feedbackModal'); if (!m) return; m.classList.add('open'); resetForm(); if (id) loadFeedback(id); } function closeModal() { const m = document.getElementById('feedbackModal'); if (m) m.classList.remove('open'); } function todayIso() { try { if (window.datetime && datetime.todayIso) return datetime.todayIso(); } catch (e) {} const d = new Date(); const y=d.getFullYear(), mo=String(d.getMonth()+1).padStart(2,'0'), da=String(d.getDate()).padStart(2,'0'); return `${y}-${mo}-${da}`; } let residentNamesLoaded = false; const residentGroups = new Map(); const normName = s => String(s || '').trim().replace(/\s+/g, ' ').toLowerCase(); async function loadResidentNames() { if (residentNamesLoaded) return; try { const r = await fetch(`${API}/api/students/names-with-groups`, { headers: hdr() }); if (!r.ok) return; const rows = await r.json(); residentGroups.clear(); (rows||[]).forEach(x => { const k = normName(x.name); if (k && !residentGroups.has(k)) residentGroups.set(k, x.group_id); }); const dl = document.getElementById('fbResidentList'); if (dl) dl.innerHTML = (rows||[]).map(x => `'; if (fsel) fsel.innerHTML = ''; (list||[]).filter(x=>!x.deleted_at).forEach(g => { const opt = document.createElement('option'); opt.value = g.id; opt.textContent = g.name; sel && sel.appendChild(opt.cloneNode(true)); fsel && fsel.appendChild(opt); }); groupsLoaded = true; } } catch (e) {} } async function loadAiProfiles() { try { const r = await fetch(`${API}/api/ai/profiles`, { headers: hdr() }); if (r.ok) { const d = await r.json(); const sel = document.getElementById('fbModel'); if (!sel) return; sel.innerHTML = ''; const nativeOpt = document.createElement('option'); nativeOpt.value = 'native'; nativeOpt.textContent = 'Нативная (llama.cpp)'; sel.appendChild(nativeOpt); (d.profiles||[]).forEach(p => { const o = document.createElement('option'); o.value = p.id; o.textContent = p.name; sel.appendChild(o); }); if (d.active) sel.value = d.active; } } catch (e) {} } async function fetchShorts(q) { if (!q || q.length < 2) { shortsListCache = []; renderShorts([]); return; } try { const r = await fetch(`${API}/api/shorts?search=${encodeURIComponent(q)}&limit=50&deleted=0&used=0`, { headers: hdr() }); if (r.ok) { const j = await r.json(); shortsListCache = j.items || []; renderShorts(shortsListCache); } } catch (e) {} } async function loadLastPastReview() { const resident = document.getElementById('fbResident').value.trim(); if (!resident) return; try { const r = await fetch(`${API}/api/feedbacks/last?resident=${encodeURIComponent(resident)}`, { headers: hdr() }); if (r.ok) { const row = await r.json(); if (row && row.text) document.getElementById('fbPastReview').value = row.text; } } catch (e) {} } function renderShorts(items) { const wrap = document.getElementById('fbShortsWrap'); const list = document.getElementById('fbShortsList'); if (!list || !wrap) return; if (!items.length) { wrap.style.display = 'none'; list.innerHTML = ''; return; } wrap.style.display = ''; list.innerHTML = items.map(s => { const text = (s.message||'').slice(0,120); const topic = s.topic || '-'; return `
`; }).join(''); } function resetForm() { document.getElementById('fbId').value = ''; document.getElementById('fbDate').value = todayIso(); document.getElementById('fbGroup').value = ''; document.getElementById('fbResident').value = ''; document.getElementById('fbTopic').value = ''; document.getElementById('fbPastCheck').checked = false; document.getElementById('fbPastReviewWrap').style.display = 'none'; document.getElementById('fbPastReview').value = ''; document.getElementById('fbShortsWrap').style.display = 'none'; document.getElementById('fbShortsList').innerHTML = ''; document.getElementById('fbText').value = ''; lastFbAutoText = ''; document.getElementById('fbTextHint').textContent = '0 / 5000'; document.getElementById('fbAiCheck').checked = true; document.getElementById('fbAiStatus').hidden = true; document.getElementById('fbModalTitle').textContent = 'Добавить отзыв'; document.getElementById('fbSubmit').textContent = 'Отправить в очередь'; } async function loadFeedback(id) { try { const r = await fetch(`${API}/api/feedbacks/${id}`, { headers: hdr() }); if (r.ok) { const f = await r.json(); document.getElementById('fbId').value = f.id; document.getElementById('fbDate').value = f.feedback_date || todayIso(); document.getElementById('fbGroup').value = f.group_id || ''; document.getElementById('fbResident').value = f.resident || ''; document.getElementById('fbTopic').value = f.topic || ''; if (f.past_review) { document.getElementById('fbPastCheck').checked = true; document.getElementById('fbPastReviewWrap').style.display = ''; document.getElementById('fbPastReview').value = f.past_review; } document.getElementById('fbText').value = f.text || ''; lastFbAutoText = ''; document.getElementById('fbTextHint').textContent = `${(f.text||'').length} / 5000`; document.getElementById('fbAiCheck').checked = (f.ai_status === 'pending' || f.ai_status === 'processing' || f.ai_status === 'none') ? true : false; document.getElementById('fbModalTitle').textContent = 'Редактировать отзыв'; document.getElementById('fbSubmit').textContent = 'Сохранить'; if (f.short_message_ids && f.short_message_ids.length) { try { const list = []; for (const sid of f.short_message_ids.slice(0,20)) { const sr = await fetch(`${API}/api/shorts/${sid}`, { headers: hdr() }); if (sr.ok) list.push(await sr.json()); } renderShorts(list); } catch (e) {} } } } catch (e) {} } async function loadFeedbacks() { const el = document.getElementById('feedbackList'); const st = document.getElementById('feedbackState'); if (!el) return; el.innerHTML = '
Загрузка…
'; const q = new URLSearchParams(); q.set('limit', PAGE_SIZE); q.set('offset', (page-1)*PAGE_SIZE); const s = document.getElementById('fbSearch').value.trim(); if (s) q.set('search', s); const g = document.getElementById('fbGroupFilter').value; if (g) q.set('group_id', g); const stv = document.getElementById('fbStatusFilter').value; if (stv) q.set('status', stv); const df = document.getElementById('fbDateFrom').value; if (df) q.set('date_from', df); const dt = document.getElementById('fbDateTo').value; if (dt) q.set('date_to', dt); if (document.getElementById('fbDeletedToggle').checked) q.set('deleted', '1'); try { const r = await fetch(`${API}/api/feedbacks?${q.toString()}`, { headers: hdr() }); if (r.ok) { const j = await r.json(); feedbacks = j.items || []; total = j.total || 0; el.innerHTML = feedbacks.length ? feedbacks.map(fbItemHtml).join('') : '
Список пуст
'; if (st) st.textContent = `Всего: ${total}`; renderPager(total, page, PAGE_SIZE, p => { page = p; loadFeedbacks(); }); } else { el.innerHTML = '
Ошибка загрузки
'; } } catch (e) { el.innerHTML = '
Ошибка
'; } if (window.lucide) lucide.createIcons(); } function renderPager(total, cur, size, onChange) { const el = document.getElementById('pager'); if (!el) return; const pages = Math.max(1, Math.ceil(total/size)); if (pages <= 1) { el.innerHTML = ''; return; } let html = ''; for (let i=1;i<=pages;i++) { html += ``; } el.innerHTML = html; el.querySelectorAll('[data-page]').forEach(b => b.addEventListener('click', () => onChange(parseInt(b.dataset.page,10)))); } document.addEventListener('DOMContentLoaded', async () => { await checkAuth().catch(()=>{}); buildSidebar('feedback'); await loadGroups(); await loadAiProfiles(); await loadResidentNames(); loadTopics(); loadFeedbacks(); connectFeedbackLive(); document.getElementById('addFeedbackBtn').addEventListener('click', () => openModal({})); document.getElementById('fbCancel').addEventListener('click', closeModal); document.getElementById('feedbackModal').addEventListener('click', e => { if (e.target.id === 'feedbackModal') closeModal(); }); document.getElementById('fbForm').addEventListener('submit', async e => { e.preventDefault(); const id = document.getElementById('fbId').value; const body = { feedback_date: document.getElementById('fbDate').value, group_id: parseInt(document.getElementById('fbGroup').value,10), resident: document.getElementById('fbResident').value.trim(), topic: document.getElementById('fbTopic').value.trim(), past_review: document.getElementById('fbPastCheck').checked ? document.getElementById('fbPastReview').value.trim() : null, text: document.getElementById('fbText').value.trim(), ai_check: document.getElementById('fbAiCheck').checked, ai_profile: document.getElementById('fbModel').value || null, }; const shortIds = []; document.querySelectorAll('input[name="fbShort"]:checked').forEach(c => shortIds.push(parseInt(c.value,10))); body.short_message_ids = shortIds; const url = id ? `${API}/api/feedbacks/${id}` : `${API}/api/feedbacks`; const method = id ? 'PUT' : 'POST'; try { const r = await fetch(url, { method, headers: {...hdr(), 'Content-Type':'application/json'}, body: JSON.stringify(body) }); if (r.ok) { closeModal(); loadFeedbacks(); } } catch (e) {} }); document.getElementById('fbResident').addEventListener('input', () => { clearTimeout(shortsFetchTimer); const v = document.getElementById('fbResident').value.trim(); applyResidentGroup(); shortsFetchTimer = setTimeout(() => fetchShorts(v), 400); }); document.getElementById('fbResident').addEventListener('change', applyResidentGroup); document.getElementById('fbPastCheck').addEventListener('change', e => { document.getElementById('fbPastReviewWrap').style.display = e.target.checked ? '' : 'none'; if (e.target.checked) loadLastPastReview(); }); document.getElementById('fbText').addEventListener('input', () => { document.getElementById('fbTextHint').textContent = `${document.getElementById('fbText').value.length} / 5000`; }); document.getElementById('fbTopic').addEventListener('change', applyTopicText); document.getElementById('feedbackList').addEventListener('click', async e => { const sentBtn = e.target.closest('[data-sent]'); if (sentBtn) { const id = parseInt(sentBtn.dataset.sent, 10); if (!id) return; const f = feedbacks.find(x => x.id === id); const next = !(f && f.sent_at); try { const r = await fetch(`${API}/api/feedbacks/${id}/sent`, { method:'PUT', headers: {...hdr(), 'Content-Type':'application/json'}, body: JSON.stringify({ sent: next }) }); if (r.ok) loadFeedbacks(); } catch (e) {} return; } const edit = e.target.closest('[data-edit]'); if (edit) { openModal({id: parseInt(edit.dataset.edit,10)}); return; } const del = e.target.closest('[data-del]'); if (del) { const id = parseInt(del.dataset.id||del.dataset.del,10); if (!id) return; if (!confirm('Удалить отзыв?')) return; try { const r = await fetch(`${API}/api/feedbacks/${id}`, { method:'DELETE', headers: hdr() }); if (r.ok) loadFeedbacks(); } catch (e) {} return; } const ret = e.target.closest('[data-restore]'); if (ret) { const id = parseInt(ret.dataset.id||ret.dataset.restore,10); if (!id) return; try { const r = await fetch(`${API}/api/feedbacks/${id}/restore`, { method:'PUT', headers: hdr() }); if (r.ok) loadFeedbacks(); } catch (e) {} return; } const retry = e.target.closest('[data-retry]'); if (retry) { const id = parseInt(retry.dataset.id||retry.dataset.retry,10); if (!id) return; try { const r = await fetch(`${API}/api/feedbacks/${id}/ai/retry`, { method:'POST', headers: hdr() }); if (r.ok) loadFeedbacks(); } catch (e) {} return; } }); ['fbSearch','fbGroupFilter','fbStatusFilter','fbDateFrom','fbDateTo','fbDeletedToggle'].forEach(id => { document.getElementById(id).addEventListener('change', () => { page = 1; loadFeedbacks(); }); if (id === 'fbSearch') document.getElementById(id).addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { page = 1; loadFeedbacks(); }, 350); }); }); if (window.lucide) lucide.createIcons(); }); let liveSource = null, liveReconnectTimer = null; function connectFeedbackLive() { if (liveSource) { liveSource.close(); liveSource = null; } const connect = () => { const t = sessionStorage.getItem('authToken'); if (!t) return; liveSource = new EventSource(`${API}/api/events?token=${encodeURIComponent(t)}`); liveSource.addEventListener('feedbacks_changed', () => { page = 1; loadFeedbacks(); }); liveSource.addEventListener('feedback_status', () => { loadFeedbacks(); }); liveSource.onerror = () => { if (liveSource) { liveSource.close(); liveSource = null; } if (liveReconnectTimer) clearTimeout(liveReconnectTimer); liveReconnectTimer = setTimeout(connect, 5000); }; }; connect(); } })();