Add feedback system functionality
- Implement feedback management system in admin interface - Add feedback processing and AI-based features - Update user interface with new feedback components - Modify server-side API endpoints for feedback handling - Enhance worker.js for background feedback processing - Update deployment configuration for feedback components Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -81,6 +81,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.filters{display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;align-items:flex-end}
|
||||
.filters select,.filters input[type=date]{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem}
|
||||
.filters label{font-size:.8rem;color:var(--muted);display:block;margin-bottom:4px}
|
||||
.filters label.toggle{display:flex;align-items:center;gap:8px;margin-bottom:0;padding:6px 10px;color:var(--text);background:transparent;border-color:transparent;min-width:0}
|
||||
.filters label.toggle .toggle-text{font-size:.8rem;white-space:nowrap}
|
||||
.filters > div{min-width:0}
|
||||
.search-input{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none}
|
||||
.search-input:focus{border-color:var(--accent)}
|
||||
#studentFilter,#linkStudent,#moduleFilter{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
|
||||
@@ -489,6 +492,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
.ai-badge-done{background:rgba(22,163,74,.12);color:#16a34a}
|
||||
.ai-badge-error{background:rgba(239,68,68,.12);color:#ef4444}
|
||||
.ai-badge-skipped,.ai-badge-reverted{background:rgba(148,163,184,.18);color:var(--muted)}
|
||||
.ai-badge-sent{background:rgba(20,184,166,.12);color:#14b8a6}
|
||||
.ai-badge-ic.ai-badge-processing .lucide-loader{animation:spin 1s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.ai-details{background:none;border:none;padding:0;margin:0;color:var(--accent);font-size:.8rem;cursor:pointer;white-space:nowrap}
|
||||
|
||||
@@ -51,6 +51,7 @@ function buildSidebar(active) {
|
||||
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
|
||||
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
|
||||
{ page: 'lessons', label: 'Отчёты о занятиях', icon: 'notebook-pen' },
|
||||
{ page: 'feedback', label: 'Обратная связь', icon: 'message-square-heart' },
|
||||
{ page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true },
|
||||
{ page: 'chat', label: 'Чаты', icon: 'messages-square', chatBadge: true },
|
||||
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Обратная связь — WhatIDo</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
<style>
|
||||
.feedback-list { display:flex; flex-direction:column; gap:10px; }
|
||||
.feedback-item { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:10px 12px; display:grid; grid-template-columns:120px 140px 160px 100px 140px 1fr 160px; gap:10px; align-items:center; }
|
||||
.feedback-item .col { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.feedback-item .text { color:var(--text-secondary); font-size:13px; }
|
||||
.feedback-item .actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.card-stack { flex-direction: column; align-items: stretch; }
|
||||
.card-stack .filters { margin-bottom: 0; }
|
||||
.feedback-item .btn-xs.sent-on { background:var(--accent); color:#fff; }
|
||||
@media (max-width: 1100px) {
|
||||
.feedback-item { grid-template-columns:120px 140px 160px 100px 140px 1fr; }
|
||||
.feedback-item .actions { grid-column:1/-1; justify-content:flex-end; }
|
||||
}
|
||||
.shorts-list { display:flex; flex-direction:column; gap:6px; width:100%; max-height:220px; overflow:auto; padding:2px 0; }
|
||||
.shorts-list .item { display:flex; gap:8px; align-items:flex-start; }
|
||||
.shorts-list .item label { flex:1; font-size:13px; line-height:1.3; color:var(--text); cursor:pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="feedback">
|
||||
<div class="app">
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
<main class="main">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h2>Обратная связь</h2>
|
||||
<div class="page-sub">Отзывы тьютора по итогам занятий</div>
|
||||
</div>
|
||||
<button class="btn-primary" id="addFeedbackBtn"><span data-lucide="plus"></span> Добавить отзыв</button>
|
||||
</header>
|
||||
<section class="card card-stack">
|
||||
<div class="filters">
|
||||
<div><label>Поиск</label><input type="text" id="fbSearch" class="search-input" placeholder="Поиск по ФИО, теме, тексту…"></div>
|
||||
<div><label>Группа</label><select id="fbGroupFilter"><option value="">Все группы</option></select></div>
|
||||
<div><label>Статус</label><select id="fbStatusFilter">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="sent">Отправлено</option>
|
||||
<option value="none">Без ИИ</option>
|
||||
<option value="pending">Ожидает</option>
|
||||
<option value="processing">В обработке</option>
|
||||
<option value="done">Готово</option>
|
||||
<option value="error">Ошибка</option>
|
||||
<option value="skipped">Пропущен</option>
|
||||
</select></div>
|
||||
<div><label>С</label><input type="date" id="fbDateFrom"></div>
|
||||
<div><label>По</label><input type="date" id="fbDateTo"></div>
|
||||
<div><label> </label>
|
||||
<label class="toggle" style="align-items:center">
|
||||
<input type="checkbox" id="fbDeletedToggle">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text">Показать удалённые</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<div class="feedback-list" id="feedbackList"></div>
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<div id="feedbackState" class="muted"></div>
|
||||
<div id="pager"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="feedbackModal">
|
||||
<div class="edit-modal" style="max-width:680px">
|
||||
<h3 id="fbModalTitle">Добавить отзыв</h3>
|
||||
<form id="fbForm" class="settings-stack" autocomplete="off">
|
||||
<input type="hidden" id="fbId">
|
||||
<div class="settings-stack-grid">
|
||||
<div class="settings-field">
|
||||
<label>Дата занятия <span style="color:#ef4444">*</span></label>
|
||||
<input type="date" id="fbDate" class="settings-input" required>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Группа <span style="color:#ef4444">*</span></label>
|
||||
<select id="fbGroup" class="settings-input" required></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-stack-grid">
|
||||
<div class="settings-field">
|
||||
<label>Фамилия/Имя резидента <span style="color:#ef4444">*</span></label>
|
||||
<input type="text" id="fbResident" class="settings-input" required maxlength="200" placeholder="Иванов Иван" list="fbResidentList" autocomplete="off">
|
||||
<datalist id="fbResidentList"></datalist>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Тема занятия</label>
|
||||
<input type="text" id="fbTopic" class="settings-input" maxlength="500" placeholder="Тема занятия" list="fbTopicList" autocomplete="off">
|
||||
<datalist id="fbTopicList"></datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Текст отзыва</label>
|
||||
<textarea id="fbText" class="settings-input" rows="5" maxlength="5000" placeholder="Текст отзыва (может быть сгенерирован ИИ)"></textarea>
|
||||
<div class="hint"><span id="fbTextHint">0 / 5000</span></div>
|
||||
</div>
|
||||
<div class="settings-stack-grid" style="align-items:start">
|
||||
<label class="toggle" style="align-items:center">
|
||||
<input type="checkbox" id="fbAiCheck" checked>
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Сгенерировать отзыв ИИ</b><small>Будет отправлено в очередь</small></span>
|
||||
</label>
|
||||
<div class="settings-field">
|
||||
<label>Модель ИИ</label>
|
||||
<select id="fbModel" class="settings-input"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label class="toggle" style="align-items:center">
|
||||
<input type="checkbox" id="fbPastCheck">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text">Добавить в промпт прошлый отзыв</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-field" id="fbPastReviewWrap" style="display:none">
|
||||
<label>Прошлый отзыв</label>
|
||||
<textarea id="fbPastReview" class="settings-input" rows="3" maxlength="2000"></textarea>
|
||||
</div>
|
||||
<div class="settings-field" id="fbShortsWrap" style="display:none">
|
||||
<label>Выберите короткие сообщения для обратной связи (отмечены все)</label>
|
||||
<div class="shorts-list" id="fbShortsList"></div>
|
||||
<div class="hint">Эти сообщения будут переданы в промпт как контекст</div>
|
||||
</div>
|
||||
<div class="ai-status" id="fbAiStatus" hidden></div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" id="fbCancel">Отмена</button>
|
||||
<button type="submit" class="btn-primary" id="fbSubmit">Отправить в очередь</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="vendor/lucide.min.js"></script>
|
||||
<script src="js/datetime.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
<script src="js/feedback.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,398 @@
|
||||
(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,'"').replace(/'/g,'''); }
|
||||
|
||||
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 short = text.length > 100 ? text.slice(0,97)+'…' : text;
|
||||
const del = f.deleted_at ? ' style="opacity:.5"' : '';
|
||||
return `<div class="feedback-item" data-id="${f.id}"${del}>
|
||||
<div class="col">${fmtDate(f.feedback_date)}</div>
|
||||
<div class="col">${escapeHtml(f.topic||'')}</div>
|
||||
<div class="col">${escapeHtml(f.resident||'')}</div>
|
||||
<div class="col">${fmtDay(f.feedback_date)}</div>
|
||||
<div class="col">${escapeHtml(f.group_name||'')}</div>
|
||||
<div class="col text">${escapeHtml(short)}</div>
|
||||
<div class="actions">
|
||||
${aiBadge(f.ai_status)}
|
||||
${f.sent_at ? '<span class="ai-badge ai-badge-sent">Отправлено</span>' : ''}
|
||||
${f.ai_status === 'error' ? `<button class="btn-xs" data-retry="${f.id}">Повторить</button>` : ''}
|
||||
${f.deleted_at ? '' : `<button class="btn-xs ${f.sent_at ? 'sent-on' : ''}" data-sent="${f.id}">${f.sent_at ? 'Вернуть' : 'Отправлено'}</button>`}
|
||||
<button class="btn-xs" data-edit="${f.id}">Изменить</button>
|
||||
<button class="btn-xs danger" data-del="${f.id}">${f.deleted_at ? 'Удалить навсегда' : 'Удалить'}</button>
|
||||
${f.deleted_at ? `<button class="btn-xs" data-restore="${f.id}">Восстановить</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 => `<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`, { 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`, { headers: hdr() });
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
shortsListCache = j.items || []; renderShorts(shortsListCache);
|
||||
}
|
||||
} 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;
|
||||
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 = '<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 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';
|
||||
});
|
||||
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'].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); });
|
||||
});
|
||||
document.getElementById('fbDeletedToggle').addEventListener('change', () => { page = 1; loadFeedbacks(); });
|
||||
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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user