feat(modules): soft delete with restore and active filter

This commit is contained in:
dev
2026-09-18 20:04:02 +03:00
parent d3dd922e32
commit d7d4cc1133
8 changed files with 57 additions and 15 deletions
+3
View File
@@ -28,9 +28,12 @@ CREATE TABLE IF NOT EXISTS modules (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE, name VARCHAR(200) NOT NULL UNIQUE,
lessons_count INT NOT NULL DEFAULT 0, lessons_count INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now() 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 ( CREATE TABLE IF NOT EXISTS students (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(150) NOT NULL UNIQUE, name VARCHAR(150) NOT NULL UNIQUE,
+2
View File
@@ -228,8 +228,10 @@ CREATE TABLE IF NOT EXISTS modules (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE, name VARCHAR(200) NOT NULL UNIQUE,
lessons_count INT NOT NULL DEFAULT 0, lessons_count INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now() 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; 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); CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
+4
View File
@@ -21,6 +21,10 @@ const ACTION_LABELS = {
'student.update': 'Изменён ученик', 'student.update': 'Изменён ученик',
'student.delete': 'Удалён ученик', 'student.delete': 'Удалён ученик',
'student.batch-group': 'Ученики перенесены в группу', 'student.batch-group': 'Ученики перенесены в группу',
'module.create': 'Создан модуль',
'module.update': 'Изменён модуль',
'module.delete': 'Модуль отключён',
'module.restore': 'Модуль включён',
'entry.update': 'Изменена запись', 'entry.update': 'Изменена запись',
'entry.photo.update': 'Изменено фото записи', 'entry.photo.update': 'Изменено фото записи',
'entry.photo.set_main': 'Главное фото записи', 'entry.photo.set_main': 'Главное фото записи',
+1
View File
@@ -151,6 +151,7 @@ async function fetchModules(q, limit) {
const p = new URLSearchParams(); const p = new URLSearchParams();
if (q) p.set('search', q); if (q) p.set('search', q);
p.set('limit', String(limit)); p.set('limit', String(limit));
p.set('active', '1');
try { try {
const res = await fetch(`${API}/api/modules?${p}`); const res = await fetch(`${API}/api/modules?${p}`);
if (!res.ok) return []; if (!res.ok) return [];
+1
View File
@@ -239,6 +239,7 @@ async function fetchModules(q, limit) {
const p = new URLSearchParams(); const p = new URLSearchParams();
if (q) p.set('search', q); if (q) p.set('search', q);
p.set('limit', String(limit)); p.set('limit', String(limit));
p.set('active', '1');
const res = await fetch(`${API}/api/modules?${p}`, { headers: hdr() }); const res = await fetch(`${API}/api/modules?${p}`, { headers: hdr() });
if (!res.ok) return []; if (!res.ok) return [];
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
+26 -10
View File
@@ -32,14 +32,17 @@ function renderModules(total) {
list.innerHTML = '<div class="empty">Модулей пока нет. Нажмите «Добавить модуль».</div>'; list.innerHTML = '<div class="empty">Модулей пока нет. Нажмите «Добавить модуль».</div>';
} else { } else {
list.innerHTML = modulesList.map(m => ` list.innerHTML = modulesList.map(m => `
<div class="item module-item" data-id="${m.id}"> <div class="item module-item${m.is_active ? '' : ' module-off'}" data-id="${m.id}">
<div class="module-info"> <div class="module-info">
<div class="module-name">${esc(m.name)}</div> <div class="module-name">${esc(m.name)}${m.is_active ? '' : ' <span class="module-badge">Отключён</span>'}</div>
<div class="module-meta">${m.lessons_count} ${mPlural(m.lessons_count, 'занятие', 'занятия', 'занятий')} · ${m.entries_count} ${mPlural(m.entries_count, 'запись', 'записи', 'записей')}</div> <div class="module-meta">${m.lessons_count} ${mPlural(m.lessons_count, 'занятие', 'занятия', 'занятий')} · ${m.entries_count} ${mPlural(m.entries_count, 'запись', 'записи', 'записей')}</div>
</div> </div>
<div class="student-actions"> <div class="student-actions">
<button class="btn-link" type="button" data-edit-module="${m.id}">Изменить</button> ${m.is_active
<button class="btn-del" type="button" data-del-module="${m.id}" title="Удалить">&times;</button> ? `<button class="btn-link" type="button" data-edit-module="${m.id}">Изменить</button>
<button class="btn-link danger" type="button" data-off-module="${m.id}">Отключить</button>`
: `<button class="btn-link" type="button" data-on-module="${m.id}">Включить</button>
<button class="btn-link" type="button" data-edit-module="${m.id}">Изменить</button>`}
</div> </div>
</div> </div>
`).join(''); `).join('');
@@ -75,15 +78,26 @@ function closeModuleModal() {
document.getElementById('moduleModal').classList.remove('open'); document.getElementById('moduleModal').classList.remove('open');
} }
async function deleteModule(id) { async function disableModule(id) {
const m = modulesList.find(x => x.id === id); const m = modulesList.find(x => x.id === id);
const msg = m && m.entries_count > 0 const msg = m && m.entries_count > 0
? `Удалить модуль «${m.name}»? У ${m.entries_count} связанных записей тема будет очищена.` ? `Отключить модуль «${m.name}»? Он станет недоступен для выбора, у ${m.entries_count} связанных записей тема сохранится.`
: 'Удалить этот модуль?'; : `Отключить модуль${m ? ` «${m.name}»` : ''}? Он станет недоступен для выбора.`;
if (!confirm(msg)) return; if (!confirm(msg)) return;
const res = await fetch(`${API}/api/modules/${id}`, { method: 'DELETE', headers: hdr() }); const res = await fetch(`${API}/api/modules/${id}`, { method: 'DELETE', headers: hdr() });
if (res.ok) { 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(); loadModules();
} else { } else {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
@@ -202,8 +216,10 @@ document.getElementById('moduleSearch').addEventListener('input', () => {
document.getElementById('moduleList').addEventListener('click', e => { document.getElementById('moduleList').addEventListener('click', e => {
const ed = e.target.closest('[data-edit-module]'); const ed = e.target.closest('[data-edit-module]');
if (ed) { openModuleModal(parseInt(ed.dataset.editModule, 10)); return; } if (ed) { openModuleModal(parseInt(ed.dataset.editModule, 10)); return; }
const dl = e.target.closest('[data-del-module]'); const off = e.target.closest('[data-off-module]');
if (dl) deleteModule(parseInt(dl.dataset.delModule, 10)); 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 () => { (async () => {
+4
View File
@@ -79,6 +79,10 @@
.module-info{min-width:0;display:flex;flex-direction:column;gap:4px} .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-name{font-weight:600;font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.module-meta{font-size:.78rem;color:var(--muted)} .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{font-size:.78rem;color:var(--muted);line-height:1.5;display:block}
#batchModuleModal .hint b{color:var(--text)} #batchModuleModal .hint b{color:var(--text)}
@media (max-width:640px){.module-item{flex-direction:column;align-items:flex-start}} @media (max-width:640px){.module-item{flex-direction:column;align-items:flex-start}}
+16 -5
View File
@@ -670,8 +670,10 @@ async function ensureModulesTable() {
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL UNIQUE, name VARCHAR(200) NOT NULL UNIQUE,
lessons_count INT NOT NULL DEFAULT 0, lessons_count INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now() 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('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)'); 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), id: reqInt(x.id),
name: reqStr(x.name, 200), name: reqStr(x.name, 200),
lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0, lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0,
is_active: x.is_active !== false,
created_at: optTs(x.created_at), created_at: optTs(x.created_at),
})); }));
const entry_photos = (data.entry_photos || []).map(x => ({ 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) { for (const x of ndata.modules) {
await client.query( await client.query(
'INSERT INTO modules (id, name, lessons_count, created_at) VALUES ($1,$2,$3,$4)', '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.created_at] [x.id, x.name, x.lessons_count, x.is_active, x.created_at]
); );
} }
for (const x of ndata.entries) { for (const x of ndata.entries) {
@@ -2291,17 +2294,18 @@ app.put('/api/groups/:id/photos/:photoId/cover', requireAuth, async (req, res) =
// --- Modules (темы модулей) --- // --- Modules (темы модулей) ---
app.get('/api/modules', apiLimiter, async (req, res) => { app.get('/api/modules', apiLimiter, async (req, res) => {
const { limit, offset, search } = req.query; const { limit, offset, search, active } = req.query;
const conditions = []; const conditions = [];
const params = []; const params = [];
if (search?.trim()) { params.push(`%${search.trim()}%`); conditions.push(`m.name ILIKE $${params.length}`); } 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 where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
const { rows: crows } = await pool.query(`SELECT count(*)::int AS n FROM modules m${where}`, params); const { rows: crows } = await pool.query(`SELECT count(*)::int AS n FROM modules m${where}`, params);
const total = crows[0].n; const total = crows[0].n;
let q = `SELECT m.*, count(e.id)::int AS entries_count let q = `SELECT m.*, count(e.id)::int AS entries_count
FROM modules m FROM modules m
LEFT JOIN entries e ON e.module_id = m.id${where} 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 qparams = params.slice();
const lim = parseInt(limit, 10); const lim = parseInt(limit, 10);
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; } 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) => { 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: 'Не найдено' }); if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
await logAudit(req, 'module.delete', { id: req.params.id }); await logAudit(req, 'module.delete', { id: req.params.id });
res.json({ ok: true }); 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 --- // --- Students CRUD ---
app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => { app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => {
const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => { const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {