Files
WhatIDo/public/js/feedback.js
T
dev a9fb7aaf77 feat(feedback): personal AI models, AI run tracking and profiles/options endpoint
- user_settings allowlist: feedback_models (up to 10 personal model configs)
- normalizeFeedbackModels() validation, UI block "Мои модели" in #fbPromptModal
- GET /api/ai/profiles/options for tutors (no api_key), used by feedback/shorts
- feedbacks columns ai_prompt/ai_model/ai_duration_ms + backup/restore support
- "ИИ-запрос" popover on feedback card with status, model, timing and prompt
- audit: feedback.update / feedback.ai.run / feedback.ai.error labels
2026-10-07 16:30:27 +03:00

688 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(async function() {
const API = location.origin;
const PAGE_SIZE = 20;
let page = 1, total = 0, searchTimer, groupsLoaded, feedbacks = [], shortsListCache = null, shortsFetchTimer = null, lastFbAutoText = '', userSettings = { settings: {}, defaults: {} }, aiActive = 'native', myModels = [], modelEditIdx = null;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;'); }
function aiBadge(s) {
switch (s) {
case 'pending': return '<span class="ai-badge pending">Ожидает</span>';
case 'processing': return '<span class="ai-badge processing">В обработке</span>';
case 'done': return '<span class="ai-badge done">Готово</span>';
case 'error': return '<span class="ai-badge error">Ошибка</span>';
case 'skipped': return '<span class="ai-badge skipped">Пропущен</span>';
default: return '<span class="ai-badge none">Без ИИ</span>';
}
}
function fbItemHtml(f) {
const text = (f.text||'').trim();
const del = f.deleted_at ? ' style="opacity:.5"' : '';
const day = fmtDay(f.feedback_date);
const hasAiInfo = f.ai_status !== 'none' || !!f.ai_prompt;
return `<div class="lesson-item feedback-item" data-id="${f.id}"${del}>
<div class="lesson-item-head">
<span class="lesson-author">${escapeHtml(f.group_name||'')}</span>
<span class="lesson-when">${fmtDate(f.feedback_date)}${day ? ` · ${day}` : ''}</span>
${f.deleted_at ? '<span class="lesson-badge">Удалён</span>' : ''}
${aiBadge(f.ai_status)}
${f.sent_at ? '<span class="ai-badge ai-badge-sent">Отправлено</span>' : ''}
<div class="lesson-actions">
${hasAiInfo ? `<button type="button" class="btn-link" data-aiinfo="${f.id}">ИИ-запрос</button>` : ''}
${f.ai_status === 'error' ? `<button type="button" class="btn-link" data-retry="${f.id}">Повторить</button>` : ''}
${f.deleted_at ? '' : `<button type="button" class="btn-link" data-sent="${f.id}">${f.sent_at ? 'Вернуть' : 'Отправлено'}</button>`}
<button type="button" class="btn-link" data-edit="${f.id}">Изменить</button>
<button type="button" class="btn-link danger" data-del="${f.id}">${f.deleted_at ? 'Удалить навсегда' : 'Удалить'}</button>
${f.deleted_at ? `<button type="button" class="btn-link" data-restore="${f.id}">Восстановить</button>` : ''}
</div>
</div>
${f.resident ? `<div class="lesson-group">${escapeHtml(f.resident)}</div>` : ''}
${f.topic ? `<div class="lesson-topic">${escapeHtml(f.topic)}</div>` : ''}
${text ? `<div class="lesson-text">${escapeHtml(text)}</div>` : ''}
<div class="fb-ai-pop" id="fbAiPop-${f.id}" hidden>${fbAiInfoHtml(f)}</div>
</div>`;
}
function fmtDurMs(ms) {
if (!Number.isFinite(ms) || ms < 0) return '';
return ms >= 1000 ? `${(ms / 1000).toFixed(1)} с` : `${Math.round(ms)} мс`;
}
function fmtTs(v) {
if (!v) return '';
try { if (typeof fmtFull === 'function') return fmtFull(v) || String(v); } catch (e) {}
return String(v);
}
function fbAiInfoHtml(f) {
let html = `<div class="fb-ai-row"><span>Статус</span>${aiBadge(f.ai_status)}</div>`;
html += `<div class="fb-ai-row"><span>Модель</span><b>${escapeHtml(f.ai_model || '—')}</b></div>`;
const dur = fmtDurMs(f.ai_duration_ms);
html += `<div class="fb-ai-row"><span>Время ответа</span><b>${escapeHtml(dur || '—')}</b></div>`;
if (f.ai_checked_at) html += `<div class="fb-ai-row"><span>Проверено</span><b>${escapeHtml(fmtTs(f.ai_checked_at))}</b></div>`;
if (f.ai_error) html += `<div class="fb-ai-err"><b>Ошибка:</b> ${escapeHtml(f.ai_error)}</div>`;
if (f.ai_prompt) {
html += `<div class="fb-ai-label">Промпт, отправленный в модель</div><pre>${escapeHtml(f.ai_prompt)}</pre>`;
} else {
html += '<div class="fb-ai-empty">Нет данных о запуске ИИ</div>';
}
return html;
}
function closeAiPops() {
document.querySelectorAll('.fb-ai-pop').forEach(p => { p.hidden = true; });
}
document.addEventListener('click', e => {
if (e.target.closest('[data-aiinfo]') || e.target.closest('.fb-ai-pop')) return;
closeAiPops();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAiPops(); });
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');
closePromptModal();
}
function closePromptModal() {
const m = document.getElementById('fbPromptModal');
if (m) m.classList.remove('open');
}
function updatePromptCount() {
const el = document.getElementById('fbPromptCount');
const ta = document.getElementById('fbPromptText');
if (el && ta) el.textContent = `${ta.value.length} / 8000`;
}
async function loadUserSettings() {
try {
const r = await fetch(`${API}/api/user-settings`, { headers: hdr() });
if (!r.ok) return;
const j = await r.json();
userSettings.settings = j.settings || {};
userSettings.defaults = j.defaults || {};
parseMyModels();
} catch (e) {}
}
function parseMyModels() {
try { myModels = JSON.parse(userSettings.settings.feedback_models || '[]'); } catch (e) { myModels = []; }
if (!Array.isArray(myModels)) myModels = [];
}
function renderModelsList() {
const el = document.getElementById('fbModelsList');
if (!el) return;
const cnt = document.getElementById('fbModelsCount');
if (cnt) cnt.textContent = myModels.length ? ` (${myModels.length})` : '';
if (!myModels.length) {
el.innerHTML = '<div class="hint">Своих моделей нет — используются системные</div>';
return;
}
el.innerHTML = myModels.map((m, i) => `
<div style="display:flex;justify-content:space-between;gap:8px;align-items:center;padding:4px 0">
<span>${escapeHtml(m.name)} — <span class="muted">${escapeHtml(m.model)}</span></span>
<span style="display:flex;gap:6px">
<button type="button" class="btn-xs" data-medit="${i}">Изменить</button>
<button type="button" class="btn-xs" data-mdel="${i}">Удалить</button>
</span>
</div>`).join('');
}
function showModelForm(idx) {
modelEditIdx = idx;
const m = idx == null ? null : myModels[idx];
document.getElementById('fbMName').value = m ? m.name : '';
document.getElementById('fbMModel').value = m ? m.model : '';
document.getElementById('fbMUrl').value = m ? m.base_url : '';
document.getElementById('fbMKey').value = m ? (m.api_key || '') : '';
document.getElementById('fbMMaxTok').value = m && m.max_tokens ? m.max_tokens : '';
document.getElementById('fbModelEdit').hidden = false;
document.getElementById('fbMName').focus();
}
function hideModelForm() {
const el = document.getElementById('fbModelEdit');
if (el) el.hidden = true;
modelEditIdx = null;
}
function saveModelForm() {
const name = document.getElementById('fbMName').value.trim();
const model = document.getElementById('fbMModel').value.trim();
const base = document.getElementById('fbMUrl').value.trim().replace(/\/+$/, '');
const key = document.getElementById('fbMKey').value.trim();
const tok = document.getElementById('fbMMaxTok').value.trim();
if (!name) return alert('Укажите название модели');
if (!model) return alert('Укажите имя модели');
try {
const u = new URL(base);
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad');
} catch (e) { return alert('Base URL должен начинаться с http:// или https://'); }
let maxTokens = null;
if (tok) {
maxTokens = parseInt(tok, 10);
if (!Number.isFinite(maxTokens) || maxTokens < 16 || maxTokens > 131072) return alert('max_tokens: 16–131072');
}
if (modelEditIdx == null && myModels.length >= 10) return alert('Не больше 10 моделей');
const entry = {
id: modelEditIdx == null ? `me-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` : myModels[modelEditIdx].id,
name, model, base_url: base, api_key: key, max_tokens: maxTokens
};
if (modelEditIdx == null) myModels.push(entry); else myModels[modelEditIdx] = entry;
hideModelForm();
renderModelsList();
refreshModelSelects();
}
function deleteModel(i) {
const m = myModels[i];
if (!m) return;
if (!confirm(`Удалить модель «${m.name}»?`)) return;
myModels.splice(i, 1);
const dst = document.getElementById('fbDefaultModel');
if (dst && dst.value === m.id) dst.value = '';
renderModelsList();
refreshModelSelects();
}
function fillDefaultModelOptions(preserve) {
const src = document.getElementById('fbModel');
const dst = document.getElementById('fbDefaultModel');
if (!src || !dst) return;
dst.innerHTML = '<option value="">Активная модель по умолчанию</option>' + src.innerHTML;
dst.value = preserve || '';
if (preserve && dst.value !== preserve) {
const o = document.createElement('option');
o.value = preserve;
o.textContent = `${preserve} (недоступна)`;
dst.appendChild(o);
dst.value = preserve;
}
}
async function refreshModelSelects() {
const keep = document.getElementById('fbDefaultModel').value;
await loadAiProfiles();
fillDefaultModelOptions(keep);
}
function openPromptModal() {
fillDefaultModelOptions(userSettings.settings.feedback_profile);
const ta = document.getElementById('fbPromptText');
ta.value = userSettings.settings.feedback_prompt || '';
ta.placeholder = userSettings.defaults.feedback_prompt || 'Системный шаблон';
document.getElementById('fbPromptStatus').hidden = true;
hideModelForm();
renderModelsList();
updatePromptCount();
document.getElementById('fbPromptModal').classList.add('open');
if (window.lucide) lucide.createIcons();
}
async function savePromptSettings() {
const st = document.getElementById('fbPromptStatus');
if (!document.getElementById('fbModel').options.length) {
st.hidden = false;
st.innerHTML = '<span class="ai-badge error">Список моделей не загружен — сохранение отменено</span>';
return;
}
const body = {
settings: {
feedback_prompt: document.getElementById('fbPromptText').value.trim(),
feedback_profile: document.getElementById('fbDefaultModel').value || '',
feedback_models: myModels.length ? JSON.stringify(myModels) : '',
}
};
try {
const r = await fetch(`${API}/api/user-settings`, { method: 'PUT', headers: {...hdr(), 'Content-Type': 'application/json'}, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
if (!r.ok) {
st.hidden = false;
st.innerHTML = `<span class="ai-badge error">${escapeHtml(j.error || 'Не удалось сохранить')}</span>`;
return;
}
userSettings.settings = j.settings || {};
userSettings.defaults = j.defaults || userSettings.defaults;
parseMyModels();
renderModelsList();
await loadAiProfiles();
st.hidden = false;
st.innerHTML = '<span class="ai-badge done">Сохранено</span>';
setTimeout(closePromptModal, 700);
} catch (e) {
st.hidden = false;
st.innerHTML = '<span class="ai-badge error">Не удалось сохранить</span>';
}
}
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 => `<option value="${escapeHtml(x.name)}">`).join('');
residentNamesLoaded = true;
} catch (e) {}
}
function applyResidentGroup() {
const k = normName(document.getElementById('fbResident').value);
if (!k || !residentGroups.has(k)) return;
const gid = residentGroups.get(k);
const sel = document.getElementById('fbGroup');
if (!gid || !sel) return;
if (![...sel.options].some(o => o.value === String(gid))) return;
sel.value = String(gid);
}
let topicsLoaded = false;
async function loadTopics() {
if (topicsLoaded) return;
try {
const r = await fetch(`${API}/api/lesson-reports/topics`, { headers: hdr() });
if (!r.ok) return;
const topics = await r.json();
const dl = document.getElementById('fbTopicList');
if (dl) dl.innerHTML = (topics||[]).map(t => `<option value="${escapeHtml(t)}">`).join('');
topicsLoaded = true;
} catch (e) {}
}
async function applyTopicText() {
const topicEl = document.getElementById('fbTopic');
const textEl = document.getElementById('fbText');
const hint = document.getElementById('fbTextHint');
const topic = topicEl.value.trim();
if (!topic) return;
let rep = topicReportCache.get(topic);
if (rep === undefined) {
try {
const r = await fetch(`${API}/api/lesson-reports/by-topic?topic=${encodeURIComponent(topic)}`, { headers: hdr() });
rep = r.ok ? (await r.json()).report : null;
} catch (e) { return; }
topicReportCache.set(topic, rep);
}
const next = (rep && rep.text || '').trim();
if (!next) return;
const cur = textEl.value;
if (cur.trim() && cur !== lastFbAutoText) return;
textEl.value = next;
lastFbAutoText = next;
hint.textContent = `${next.length} / 5000`;
}
async function loadGroups() {
if (groupsLoaded) return;
try {
const r = await fetch(`${API}/api/groups`, { headers: hdr() });
if (r.ok) {
const list = await r.json();
const sel = document.getElementById('fbGroup');
const fsel = document.getElementById('fbGroupFilter');
if (sel) sel.innerHTML = '<option value="">-- выберите --</option>';
if (fsel) fsel.innerHTML = '<option value="">Все группы</option>';
(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/options`, { headers: hdr() });
if (r.ok) {
const d = await r.json();
const sel = document.getElementById('fbModel');
if (!sel) return;
const keep = sel.value;
sel.innerHTML = '';
if (myModels.length) {
const mine = document.createElement('optgroup');
mine.label = 'Мои модели';
myModels.forEach(m => {
const o = document.createElement('option');
o.value = m.id;
o.textContent = m.name;
mine.appendChild(o);
});
sel.appendChild(mine);
}
const sys = document.createElement('optgroup');
sys.label = 'Системные';
const nativeOpt = document.createElement('option'); nativeOpt.value = 'native'; nativeOpt.textContent = 'Нативная (llama.cpp)'; sys.appendChild(nativeOpt);
(d.profiles||[]).forEach(p => { const o = document.createElement('option'); o.value = p.id; o.textContent = p.name; sys.appendChild(o); });
sel.appendChild(sys);
aiActive = d.active || 'native';
if (keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
else sel.value = aiActive;
}
} 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 `<div class="item"><input type="checkbox" name="fbShort" value="${s.id}" checked><label>${escapeHtml(s.lesson_date||'')} – ${escapeHtml(topic)} – ${escapeHtml(text)}</label></div>`;
}).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;
const ms = document.getElementById('fbModel');
const pref = userSettings.settings.feedback_profile;
if (pref && [...ms.options].some(o => o.value === pref)) ms.value = pref;
else if (aiActive && [...ms.options].some(o => o.value === aiActive)) ms.value = aiActive;
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;
const fms = document.getElementById('fbModel');
if (f.ai_profile && [...fms.options].some(o => o.value === f.ai_profile)) fms.value = f.ai_profile;
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 = '<div class="muted">Загрузка…</div>';
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('') : '<div class="muted">Список пуст</div>';
if (st) st.textContent = `Всего: ${total}`;
renderPager(total, page, PAGE_SIZE, p => { page = p; loadFeedbacks(); });
} else { el.innerHTML = '<div class="muted">Ошибка загрузки</div>'; }
} catch (e) { el.innerHTML = '<div class="muted">Ошибка</div>'; }
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 += `<button class="btn-xs ${i===cur?'primary':''}" data-page="${i}">${i}</button>`;
}
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 loadUserSettings();
await loadAiProfiles();
await loadResidentNames();
loadTopics();
loadFeedbacks();
connectFeedbackLive();
document.getElementById('addFeedbackBtn').addEventListener('click', () => openModal({}));
document.getElementById('fbCancel').addEventListener('click', closeModal);
document.getElementById('fbPromptBtn').addEventListener('click', openPromptModal);
document.getElementById('fbPromptClose').addEventListener('click', closePromptModal);
document.getElementById('fbPromptModal').addEventListener('click', e => { if (e.target.id === 'fbPromptModal') closePromptModal(); });
document.getElementById('fbPromptText').addEventListener('input', updatePromptCount);
document.getElementById('fbPromptReset').addEventListener('click', () => {
document.getElementById('fbPromptText').value = '';
document.getElementById('fbDefaultModel').value = '';
document.getElementById('fbPromptStatus').hidden = true;
updatePromptCount();
});
document.getElementById('fbPromptSave').addEventListener('click', savePromptSettings);
document.getElementById('fbModelAdd').addEventListener('click', () => showModelForm(null));
document.getElementById('fbMCancel').addEventListener('click', hideModelForm);
document.getElementById('fbMSave').addEventListener('click', saveModelForm);
document.getElementById('fbModelsList').addEventListener('click', e => {
const ed = e.target.closest('[data-medit]');
if (ed) { showModelForm(parseInt(ed.dataset.medit, 10)); return; }
const del = e.target.closest('[data-mdel]');
if (del) deleteModel(parseInt(del.dataset.mdel, 10));
});
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 aiinfo = e.target.closest('[data-aiinfo]'); if (aiinfo) {
const pop = document.getElementById(`fbAiPop-${aiinfo.dataset.aiinfo}`);
if (pop) { const wasOpen = !pop.hidden; closeAiPops(); pop.hidden = wasOpen; }
return;
}
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;
const row = feedbacks.find(x => x.id === id);
const perm = !!(row && row.deleted_at);
if (!confirm(perm ? 'Удалить отзыв навсегда? Это действие нельзя отменить.' : 'Удалить отзыв?')) return;
const url = perm ? `${API}/api/feedbacks/${id}/permanent` : `${API}/api/feedbacks/${id}`;
try {
const r = await fetch(url, { method:'DELETE', headers: hdr() });
if (r.ok) { loadFeedbacks(); return; }
const err = await r.json().catch(() => null);
alert((err && err.error) || `Ошибка ${r.status}`);
} catch (err) { alert('Ошибка сети'); }
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(); return; }
const err = await r.json().catch(() => null);
alert((err && err.error) || `Ошибка ${r.status}`);
} catch (err) { alert('Ошибка сети'); }
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();
}
})();