(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();
}
})();