From d7d4cc113325dcfef5fb6b9494fd281b49b60b71 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 18 Sep 2026 20:04:02 +0300 Subject: [PATCH] feat(modules): soft delete with restore and active filter --- db/init.sql | 3 +++ db/migration.sql | 2 ++ public/js/audit.js | 4 ++++ public/js/index.js | 1 + public/js/journal.js | 1 + public/js/modules.js | 36 ++++++++++++++++++++++++++---------- public/modules.html | 4 ++++ server.js | 21 ++++++++++++++++----- 8 files changed, 57 insertions(+), 15 deletions(-) diff --git a/db/init.sql b/db/init.sql index e5af457..88faa7b 100644 --- a/db/init.sql +++ b/db/init.sql @@ -28,9 +28,12 @@ CREATE TABLE IF NOT EXISTS modules ( id SERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL UNIQUE, lessons_count INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ DEFAULT now() ); +ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true; + CREATE TABLE IF NOT EXISTS students ( id SERIAL PRIMARY KEY, name VARCHAR(150) NOT NULL UNIQUE, diff --git a/db/migration.sql b/db/migration.sql index 97e26c3..65f40ad 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -228,8 +228,10 @@ CREATE TABLE IF NOT EXISTS modules ( id SERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL UNIQUE, lessons_count INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ DEFAULT now() ); +ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true; ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL; CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id); diff --git a/public/js/audit.js b/public/js/audit.js index ed16053..7e2530f 100644 --- a/public/js/audit.js +++ b/public/js/audit.js @@ -21,6 +21,10 @@ const ACTION_LABELS = { 'student.update': 'Изменён ученик', 'student.delete': 'Удалён ученик', 'student.batch-group': 'Ученики перенесены в группу', + 'module.create': 'Создан модуль', + 'module.update': 'Изменён модуль', + 'module.delete': 'Модуль отключён', + 'module.restore': 'Модуль включён', 'entry.update': 'Изменена запись', 'entry.photo.update': 'Изменено фото записи', 'entry.photo.set_main': 'Главное фото записи', diff --git a/public/js/index.js b/public/js/index.js index e756085..3cc0c90 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -151,6 +151,7 @@ async function fetchModules(q, limit) { const p = new URLSearchParams(); if (q) p.set('search', q); p.set('limit', String(limit)); + p.set('active', '1'); try { const res = await fetch(`${API}/api/modules?${p}`); if (!res.ok) return []; diff --git a/public/js/journal.js b/public/js/journal.js index 843eba5..2f97432 100644 --- a/public/js/journal.js +++ b/public/js/journal.js @@ -239,6 +239,7 @@ async function fetchModules(q, limit) { const p = new URLSearchParams(); if (q) p.set('search', q); p.set('limit', String(limit)); + p.set('active', '1'); const res = await fetch(`${API}/api/modules?${p}`, { headers: hdr() }); if (!res.ok) return []; const data = await res.json().catch(() => ({})); diff --git a/public/js/modules.js b/public/js/modules.js index 63900e3..c857277 100644 --- a/public/js/modules.js +++ b/public/js/modules.js @@ -32,14 +32,17 @@ function renderModules(total) { list.innerHTML = '
Модулей пока нет. Нажмите «Добавить модуль».
'; } else { list.innerHTML = modulesList.map(m => ` -
+
-
${esc(m.name)}
+
${esc(m.name)}${m.is_active ? '' : ' Отключён'}
${m.lessons_count} ${mPlural(m.lessons_count, 'занятие', 'занятия', 'занятий')} · ${m.entries_count} ${mPlural(m.entries_count, 'запись', 'записи', 'записей')}
- - + ${m.is_active + ? ` + ` + : ` + `}
`).join(''); @@ -75,15 +78,26 @@ function closeModuleModal() { document.getElementById('moduleModal').classList.remove('open'); } -async function deleteModule(id) { +async function disableModule(id) { const m = modulesList.find(x => x.id === id); const msg = m && m.entries_count > 0 - ? `Удалить модуль «${m.name}»? У ${m.entries_count} связанных записей тема будет очищена.` - : 'Удалить этот модуль?'; + ? `Отключить модуль «${m.name}»? Он станет недоступен для выбора, у ${m.entries_count} связанных записей тема сохранится.` + : `Отключить модуль${m ? ` «${m.name}»` : ''}? Он станет недоступен для выбора.`; if (!confirm(msg)) return; const res = await fetch(`${API}/api/modules/${id}`, { method: 'DELETE', headers: hdr() }); if (res.ok) { - showToast('Модуль удалён'); + showToast('Модуль отключён'); + loadModules(); + } else { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Ошибка'); + } +} + +async function enableModule(id) { + const res = await fetch(`${API}/api/modules/${id}/restore`, { method: 'PUT', headers: hdr() }); + if (res.ok) { + showToast('Модуль включён'); loadModules(); } else { const err = await res.json().catch(() => ({})); @@ -202,8 +216,10 @@ document.getElementById('moduleSearch').addEventListener('input', () => { document.getElementById('moduleList').addEventListener('click', e => { const ed = e.target.closest('[data-edit-module]'); if (ed) { openModuleModal(parseInt(ed.dataset.editModule, 10)); return; } - const dl = e.target.closest('[data-del-module]'); - if (dl) deleteModule(parseInt(dl.dataset.delModule, 10)); + const off = e.target.closest('[data-off-module]'); + if (off) { disableModule(parseInt(off.dataset.offModule, 10)); return; } + const on = e.target.closest('[data-on-module]'); + if (on) enableModule(parseInt(on.dataset.onModule, 10)); }); (async () => { diff --git a/public/modules.html b/public/modules.html index 212fe98..494ac14 100644 --- a/public/modules.html +++ b/public/modules.html @@ -79,6 +79,10 @@ .module-info{min-width:0;display:flex;flex-direction:column;gap:4px} .module-name{font-weight:600;font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .module-meta{font-size:.78rem;color:var(--muted)} +.module-item.module-off .module-name{color:var(--muted)} +.module-badge{display:inline-block;margin-left:8px;padding:1px 8px;border-radius:999px;font-size:.7rem;font-weight:600;background:rgba(239,68,68,.12);color:#ef4444;vertical-align:middle} +.module-item .btn-link.danger{color:#ef4444} +.module-item .btn-link.danger:hover{border-color:#ef4444} #batchModuleModal .hint{font-size:.78rem;color:var(--muted);line-height:1.5;display:block} #batchModuleModal .hint b{color:var(--text)} @media (max-width:640px){.module-item{flex-direction:column;align-items:flex-start}} diff --git a/server.js b/server.js index 8af621f..b1aed04 100644 --- a/server.js +++ b/server.js @@ -670,8 +670,10 @@ async function ensureModulesTable() { id SERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL UNIQUE, lessons_count INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ DEFAULT now() )`); + await pool.query('ALTER TABLE modules ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true'); await pool.query('ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL'); await pool.query('CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id)'); } @@ -1346,6 +1348,7 @@ function normalizeRestoreData(data) { id: reqInt(x.id), name: reqStr(x.name, 200), lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0, + is_active: x.is_active !== false, created_at: optTs(x.created_at), })); const entry_photos = (data.entry_photos || []).map(x => ({ @@ -1529,8 +1532,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req } for (const x of ndata.modules) { await client.query( - 'INSERT INTO modules (id, name, lessons_count, created_at) VALUES ($1,$2,$3,$4)', - [x.id, x.name, x.lessons_count, x.created_at] + 'INSERT INTO modules (id, name, lessons_count, is_active, created_at) VALUES ($1,$2,$3,$4,$5)', + [x.id, x.name, x.lessons_count, x.is_active, x.created_at] ); } for (const x of ndata.entries) { @@ -2291,17 +2294,18 @@ app.put('/api/groups/:id/photos/:photoId/cover', requireAuth, async (req, res) = // --- Modules (темы модулей) --- app.get('/api/modules', apiLimiter, async (req, res) => { - const { limit, offset, search } = req.query; + const { limit, offset, search, active } = req.query; const conditions = []; const params = []; if (search?.trim()) { params.push(`%${search.trim()}%`); conditions.push(`m.name ILIKE $${params.length}`); } + if (active === '1' || active === 'true') conditions.push('m.is_active = true'); const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''; const { rows: crows } = await pool.query(`SELECT count(*)::int AS n FROM modules m${where}`, params); const total = crows[0].n; let q = `SELECT m.*, count(e.id)::int AS entries_count FROM modules m LEFT JOIN entries e ON e.module_id = m.id${where} - GROUP BY m.id ORDER BY m.id`; + GROUP BY m.id ORDER BY m.is_active DESC, m.id`; const qparams = params.slice(); const lim = parseInt(limit, 10); if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; } @@ -2358,12 +2362,19 @@ app.put('/api/modules/:id', requireAdmin, async (req, res) => { }); app.delete('/api/modules/:id', requireAdmin, async (req, res) => { - const { rows } = await pool.query('DELETE FROM modules WHERE id = $1 RETURNING id', [req.params.id]); + const { rows } = await pool.query('UPDATE modules SET is_active = false WHERE id = $1 RETURNING id', [req.params.id]); if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); await logAudit(req, 'module.delete', { id: req.params.id }); res.json({ ok: true }); }); +app.put('/api/modules/:id/restore', requireAdmin, async (req, res) => { + const { rows } = await pool.query('UPDATE modules SET is_active = true WHERE id = $1 RETURNING *', [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'Не найдено' }); + await logAudit(req, 'module.restore', { id: req.params.id }); + res.json(rows[0]); +}); + // --- Students CRUD --- app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => { const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {