feat(wishes): трекер пожеланий по улучшению системы

Любой авторизованный пользователь подаёт пожелание (заголовок, категория, описание);
видит только свои. Админ видит все, фильтрует по статусу, ведёт по статусам
(новое → запланировано → в работе → готово / отклонено) и пишет ответ автору. Автор
получает уведомление при смене статуса (pushNotif).

Бэкенд: миграция 080 (таблица wishes), wishController (list/create/update/remove с
валидацией и whitelist категорий/статусов), routes/wishes (PATCH — только админ, DELETE —
автор«новое»/админ, проверка в хендлере), смонтировано в server.js. Тесты 15/15.

Фронт: страница /wishes (форма + список со статус-бейджами; у админа — фильтры,
смена статуса, ответ, удаление), пункт «Пожелания» в сайдбаре (все роли), фиче-флаг
feature_wishes_enabled (тумблер в админ-модулях + whitelist + FEATURE_HREFS; админ
видит всегда). Клиентские врапперы LS.wish*.

⚠️ Живой БД нужен npm run migrate (080). lint:routes 0; node --check всех файлов + инлайна.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-23 16:12:10 +03:00
parent 758e1bf6cb
commit be9fdfa703
10 changed files with 506 additions and 1 deletions
+242
View File
@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Пожелания — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.sb-content { background: #f4f5f8; }
.container { max-width: 820px; margin: 0 auto; padding: 28px 32px 100px; }
.page-title { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800; color: #0F172A; margin-bottom: 6px; }
.page-sub { font-size: 0.82rem; color: var(--text-3); margin-bottom: 22px; }
/* submit card */
.wf-card { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 16px; padding: 18px 20px; margin-bottom: 24px; }
.wf-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.wf-inp, .wf-sel, .wf-area {
width: 100%; padding: 9px 12px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.86rem; color: #0F172A; outline: none; transition: border-color .15s;
}
.wf-inp:focus, .wf-sel:focus, .wf-area:focus { border-color: var(--violet); }
.wf-area { min-height: 70px; resize: vertical; }
.wf-sel { cursor: pointer; }
.wf-actions { display: flex; justify-content: flex-end; gap: 10px; }
/* filters */
.w-filters { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
.w-fchip { padding: 6px 13px; border-radius: 999px; border: 1.5px solid rgba(15,23,42,0.1); background: transparent;
font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all .15s; }
.w-fchip:hover { border-color: var(--violet); color: var(--violet); }
.w-fchip.active { background: rgba(155,93,229,0.08); border-color: var(--violet); color: var(--violet); }
/* wish list */
.w-list { display: flex; flex-direction: column; gap: 12px; }
.w-card { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 16px; padding: 16px 18px; border-left: 3px solid var(--wc, #9B5DE5); }
.w-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
.w-title { font-size: 0.92rem; font-weight: 700; color: #0F172A; flex: 1; min-width: 0; }
.w-badge { font-size: 0.68rem; font-weight: 700; padding: 3px 10px; border-radius: 999px; white-space: nowrap; }
.wb-new { background: rgba(6,214,224,0.12); color: #06aab3; }
.wb-planned { background: rgba(155,93,229,0.12); color: #9B5DE5; }
.wb-in_progress{ background: rgba(255,179,71,0.14); color: #d97706; }
.wb-done { background: rgba(5,150,82,0.12); color: #059652; }
.wb-declined { background: rgba(15,23,42,0.07); color: var(--text-3); }
.w-cat { font-size: 0.7rem; font-weight: 700; color: var(--text-3); }
.w-meta { font-size: 0.72rem; color: var(--text-3); }
.w-body { font-size: 0.84rem; color: #3D4F6B; line-height: 1.5; margin-top: 4px; white-space: pre-wrap; word-break: break-word; }
.w-note { font-size: 0.8rem; color: #0F172A; background: rgba(155,93,229,0.06); border: 1px solid rgba(155,93,229,0.18);
border-radius: 10px; padding: 8px 12px; margin-top: 10px; line-height: 1.5; }
.w-note b { color: var(--violet); }
.w-author { font-size: 0.72rem; font-weight: 700; color: var(--violet); }
/* admin manage */
.w-manage { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px dashed rgba(15,23,42,0.1); }
.w-manage .wf-sel { width: auto; min-width: 150px; }
.w-manage .wf-area { flex: 1; min-width: 200px; min-height: 40px; }
.w-btn { padding: 7px 14px; border-radius: 10px; border: 1.5px solid rgba(15,23,42,0.12); background: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 700; color: #3D4F6B; cursor: pointer; transition: all .15s; }
.w-btn:hover { border-color: var(--violet); color: var(--violet); }
.w-btn-primary { background: var(--grad-1); color: #fff; border-color: transparent; }
.w-btn-primary:hover { opacity: .9; color: #fff; }
.w-btn-danger { border-color: rgba(239,71,111,0.25); color: #EF476F; }
.w-btn-danger:hover { background: rgba(239,71,111,0.06); }
.w-empty { text-align: center; padding: 50px 20px; color: var(--text-3); }
@media (max-width: 600px) { .container { padding: 16px 14px 80px; } }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<div class="container">
<div class="page-title">Пожелания по улучшению</div>
<div class="page-sub" id="w-sub">Предложите, что улучшить в системе — мы это увидим и ответим.</div>
<!-- Submit form -->
<div class="wf-card">
<div class="wf-row">
<input class="wf-inp" id="wf-title" maxlength="200" placeholder="Кратко: что улучшить?" style="flex:2;min-width:200px" />
<select class="wf-sel" id="wf-cat" style="flex:1;min-width:150px">
<option value="feature">Новая функция</option>
<option value="ui">Интерфейс</option>
<option value="content">Контент</option>
<option value="bug">Баг / ошибка</option>
<option value="other">Другое</option>
</select>
</div>
<div class="wf-row">
<textarea class="wf-area" id="wf-body" maxlength="4000" placeholder="Подробнее (необязательно): как должно работать, зачем это нужно…"></textarea>
</div>
<div class="wf-actions">
<button class="w-btn w-btn-primary" id="wf-submit" onclick="submitWish()">
<i data-lucide="send" style="width:13px;height:13px;vertical-align:-2px"></i> Отправить
</button>
</div>
</div>
<!-- Admin filters -->
<div class="w-filters" id="w-filters" style="display:none"></div>
<div class="w-list" id="w-list"><div class="w-empty">Загрузка…</div></div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script>
const { user, isAdmin } = LS.initPage();
if (!user) throw new Error('Not logged in');
LS.showBoardIfAllowed();
LS.notif.init();
const CAT_LABEL = { ui: 'Интерфейс', content: 'Контент', feature: 'Новая функция', bug: 'Баг', other: 'Другое' };
const ST_LABEL = { new: 'Новое', planned: 'Запланировано', in_progress: 'В работе', done: 'Готово', declined: 'Отклонено' };
const ST_ORDER = ['new', 'planned', 'in_progress', 'done', 'declined'];
let _statusFilter = null;
let _wishes = [];
function fmtDate(s) {
if (!s) return '';
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z');
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short', year: 'numeric' });
}
async function load() {
try {
const params = _statusFilter ? { status: _statusFilter } : {};
const data = await LS.wishesList(params);
_wishes = data.wishes || [];
if (data.isAdmin) renderFilters(data.counts || {});
render();
} catch (e) {
document.getElementById('w-list').innerHTML = `<div class="w-empty">Не удалось загрузить: ${esc(e.message || '')}</div>`;
}
}
function renderFilters(counts) {
const el = document.getElementById('w-filters');
el.style.display = '';
const total = Object.values(counts).reduce((a, b) => a + b, 0);
let html = `<button class="w-fchip${!_statusFilter ? ' active' : ''}" onclick="setFilter(null)">Все ${total ? '· ' + total : ''}</button>`;
html += ST_ORDER.map(s => counts[s]
? `<button class="w-fchip${_statusFilter === s ? ' active' : ''}" onclick="setFilter('${s}')">${ST_LABEL[s]} · ${counts[s]}</button>`
: '').join('');
el.innerHTML = html;
}
function setFilter(s) { _statusFilter = s; load(); }
function render() {
const el = document.getElementById('w-list');
if (!_wishes.length) {
el.innerHTML = `<div class="w-empty"><div style="opacity:.4;margin-bottom:8px"><i data-lucide="lightbulb" style="width:40px;height:40px"></i></div>${isAdmin ? 'Пожеланий пока нет.' : 'Вы ещё не оставляли пожеланий. Поделитесь идеей выше!'}</div>`;
if (window.lucide) lucide.createIcons();
return;
}
el.innerHTML = _wishes.map(cardHtml).join('');
if (window.lucide) lucide.createIcons();
}
const ST_COLOR = { new: '#06aab3', planned: '#9B5DE5', in_progress: '#d97706', done: '#059652', declined: '#94A3B8' };
function cardHtml(w) {
const author = (isAdmin && w.author_name) ? `<span class="w-author">${esc(w.author_name)}</span> · ` : '';
const noteHtml = w.admin_note ? `<div class="w-note"><b>Ответ:</b> ${esc(w.admin_note)}</div>` : '';
let manage = '';
if (isAdmin) {
const opts = ST_ORDER.map(s => `<option value="${s}"${w.status === s ? ' selected' : ''}>${ST_LABEL[s]}</option>`).join('');
manage = `<div class="w-manage">
<select class="wf-sel" id="st-${w.id}">${opts}</select>
<textarea class="wf-area" id="note-${w.id}" placeholder="Ответ автору (необязательно)…">${esc(w.admin_note || '')}</textarea>
<button class="w-btn w-btn-primary" onclick="saveWish(${w.id})">Сохранить</button>
<button class="w-btn w-btn-danger" onclick="delWish(${w.id})" title="Удалить"><i data-lucide="trash-2" style="width:13px;height:13px"></i></button>
</div>`;
} else if (w.status === 'new') {
manage = `<div class="w-manage"><button class="w-btn w-btn-danger" onclick="delWish(${w.id})"><i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i> Удалить</button></div>`;
}
return `<div class="w-card" style="--wc:${ST_COLOR[w.status] || '#9B5DE5'}">
<div class="w-head">
<span class="w-title">${esc(w.title)}</span>
<span class="w-badge wb-${w.status}">${ST_LABEL[w.status] || w.status}</span>
</div>
<div class="w-meta">${author}<span class="w-cat">${CAT_LABEL[w.category] || w.category}</span> · ${fmtDate(w.created_at)}</div>
${w.body ? `<div class="w-body">${esc(w.body)}</div>` : ''}
${noteHtml}
${manage}
</div>`;
}
async function submitWish() {
const title = document.getElementById('wf-title').value.trim();
if (!title) { LS.toast('Введите заголовок', 'warn'); return; }
const btn = document.getElementById('wf-submit');
btn.disabled = true;
try {
await LS.wishCreate({
title,
category: document.getElementById('wf-cat').value,
body: document.getElementById('wf-body').value.trim(),
});
document.getElementById('wf-title').value = '';
document.getElementById('wf-body').value = '';
LS.toast('Пожелание отправлено — спасибо!', 'success');
_statusFilter = null;
await load();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
finally { btn.disabled = false; }
}
async function saveWish(id) {
try {
await LS.wishUpdate(id, {
status: document.getElementById('st-' + id).value,
admin_note: document.getElementById('note-' + id).value.trim(),
});
LS.toast('Сохранено', 'success');
await load();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function delWish(id) {
if (!await LS.confirm('Удалить это пожелание?', { title: 'Удаление', confirmText: 'Удалить', danger: true })) return;
try {
await LS.wishDelete(id);
_wishes = _wishes.filter(w => w.id !== id);
render();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
load();
if (window.lucide) lucide.createIcons();
</script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>