feat(feedback): improve feedback workflow
This commit is contained in:
+35
-50
@@ -7,65 +7,50 @@
|
|||||||
<link rel="stylesheet" href="admin.css">
|
<link rel="stylesheet" href="admin.css">
|
||||||
<style>
|
<style>
|
||||||
.feedback-list { display:flex; flex-direction:column; gap:10px; }
|
.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 .ai-badge { margin-left:0; }
|
||||||
.feedback-item .col { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
.feedback-item .btn-link.sent-on { background:var(--accent); color:#fff; border-color:var(--accent); }
|
||||||
.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 { 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 { 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; }
|
.shorts-list .item label { flex:1; font-size:13px; line-height:1.3; color:var(--text); cursor:pointer; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-page="feedback">
|
<body data-page="feedback">
|
||||||
<div class="app">
|
<div class="layout">
|
||||||
<aside class="sidebar" id="sidebar"></aside>
|
<aside class="sidebar" id="sidebar"></aside>
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<header class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<h2>Обратная связь</h2>
|
||||||
<h2>Обратная связь</h2>
|
<p class="page-sub">Отзывы тьютора по итогам занятий</p>
|
||||||
<div class="page-sub">Отзывы тьютора по итогам занятий</div>
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<input type="checkbox" id="fbDeletedToggle">
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text">Удалённые</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-primary" id="addFeedbackBtn"><span data-lucide="plus"></span> Добавить отзыв</button>
|
<div><label> </label><button type="button" class="btn-primary" id="addFeedbackBtn"><span data-lucide="plus"></span> Добавить отзыв</button></div>
|
||||||
</header>
|
</div>
|
||||||
<section class="card card-stack">
|
|
||||||
<div class="filters">
|
<div class="hint" id="feedbackState" style="margin-bottom:12px"></div>
|
||||||
<div><label>Поиск</label><input type="text" id="fbSearch" class="search-input" placeholder="Поиск по ФИО, теме, тексту…"></div>
|
<div class="feedback-list" id="feedbackList"><div class="empty">Загрузка…</div></div>
|
||||||
<div><label>Группа</label><select id="fbGroupFilter"><option value="">Все группы</option></select></div>
|
<div class="pager" id="pager"></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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+31
-17
@@ -25,24 +25,26 @@
|
|||||||
|
|
||||||
function fbItemHtml(f) {
|
function fbItemHtml(f) {
|
||||||
const text = (f.text||'').trim();
|
const text = (f.text||'').trim();
|
||||||
const short = text.length > 100 ? text.slice(0,97)+'…' : text;
|
|
||||||
const del = f.deleted_at ? ' style="opacity:.5"' : '';
|
const del = f.deleted_at ? ' style="opacity:.5"' : '';
|
||||||
return `<div class="feedback-item" data-id="${f.id}"${del}>
|
const day = fmtDay(f.feedback_date);
|
||||||
<div class="col">${fmtDate(f.feedback_date)}</div>
|
return `<div class="lesson-item feedback-item" data-id="${f.id}"${del}>
|
||||||
<div class="col">${escapeHtml(f.topic||'')}</div>
|
<div class="lesson-item-head">
|
||||||
<div class="col">${escapeHtml(f.resident||'')}</div>
|
<span class="lesson-group">${escapeHtml(f.group_name||'')}</span>
|
||||||
<div class="col">${fmtDay(f.feedback_date)}</div>
|
<span class="lesson-when">${fmtDate(f.feedback_date)}${day ? ` · ${day}` : ''}</span>
|
||||||
<div class="col">${escapeHtml(f.group_name||'')}</div>
|
${f.deleted_at ? '<span class="lesson-badge">Удалён</span>' : ''}
|
||||||
<div class="col text">${escapeHtml(short)}</div>
|
|
||||||
<div class="actions">
|
|
||||||
${aiBadge(f.ai_status)}
|
${aiBadge(f.ai_status)}
|
||||||
${f.sent_at ? '<span class="ai-badge ai-badge-sent">Отправлено</span>' : ''}
|
${f.sent_at ? '<span class="ai-badge ai-badge-sent">Отправлено</span>' : ''}
|
||||||
${f.ai_status === 'error' ? `<button class="btn-xs" data-retry="${f.id}">Повторить</button>` : ''}
|
<div class="lesson-actions">
|
||||||
${f.deleted_at ? '' : `<button class="btn-xs ${f.sent_at ? 'sent-on' : ''}" data-sent="${f.id}">${f.sent_at ? 'Вернуть' : 'Отправлено'}</button>`}
|
${f.ai_status === 'error' ? `<button type="button" class="btn-link" data-retry="${f.id}">Повторить</button>` : ''}
|
||||||
<button class="btn-xs" data-edit="${f.id}">Изменить</button>
|
${f.deleted_at ? '' : `<button type="button" class="btn-link" data-sent="${f.id}">${f.sent_at ? 'Вернуть' : 'Отправлено'}</button>`}
|
||||||
<button class="btn-xs danger" data-del="${f.id}">${f.deleted_at ? 'Удалить навсегда' : 'Удалить'}</button>
|
<button type="button" class="btn-link" data-edit="${f.id}">Изменить</button>
|
||||||
${f.deleted_at ? `<button class="btn-xs" data-restore="${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>
|
</div>
|
||||||
|
${f.resident ? `<div class="lesson-author">${escapeHtml(f.resident)}</div>` : ''}
|
||||||
|
${f.topic ? `<div class="lesson-topic">${escapeHtml(f.topic)}</div>` : ''}
|
||||||
|
${text ? `<div class="lesson-text">${escapeHtml(text)}</div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +177,7 @@
|
|||||||
async function fetchShorts(q) {
|
async function fetchShorts(q) {
|
||||||
if (!q || q.length < 2) { shortsListCache = []; renderShorts([]); return; }
|
if (!q || q.length < 2) { shortsListCache = []; renderShorts([]); return; }
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${API}/api/shorts?search=${encodeURIComponent(q)}&limit=50&deleted=0`, { headers: hdr() });
|
const r = await fetch(`${API}/api/shorts?search=${encodeURIComponent(q)}&limit=50&deleted=0&used=0`, { headers: hdr() });
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
shortsListCache = j.items || []; renderShorts(shortsListCache);
|
shortsListCache = j.items || []; renderShorts(shortsListCache);
|
||||||
@@ -183,6 +185,18 @@
|
|||||||
} catch (e) {}
|
} 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) {
|
function renderShorts(items) {
|
||||||
const wrap = document.getElementById('fbShortsWrap');
|
const wrap = document.getElementById('fbShortsWrap');
|
||||||
const list = document.getElementById('fbShortsList');
|
const list = document.getElementById('fbShortsList');
|
||||||
@@ -335,6 +349,7 @@
|
|||||||
document.getElementById('fbResident').addEventListener('change', applyResidentGroup);
|
document.getElementById('fbResident').addEventListener('change', applyResidentGroup);
|
||||||
document.getElementById('fbPastCheck').addEventListener('change', e => {
|
document.getElementById('fbPastCheck').addEventListener('change', e => {
|
||||||
document.getElementById('fbPastReviewWrap').style.display = e.target.checked ? '' : 'none';
|
document.getElementById('fbPastReviewWrap').style.display = e.target.checked ? '' : 'none';
|
||||||
|
if (e.target.checked) loadLastPastReview();
|
||||||
});
|
});
|
||||||
document.getElementById('fbText').addEventListener('input', () => {
|
document.getElementById('fbText').addEventListener('input', () => {
|
||||||
document.getElementById('fbTextHint').textContent = `${document.getElementById('fbText').value.length} / 5000`;
|
document.getElementById('fbTextHint').textContent = `${document.getElementById('fbText').value.length} / 5000`;
|
||||||
@@ -370,11 +385,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
['fbSearch','fbGroupFilter','fbStatusFilter','fbDateFrom','fbDateTo'].forEach(id => {
|
['fbSearch','fbGroupFilter','fbStatusFilter','fbDateFrom','fbDateTo','fbDeletedToggle'].forEach(id => {
|
||||||
document.getElementById(id).addEventListener('change', () => { page = 1; loadFeedbacks(); });
|
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 (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();
|
if (window.lucide) lucide.createIcons();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2750,8 +2750,11 @@ app.get('/api/shorts', requireAuth, async (req, res) => {
|
|||||||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||||||
const showDeleted = req.query.deleted === '1' || req.query.deleted === 'true';
|
const showDeleted = req.query.deleted === '1' || req.query.deleted === 'true';
|
||||||
const search = req.query.search ? String(req.query.search).trim() : '';
|
const search = req.query.search ? String(req.query.search).trim() : '';
|
||||||
|
const usedFilter = req.query.used;
|
||||||
const params = [];
|
const params = [];
|
||||||
let where = showDeleted ? 'WHERE sm.deleted_at IS NOT NULL' : 'WHERE sm.deleted_at IS NULL';
|
let where = showDeleted ? 'WHERE sm.deleted_at IS NOT NULL' : 'WHERE sm.deleted_at IS NULL';
|
||||||
|
if (usedFilter === '0' || usedFilter === 'false') where += ' AND sm.used = false';
|
||||||
|
else if (usedFilter === '1' || usedFilter === 'true') where += ' AND sm.used = true';
|
||||||
if (search) {
|
if (search) {
|
||||||
params.push(`%${search}%`);
|
params.push(`%${search}%`);
|
||||||
where += ` AND (sm.resident ILIKE $${params.length} OR sm.topic ILIKE $${params.length} OR sm.message ILIKE $${params.length})`;
|
where += ` AND (sm.resident ILIKE $${params.length} OR sm.topic ILIKE $${params.length} OR sm.message ILIKE $${params.length})`;
|
||||||
@@ -2935,6 +2938,35 @@ function parseFeedbackDate(s) {
|
|||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.get('/api/feedbacks/last', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const resident = typeof req.query.resident === 'string' ? req.query.resident.trim() : '';
|
||||||
|
if (!resident) return res.status(400).json({ error: 'Нужен резидент' });
|
||||||
|
const key = ['feedbacks:last:', scopeKey(req.user), resident.toLowerCase()].join(':');
|
||||||
|
const payload = await cacheWrap(key, FEEDBACK_LIST_TTL_MS, async () => {
|
||||||
|
const bs = branchScope(req.user);
|
||||||
|
if (bs.ids && !bs.ids.length) return null;
|
||||||
|
const params = [`%${resident}%`];
|
||||||
|
let cond = 'f.deleted_at IS NULL AND f.resident ILIKE $1';
|
||||||
|
if (bs.ids) { params.push(bs.ids); cond += ` AND f.branch_id = ANY($${params.length}::int[])`; }
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT f.id, f.feedback_date, f.group_id, f.resident, f.topic, f.past_review, f.text, f.ai_status, f.sent_at, f.branch_id, f.deleted_at, g.name AS group_name
|
||||||
|
FROM feedbacks f
|
||||||
|
LEFT JOIN groups g ON g.id = f.group_id
|
||||||
|
WHERE ${cond}
|
||||||
|
ORDER BY f.feedback_date DESC NULLS LAST, f.id DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return rows[0] || null;
|
||||||
|
});
|
||||||
|
res.json(payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Internal error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/feedbacks', requireAuth, async (req, res) => {
|
app.get('/api/feedbacks', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
|
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
|
||||||
@@ -5690,7 +5722,7 @@ app.get('/api/students/names-with-groups', requireAuth, async (req, res) => {
|
|||||||
`SELECT DISTINCT ON (e.student_name) e.student_name AS name, e.group_id, g.name AS group_name
|
`SELECT DISTINCT ON (e.student_name) e.student_name AS name, e.group_id, g.name AS group_name
|
||||||
FROM entries e
|
FROM entries e
|
||||||
JOIN groups g ON g.id = e.group_id
|
JOIN groups g ON g.id = e.group_id
|
||||||
WHERE e.student_name IS NOT NULL AND e.student_name <> ''${bw.where}
|
WHERE e.student_name IS NOT NULL AND e.student_name <> '' AND e.deleted_at IS NULL${bw.where}
|
||||||
ORDER BY e.student_name, e.created_at DESC NULLS LAST, e.id DESC`,
|
ORDER BY e.student_name, e.created_at DESC NULLS LAST, e.id DESC`,
|
||||||
bw.params
|
bw.params
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user