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,
|
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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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': 'Главное фото записи',
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|||||||
@@ -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
@@ -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="Удалить">×</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 () => {
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user