feat(modules): soft delete with restore and active filter
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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': 'Главное фото записи',
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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(() => ({}));
|
||||
|
||||
+26
-10
@@ -32,14 +32,17 @@ function renderModules(total) {
|
||||
list.innerHTML = '<div class="empty">Модулей пока нет. Нажмите «Добавить модуль».</div>';
|
||||
} else {
|
||||
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-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>
|
||||
<div class="student-actions">
|
||||
<button class="btn-link" type="button" data-edit-module="${m.id}">Изменить</button>
|
||||
<button class="btn-del" type="button" data-del-module="${m.id}" title="Удалить">×</button>
|
||||
${m.is_active
|
||||
? `<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>
|
||||
`).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 () => {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user