feat(modules): module topics for student form, admin CRUD with pagination
- db: modules table (name, lessons_count) + entries.module_id (ON DELETE SET NULL), migration + idempotent startup ensure - api: GET /api/modules (public, search + limit/offset, entries_count), POST/PUT/DELETE (admin, audit-logged) - entries: accept/validate module_id on create/update, return module_name, module_id filter - backup/restore: include modules and entries.module_id - student form: required module select, hidden while no modules exist - admin: modules.html + js/modules.js list with pagination, search, create/edit/delete modal - journal: module filter, module select in edit modal, module badge, CSV column
This commit is contained in:
+10
@@ -24,6 +24,13 @@ CREATE TABLE IF NOT EXISTS groups (
|
|||||||
cover_path VARCHAR(255)
|
cover_path VARCHAR(255)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS modules (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL UNIQUE,
|
||||||
|
lessons_count INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
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,
|
||||||
@@ -35,6 +42,7 @@ CREATE TABLE IF NOT EXISTS entries (
|
|||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
student_name VARCHAR(150) NOT NULL,
|
student_name VARCHAR(150) NOT NULL,
|
||||||
group_id INT NOT NULL REFERENCES groups(id),
|
group_id INT NOT NULL REFERENCES groups(id),
|
||||||
|
module_id INT REFERENCES modules(id) ON DELETE SET NULL,
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
description_original TEXT,
|
description_original TEXT,
|
||||||
description_ai TEXT,
|
description_ai TEXT,
|
||||||
@@ -52,8 +60,10 @@ ALTER TABLE entries ADD COLUMN IF NOT EXISTS description_ai TEXT;
|
|||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'pending';
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
||||||
|
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_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
|
CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id);
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
|
CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
|
|||||||
@@ -223,3 +223,13 @@ ON CONFLICT (key) DO NOTHING;
|
|||||||
|
|
||||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
|
ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255);
|
||||||
ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false;
|
ALTER TABLE photo_jobs ADD COLUMN IF NOT EXISTS applied BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS modules (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL UNIQUE,
|
||||||
|
lessons_count INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function buildSidebar(active) {
|
|||||||
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
|
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
|
||||||
];
|
];
|
||||||
const adminNav = [
|
const adminNav = [
|
||||||
|
{ page: 'modules', label: 'Темы модулей', icon: 'layers' },
|
||||||
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
|
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
|
||||||
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
|
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
|
||||||
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
|
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
|
||||||
|
|||||||
@@ -127,6 +127,13 @@ nav a:hover{color:var(--text)}
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field" id="moduleField" style="display:none">
|
||||||
|
<label>Тема модуля</label>
|
||||||
|
<select id="moduleInput">
|
||||||
|
<option value="">— выбери тему —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Что сделал</label>
|
<label>Что сделал</label>
|
||||||
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Имя или описание"></div>
|
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Имя или описание"></div>
|
||||||
<div><label>Студент</label><input type="text" id="studentFilter" list="studentFilterList" placeholder="Все / введите имя..."><datalist id="studentFilterList"></datalist></div>
|
<div><label>Студент</label><input type="text" id="studentFilter" list="studentFilterList" placeholder="Все / введите имя..."><datalist id="studentFilterList"></datalist></div>
|
||||||
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
|
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
|
||||||
|
<div><label>Модуль</label><select id="moduleFilter"><option value="">Все модули</option></select></div>
|
||||||
<div><label>С</label><input type="date" id="dateFrom"></div>
|
<div><label>С</label><input type="date" id="dateFrom"></div>
|
||||||
<div><label>По</label><input type="date" id="dateTo"></div>
|
<div><label>По</label><input type="date" id="dateTo"></div>
|
||||||
<div><label> </label><button type="button" id="exportCsvBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
|
<div><label> </label><button type="button" id="exportCsvBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
|
||||||
@@ -76,6 +77,10 @@
|
|||||||
<label>Группа <span style="color:#ef4444">*</span></label>
|
<label>Группа <span style="color:#ef4444">*</span></label>
|
||||||
<select id="editGroup" class="settings-input" required></select>
|
<select id="editGroup" class="settings-input" required></select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Тема модуля</label>
|
||||||
|
<select id="editModule" class="settings-input"></select>
|
||||||
|
</div>
|
||||||
<div class="settings-field full">
|
<div class="settings-field full">
|
||||||
<label>Что делала(а) <span style="color:#ef4444">*</span>
|
<label>Что делала(а) <span style="color:#ef4444">*</span>
|
||||||
<button type="button" id="aiCorrectBtn" class="ai-btn" title="Исправить ошибки ИИ">✨</button>
|
<button type="button" id="aiCorrectBtn" class="ai-btn" title="Исправить ошибки ИИ">✨</button>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const preview = document.getElementById('preview');
|
|||||||
const placeholderSrc = preview.src;
|
const placeholderSrc = preview.src;
|
||||||
const removePhotoBtn = document.getElementById('removePhotoBtn');
|
const removePhotoBtn = document.getElementById('removePhotoBtn');
|
||||||
const groupInput = document.getElementById('groupInput');
|
const groupInput = document.getElementById('groupInput');
|
||||||
|
const moduleInput = document.getElementById('moduleInput');
|
||||||
|
const moduleField = document.getElementById('moduleField');
|
||||||
const toast = document.getElementById('toast');
|
const toast = document.getElementById('toast');
|
||||||
const camVideo = document.getElementById('camVideo');
|
const camVideo = document.getElementById('camVideo');
|
||||||
const camCanvas = document.getElementById('camCanvas');
|
const camCanvas = document.getElementById('camCanvas');
|
||||||
@@ -133,6 +135,23 @@ if (params.get('nameInput')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
try {
|
||||||
|
const mRes = await fetch(`${API}/api/modules`);
|
||||||
|
if (mRes.ok) {
|
||||||
|
const mData = await mRes.json();
|
||||||
|
const list = mData.modules || [];
|
||||||
|
if (list.length) {
|
||||||
|
list.forEach(m => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = m.id;
|
||||||
|
opt.textContent = m.lessons_count > 0 ? `${m.name} · ${m.lessons_count} зан.` : m.name;
|
||||||
|
moduleInput.appendChild(opt);
|
||||||
|
});
|
||||||
|
moduleInput.required = true;
|
||||||
|
moduleField.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
const res = await fetch(`${API}/api/students`);
|
const res = await fetch(`${API}/api/students`);
|
||||||
const students = await res.json();
|
const students = await res.json();
|
||||||
const me = studentName ? students.find(s => s.name === studentName) : null;
|
const me = studentName ? students.find(s => s.name === studentName) : null;
|
||||||
@@ -219,6 +238,12 @@ form.addEventListener('submit', async (e) => {
|
|||||||
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (moduleField.style.display !== 'none' && !moduleInput.value) {
|
||||||
|
toast.textContent = 'Выберите тему модуля';
|
||||||
|
toast.classList.add('show', 'error');
|
||||||
|
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const btn = document.getElementById('submitBtn');
|
const btn = document.getElementById('submitBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Отправка...';
|
btn.textContent = 'Отправка...';
|
||||||
@@ -228,6 +253,7 @@ form.addEventListener('submit', async (e) => {
|
|||||||
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
||||||
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
||||||
fd.append('group_id', groupInput.value);
|
fd.append('group_id', groupInput.value);
|
||||||
|
fd.append('module_id', moduleInput.value);
|
||||||
fd.append('description', document.getElementById('descInput').value);
|
fd.append('description', document.getElementById('descInput').value);
|
||||||
fd.append('website', document.getElementById('hpWebsite').value);
|
fd.append('website', document.getElementById('hpWebsite').value);
|
||||||
|
|
||||||
|
|||||||
+40
-2
@@ -4,6 +4,8 @@ let highlightNewIds = new Set();
|
|||||||
let highlightClearTimer = null;
|
let highlightClearTimer = null;
|
||||||
let editId = null;
|
let editId = null;
|
||||||
let editGroups = [];
|
let editGroups = [];
|
||||||
|
let editModules = [];
|
||||||
|
let modulesLoaded = false;
|
||||||
let groupsLoaded = false;
|
let groupsLoaded = false;
|
||||||
let studentsLoaded = false;
|
let studentsLoaded = false;
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -99,7 +101,7 @@ function gridEntryHTML(e) {
|
|||||||
${mp ? `<img src="${thumbSrc(mp)}" data-img="${API}${mp}" loading="lazy" decoding="async" alt="">` : ''}
|
${mp ? `<img src="${thumbSrc(mp)}" data-img="${API}${mp}" loading="lazy" decoding="async" alt="">` : ''}
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="name">${esc(e.student_name)}</div>
|
<div class="name">${esc(e.student_name)}</div>
|
||||||
<div class="group">${esc(e.group_name)}</div>
|
<div class="group">${esc(e.group_name)}${e.module_name ? ' · ' + esc(e.module_name) : ''}</div>
|
||||||
<div class="desc">${esc(e.description)}${aiBadge(e)}</div>
|
<div class="desc">${esc(e.description)}${aiBadge(e)}</div>
|
||||||
${filesHTML(e.files)}
|
${filesHTML(e.files)}
|
||||||
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
|
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
|
||||||
@@ -119,6 +121,7 @@ function cardEntryHTML(e) {
|
|||||||
? `<img src="${thumbSrc(mp)}" data-img="${API}${mp}" loading="lazy" decoding="async" alt="">`
|
? `<img src="${thumbSrc(mp)}" data-img="${API}${mp}" loading="lazy" decoding="async" alt="">`
|
||||||
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
|
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
|
||||||
${e.group_name ? `<span class="group-badge" title="${esc(e.group_name)}">${esc(e.group_name)}</span>` : ''}
|
${e.group_name ? `<span class="group-badge" title="${esc(e.group_name)}">${esc(e.group_name)}</span>` : ''}
|
||||||
|
${e.module_name ? `<span class="group-badge" style="left:auto;right:8px;background:rgba(37,99,235,.8)" title="Модуль: ${esc(e.module_name)}">${esc(e.module_name)}</span>` : ''}
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div class="name">${esc(e.student_name)}</div>
|
<div class="name">${esc(e.student_name)}</div>
|
||||||
<div class="desc">${esc(e.description)}${aiBadge(e)}</div>
|
<div class="desc">${esc(e.description)}${aiBadge(e)}</div>
|
||||||
@@ -216,6 +219,31 @@ async function loadGroupOptions() {
|
|||||||
groupsLoaded = true;
|
groupsLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadModuleOptions() {
|
||||||
|
const res = await fetch(`${API}/api/modules`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
editModules = data.modules || [];
|
||||||
|
modulesLoaded = true;
|
||||||
|
const filt = document.getElementById('moduleFilter');
|
||||||
|
if (filt) {
|
||||||
|
while (filt.options.length > 1) filt.remove(1);
|
||||||
|
editModules.forEach(m => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = m.id; o.textContent = m.name;
|
||||||
|
filt.appendChild(o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillEditModuleSelect(selected) {
|
||||||
|
const sel = document.getElementById('editModule');
|
||||||
|
if (!sel) return;
|
||||||
|
sel.innerHTML = '<option value="">— не выбрано —</option>' +
|
||||||
|
editModules.map(m => `<option value="${m.id}">${esc(m.name)}</option>`).join('');
|
||||||
|
sel.value = selected ? String(selected) : '';
|
||||||
|
}
|
||||||
|
|
||||||
async function loadStudentOptions() {
|
async function loadStudentOptions() {
|
||||||
if (studentsLoaded) return;
|
if (studentsLoaded) return;
|
||||||
const dl = document.getElementById('studentFilterList');
|
const dl = document.getElementById('studentFilterList');
|
||||||
@@ -237,6 +265,8 @@ async function loadEntries() {
|
|||||||
if (gid) p.set('group_id', gid);
|
if (gid) p.set('group_id', gid);
|
||||||
if (df) p.set('date_from', df);
|
if (df) p.set('date_from', df);
|
||||||
if (dt) p.set('date_to', dt);
|
if (dt) p.set('date_to', dt);
|
||||||
|
const mid = document.getElementById('moduleFilter') ? document.getElementById('moduleFilter').value : '';
|
||||||
|
if (mid) p.set('module_id', mid);
|
||||||
p.set('limit', PAGE_SIZE);
|
p.set('limit', PAGE_SIZE);
|
||||||
p.set('offset', (page - 1) * PAGE_SIZE);
|
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||||
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
|
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
|
||||||
@@ -337,6 +367,8 @@ async function openEdit(id) {
|
|||||||
sel.innerHTML = editGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
sel.innerHTML = editGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||||
}
|
}
|
||||||
sel.value = e.group_id;
|
sel.value = e.group_id;
|
||||||
|
if (!modulesLoaded) await loadModuleOptions();
|
||||||
|
fillEditModuleSelect(e.module_id);
|
||||||
renderEditPhotos(e.photos || [], e.photo_path);
|
renderEditPhotos(e.photos || [], e.photo_path);
|
||||||
document.getElementById('editModal').classList.add('open');
|
document.getElementById('editModal').classList.add('open');
|
||||||
}
|
}
|
||||||
@@ -1130,6 +1162,7 @@ async function saveEdit() {
|
|||||||
formData.append('student_name', name);
|
formData.append('student_name', name);
|
||||||
formData.append('group_id', group_id);
|
formData.append('group_id', group_id);
|
||||||
formData.append('description', description);
|
formData.append('description', description);
|
||||||
|
formData.append('module_id', document.getElementById('editModule').value);
|
||||||
|
|
||||||
const photoFiles = document.getElementById('editPhoto').files;
|
const photoFiles = document.getElementById('editPhoto').files;
|
||||||
for (const f of photoFiles) formData.append('photo', f);
|
for (const f of photoFiles) formData.append('photo', f);
|
||||||
@@ -1173,6 +1206,7 @@ async function delEntry(id) {
|
|||||||
|
|
||||||
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
||||||
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
||||||
|
document.getElementById('moduleFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
||||||
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadEntries(); });
|
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadEntries(); });
|
||||||
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadEntries(); });
|
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadEntries(); });
|
||||||
document.getElementById('searchInput').addEventListener('input', () => {
|
document.getElementById('searchInput').addEventListener('input', () => {
|
||||||
@@ -1227,14 +1261,17 @@ async function exportCSV() {
|
|||||||
if (gid) p.set('group_id', gid);
|
if (gid) p.set('group_id', gid);
|
||||||
if (df) p.set('date_from', df);
|
if (df) p.set('date_from', df);
|
||||||
if (dt) p.set('date_to', dt);
|
if (dt) p.set('date_to', dt);
|
||||||
|
const mid = document.getElementById('moduleFilter') ? document.getElementById('moduleFilter').value : '';
|
||||||
|
if (mid) p.set('module_id', mid);
|
||||||
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
|
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
|
||||||
const entries = (await res.json()).entries;
|
const entries = (await res.json()).entries;
|
||||||
if (!entries.length) { alert('Нет данных для экспорта'); return; }
|
if (!entries.length) { alert('Нет данных для экспорта'); return; }
|
||||||
const rows = [['Фамилия и имя', 'Группа', 'Описание', 'Дата', 'Фото']];
|
const rows = [['Фамилия и имя', 'Группа', 'Тема модуля', 'Описание', 'Дата', 'Фото']];
|
||||||
entries.forEach(e => {
|
entries.forEach(e => {
|
||||||
rows.push([
|
rows.push([
|
||||||
e.student_name,
|
e.student_name,
|
||||||
e.group_name,
|
e.group_name,
|
||||||
|
e.module_name || '',
|
||||||
e.description.replace(/\n/g, ' '),
|
e.description.replace(/\n/g, ' '),
|
||||||
new Date(e.created_at).toLocaleString('ru'),
|
new Date(e.created_at).toLocaleString('ru'),
|
||||||
e.photo_path ? `${API}${e.photo_path}` : ''
|
e.photo_path ? `${API}${e.photo_path}` : ''
|
||||||
@@ -1440,6 +1477,7 @@ document.getElementById('linkCopyBtn').addEventListener('click', copyLink);
|
|||||||
});
|
});
|
||||||
setDefaultDates();
|
setDefaultDates();
|
||||||
loadGroupOptions();
|
loadGroupOptions();
|
||||||
|
loadModuleOptions();
|
||||||
loadStudentOptions();
|
loadStudentOptions();
|
||||||
loadEntries();
|
loadEntries();
|
||||||
connectLiveUpdates();
|
connectLiveUpdates();
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
let modulesList = [];
|
||||||
|
let mPage = 1;
|
||||||
|
const M_PAGE_SIZE = 25;
|
||||||
|
let mSearchTimer = null;
|
||||||
|
|
||||||
|
function mPlural(n, one, few, many) {
|
||||||
|
n = Math.abs(n) % 100; const n1 = n % 10;
|
||||||
|
if (n > 10 && n < 20) return many;
|
||||||
|
if (n1 > 1 && n1 < 5) return few;
|
||||||
|
if (n1 === 1) return one;
|
||||||
|
return many;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModules() {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
const q = document.getElementById('moduleSearch').value.trim();
|
||||||
|
if (q) p.set('search', q);
|
||||||
|
p.set('limit', M_PAGE_SIZE);
|
||||||
|
p.set('offset', (mPage - 1) * M_PAGE_SIZE);
|
||||||
|
const res = await fetch(`${API}/api/modules?${p}`, { headers: hdr() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
modulesList = data.modules || [];
|
||||||
|
const totalPages = Math.max(1, Math.ceil(data.total / M_PAGE_SIZE));
|
||||||
|
if (mPage > totalPages) { mPage = totalPages; return loadModules(); }
|
||||||
|
renderModules(data.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModules(total) {
|
||||||
|
const list = document.getElementById('moduleList');
|
||||||
|
if (!modulesList.length) {
|
||||||
|
list.innerHTML = '<div class="empty">Модулей пока нет. Нажмите «Добавить модуль».</div>';
|
||||||
|
} else {
|
||||||
|
list.innerHTML = modulesList.map(m => `
|
||||||
|
<div class="item module-item" data-id="${m.id}">
|
||||||
|
<div class="module-info">
|
||||||
|
<div class="module-name">${esc(m.name)}</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
renderPager(document.getElementById('pager'), mPage, Math.max(1, Math.ceil(total / M_PAGE_SIZE)), p => { mPage = p; loadModules(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModuleModal(id = null) {
|
||||||
|
const modal = document.getElementById('moduleModal');
|
||||||
|
const form = document.getElementById('moduleForm');
|
||||||
|
const title = document.getElementById('moduleModalTitle');
|
||||||
|
const submitBtn = document.getElementById('moduleSubmitBtn');
|
||||||
|
form.reset();
|
||||||
|
document.getElementById('moduleId').value = '';
|
||||||
|
document.getElementById('moduleLessons').value = '0';
|
||||||
|
if (id) {
|
||||||
|
const m = modulesList.find(x => x.id === id);
|
||||||
|
if (!m) return;
|
||||||
|
title.textContent = 'Редактировать модуль';
|
||||||
|
submitBtn.textContent = 'Сохранить';
|
||||||
|
form.name.value = m.name;
|
||||||
|
document.getElementById('moduleLessons').value = String(m.lessons_count);
|
||||||
|
document.getElementById('moduleId').value = m.id;
|
||||||
|
} else {
|
||||||
|
title.textContent = 'Создать модуль';
|
||||||
|
submitBtn.textContent = 'Создать';
|
||||||
|
}
|
||||||
|
modal.classList.add('open');
|
||||||
|
setTimeout(() => form.name.focus(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModuleModal() {
|
||||||
|
document.getElementById('moduleModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteModule(id) {
|
||||||
|
const m = modulesList.find(x => x.id === id);
|
||||||
|
const msg = m && m.entries_count > 0
|
||||||
|
? `Удалить модуль «${m.name}»? У ${m.entries_count} связанных записей тема будет очищена.`
|
||||||
|
: 'Удалить этот модуль?';
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
const res = await fetch(`${API}/api/modules/${id}`, { method: 'DELETE', headers: hdr() });
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Модуль удалён');
|
||||||
|
loadModules();
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
alert(err.error || 'Ошибка');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('moduleForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
const id = document.getElementById('moduleId').value;
|
||||||
|
const name = form.name.value.trim();
|
||||||
|
const lessons_count = parseInt(form.lessons_count.value, 10);
|
||||||
|
if (!name) { alert('Название обязательно'); return; }
|
||||||
|
if (!Number.isInteger(lessons_count) || lessons_count < 0) { alert('Количество занятий — целое неотрицательное число'); return; }
|
||||||
|
const res = await fetch(id ? `${API}/api/modules/${id}` : `${API}/api/modules`, {
|
||||||
|
method: id ? 'PUT' : 'POST',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify({ name, lessons_count })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(id ? 'Модуль обновлён' : 'Модуль создан');
|
||||||
|
closeModuleModal();
|
||||||
|
loadModules();
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
alert(err.error || 'Ошибка');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('addModuleBtn').addEventListener('click', () => openModuleModal());
|
||||||
|
document.getElementById('moduleCancelBtn').addEventListener('click', closeModuleModal);
|
||||||
|
document.getElementById('moduleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModuleModal(); });
|
||||||
|
document.getElementById('moduleSearch').addEventListener('input', () => {
|
||||||
|
clearTimeout(mSearchTimer);
|
||||||
|
mSearchTimer = setTimeout(() => { mPage = 1; loadModules(); }, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (await requireAdminPage()) {
|
||||||
|
buildSidebar(document.body.dataset.page);
|
||||||
|
loadModules();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Темы модулей — Админ-панель</title>
|
||||||
|
<link rel="stylesheet" href="admin.css">
|
||||||
|
</head>
|
||||||
|
<body data-page="modules">
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar" id="sidebar"></div>
|
||||||
|
<div class="main">
|
||||||
|
<div class="page-head">
|
||||||
|
<h2>Темы модулей</h2>
|
||||||
|
<p class="page-sub">Ученик выбирает тему модуля при заполнении формы</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-row" style="margin-bottom:16px">
|
||||||
|
<button class="btn-primary" id="addModuleBtn" type="button">
|
||||||
|
<span style="font-size:1.2rem">+</span> Добавить модуль
|
||||||
|
</button>
|
||||||
|
<input type="text" id="moduleSearch" class="search-input" placeholder="Поиск по названию..." style="flex:1;min-width:200px;max-width:340px;padding:10px 14px;font-size:.9rem">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list" id="moduleList"><div class="empty">Загрузка…</div></div>
|
||||||
|
<div class="pager" id="pager"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="moduleModal">
|
||||||
|
<div class="edit-modal" style="max-width:480px">
|
||||||
|
<h3 id="moduleModalTitle">Создать модуль</h3>
|
||||||
|
<form id="moduleForm" class="settings-stack" style="gap:12px">
|
||||||
|
<input type="hidden" id="moduleId">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Название <span style="color:#ef4444">*</span></label>
|
||||||
|
<input type="text" name="name" class="settings-input" required placeholder="Например: Основы Python" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Количество занятий</label>
|
||||||
|
<input type="number" name="lessons_count" id="moduleLessons" class="settings-input" min="0" max="10000" step="1" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="moduleCancelBtn">Отмена</button>
|
||||||
|
<button type="submit" class="btn-primary" id="moduleSubmitBtn">Создать</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="vendor/lucide.min.js"></script>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
<script src="js/modules.js"></script>
|
||||||
|
<style>
|
||||||
|
.page-sub{color:var(--muted);font-size:.85rem;margin:0}
|
||||||
|
.module-item{display:flex;justify-content:space-between;align-items:center;gap:12px}
|
||||||
|
.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)}
|
||||||
|
@media (max-width:640px){.module-item{flex-direction:column;align-items:flex-start}}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -665,6 +665,17 @@ async function ensureBannedIpsTable() {
|
|||||||
)`);
|
)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureModulesTable() {
|
||||||
|
await pool.query(`CREATE TABLE IF NOT EXISTS modules (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(200) NOT NULL UNIQUE,
|
||||||
|
lessons_count INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
)`);
|
||||||
|
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)');
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureEntryPhotosTable() {
|
async function ensureEntryPhotosTable() {
|
||||||
await pool.query(`CREATE TABLE IF NOT EXISTS entry_photos (
|
await pool.query(`CREATE TABLE IF NOT EXISTS entry_photos (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -1292,6 +1303,7 @@ function normalizeRestoreData(data) {
|
|||||||
id: reqInt(x.id),
|
id: reqInt(x.id),
|
||||||
student_name: reqStr(x.student_name, 150),
|
student_name: reqStr(x.student_name, 150),
|
||||||
group_id: reqInt(x.group_id),
|
group_id: reqInt(x.group_id),
|
||||||
|
module_id: optInt(x.module_id, 0, 2147483647),
|
||||||
description: reqStr(x.description, 100000),
|
description: reqStr(x.description, 100000),
|
||||||
description_original: optAiText(x.description_original, 100000) ?? reqStr(x.description, 100000),
|
description_original: optAiText(x.description_original, 100000) ?? reqStr(x.description, 100000),
|
||||||
description_ai: optAiText(x.description_ai, 100000),
|
description_ai: optAiText(x.description_ai, 100000),
|
||||||
@@ -1330,6 +1342,12 @@ function normalizeRestoreData(data) {
|
|||||||
user_id: reqInt(x.user_id),
|
user_id: reqInt(x.user_id),
|
||||||
branch_id: reqInt(x.branch_id),
|
branch_id: reqInt(x.branch_id),
|
||||||
}));
|
}));
|
||||||
|
const modules = (data.modules || []).map(x => ({
|
||||||
|
id: reqInt(x.id),
|
||||||
|
name: reqStr(x.name, 200),
|
||||||
|
lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0,
|
||||||
|
created_at: optTs(x.created_at),
|
||||||
|
}));
|
||||||
const entry_photos = (data.entry_photos || []).map(x => ({
|
const entry_photos = (data.entry_photos || []).map(x => ({
|
||||||
id: reqInt(x.id),
|
id: reqInt(x.id),
|
||||||
entry_id: reqInt(x.entry_id),
|
entry_id: reqInt(x.entry_id),
|
||||||
@@ -1369,13 +1387,13 @@ function normalizeRestoreData(data) {
|
|||||||
for (const [k, v] of Object.entries(data.settings || {})) {
|
for (const [k, v] of Object.entries(data.settings || {})) {
|
||||||
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
|
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
|
||||||
}
|
}
|
||||||
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, group_photos, share_links };
|
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, group_photos, share_links, modules };
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/api/backup', requireAdmin, async (req, res) => {
|
app.get('/api/backup', requireAdmin, async (req, res) => {
|
||||||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
||||||
try {
|
try {
|
||||||
const [g, s, e, st, pf, br, us, ub, gp, ep] = await Promise.all([
|
const [g, s, e, st, pf, br, us, ub, gp, ep, md] = await Promise.all([
|
||||||
pool.query('SELECT * FROM groups ORDER BY id'),
|
pool.query('SELECT * FROM groups ORDER BY id'),
|
||||||
pool.query('SELECT * FROM students ORDER BY id'),
|
pool.query('SELECT * FROM students ORDER BY id'),
|
||||||
pool.query('SELECT * FROM entries ORDER BY id'),
|
pool.query('SELECT * FROM entries ORDER BY id'),
|
||||||
@@ -1386,10 +1404,11 @@ app.get('/api/backup', requireAdmin, async (req, res) => {
|
|||||||
pool.query('SELECT * FROM user_branches ORDER BY user_id, branch_id'),
|
pool.query('SELECT * FROM user_branches ORDER BY user_id, branch_id'),
|
||||||
pool.query('SELECT * FROM group_photos ORDER BY id'),
|
pool.query('SELECT * FROM group_photos ORDER BY id'),
|
||||||
pool.query('SELECT * FROM entry_photos ORDER BY id'),
|
pool.query('SELECT * FROM entry_photos ORDER BY id'),
|
||||||
|
pool.query('SELECT * FROM modules ORDER BY id'),
|
||||||
]);
|
]);
|
||||||
const settings = {};
|
const settings = {};
|
||||||
st.rows.forEach(r => { settings[r.key] = r.value; });
|
st.rows.forEach(r => { settings[r.key] = r.value; });
|
||||||
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows };
|
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows };
|
||||||
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
|
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
|
||||||
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
|
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
|
||||||
const dir = path.join(__dirname, 'uploads');
|
const dir = path.join(__dirname, 'uploads');
|
||||||
@@ -1483,6 +1502,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
|||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
await client.query('DELETE FROM project_files');
|
await client.query('DELETE FROM project_files');
|
||||||
await client.query('DELETE FROM entries');
|
await client.query('DELETE FROM entries');
|
||||||
|
await client.query('DELETE FROM modules');
|
||||||
await client.query('DELETE FROM students');
|
await client.query('DELETE FROM students');
|
||||||
await client.query('DELETE FROM groups');
|
await client.query('DELETE FROM groups');
|
||||||
await client.query('DELETE FROM user_branches');
|
await client.query('DELETE FROM user_branches');
|
||||||
@@ -1507,10 +1527,16 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
|||||||
[x.id, x.name, x.created_at, x.group_id]
|
[x.id, x.name, x.created_at, x.group_id]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
}
|
||||||
for (const x of ndata.entries) {
|
for (const x of ndata.entries) {
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO entries (id, student_name, group_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)',
|
'INSERT INTO entries (id, student_name, group_id, module_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)',
|
||||||
[x.id, x.student_name, x.group_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.deleted_at, x.created_at]
|
[x.id, x.student_name, x.group_id, x.module_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.deleted_at, x.created_at]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const x of ndata.project_files) {
|
for (const x of ndata.project_files) {
|
||||||
@@ -1549,7 +1575,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
|||||||
[k, String(v ?? '')]
|
[k, String(v ?? '')]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos']) {
|
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules']) {
|
||||||
const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl);
|
const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl);
|
||||||
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
|
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
|
||||||
}
|
}
|
||||||
@@ -2263,6 +2289,81 @@ app.put('/api/groups/:id/photos/:photoId/cover', requireAuth, async (req, res) =
|
|||||||
res.json(gRows[0]);
|
res.json(gRows[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Modules (темы модулей) ---
|
||||||
|
app.get('/api/modules', apiLimiter, async (req, res) => {
|
||||||
|
const { limit, offset, search } = req.query;
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
if (search?.trim()) { params.push(`%${search.trim()}%`); conditions.push(`m.name ILIKE $${params.length}`); }
|
||||||
|
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`;
|
||||||
|
const qparams = params.slice();
|
||||||
|
const lim = parseInt(limit, 10);
|
||||||
|
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
||||||
|
const off = parseInt(offset, 10);
|
||||||
|
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
||||||
|
const { rows } = await pool.query(q, qparams);
|
||||||
|
res.json({ modules: rows, total });
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseLessonsCount(v) {
|
||||||
|
if (v === undefined || v === null || v === '') return 0;
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isInteger(n) || n < 0 || n > 10000) throw new Error('Количество занятий — целое число от 0 до 10000');
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/modules', requireAdmin, async (req, res) => {
|
||||||
|
const { name, lessons_count } = req.body;
|
||||||
|
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||||
|
let lessons;
|
||||||
|
try { lessons = parseLessonsCount(lessons_count); }
|
||||||
|
catch (e) { return res.status(400).json({ error: e.message }); }
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'INSERT INTO modules (name, lessons_count) VALUES ($1, $2) RETURNING *',
|
||||||
|
[name.trim(), lessons]
|
||||||
|
);
|
||||||
|
await logAudit(req, 'module.create', { id: rows[0].id, name: name.trim(), lessons_count: lessons });
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === '23505') return res.status(409).json({ error: 'Модуль с таким названием уже существует' });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/modules/:id', requireAdmin, async (req, res) => {
|
||||||
|
const { name, lessons_count } = req.body;
|
||||||
|
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||||
|
let lessons;
|
||||||
|
try { lessons = parseLessonsCount(lessons_count); }
|
||||||
|
catch (e) { return res.status(400).json({ error: e.message }); }
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'UPDATE modules SET name = $1, lessons_count = $2 WHERE id = $3 RETURNING *',
|
||||||
|
[name.trim(), lessons, req.params.id]
|
||||||
|
);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||||||
|
await logAudit(req, 'module.update', { id: req.params.id, name: name.trim(), lessons_count: lessons });
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === '23505') return res.status(409).json({ error: 'Модуль с таким названием уже существует' });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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]);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||||||
|
await logAudit(req, 'module.delete', { id: req.params.id });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
// --- 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 () => {
|
||||||
@@ -3009,12 +3110,13 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
// --- Entries ---
|
// --- Entries ---
|
||||||
app.get('/api/entries', requireAuth, async (req, res) => {
|
app.get('/api/entries', requireAuth, async (req, res) => {
|
||||||
const { group_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;
|
const { group_id, module_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
if (deleted === '1') conditions.push('e.deleted_at IS NOT NULL');
|
if (deleted === '1') conditions.push('e.deleted_at IS NOT NULL');
|
||||||
else conditions.push('e.deleted_at IS NULL');
|
else conditions.push('e.deleted_at IS NULL');
|
||||||
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
|
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||||||
|
if (module_id) { params.push(module_id); conditions.push(`e.module_id = $${params.length}`); }
|
||||||
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
|
||||||
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
|
||||||
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
|
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
|
||||||
@@ -3037,8 +3139,9 @@ app.get('/api/entries', requireAuth, async (req, res) => {
|
|||||||
params
|
params
|
||||||
);
|
);
|
||||||
const total = crows[0].n;
|
const total = crows[0].n;
|
||||||
let q = `SELECT e.*, g.name AS group_name FROM entries e
|
let q = `SELECT e.*, g.name AS group_name, m.name AS module_name FROM entries e
|
||||||
JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`;
|
JOIN groups g ON g.id = e.group_id
|
||||||
|
LEFT JOIN modules m ON m.id = e.module_id${where} ORDER BY e.created_at DESC`;
|
||||||
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}`; }
|
||||||
@@ -3082,7 +3185,10 @@ app.get('/api/entries/:id', requireAuth, async (req, res) => {
|
|||||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||||
}
|
}
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
'SELECT e.*, g.name AS group_name FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1',
|
`SELECT e.*, g.name AS group_name, m.name AS module_name FROM entries e
|
||||||
|
JOIN groups g ON g.id = e.group_id
|
||||||
|
LEFT JOIN modules m ON m.id = e.module_id
|
||||||
|
WHERE e.id = $1`,
|
||||||
[req.params.id]
|
[req.params.id]
|
||||||
);
|
);
|
||||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||||
@@ -3543,7 +3649,7 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
|||||||
return res.status(400).json({ error: 'Недопустимый файл' });
|
return res.status(400).json({ error: 'Недопустимый файл' });
|
||||||
});
|
});
|
||||||
}, async (req, res) => {
|
}, async (req, res) => {
|
||||||
const { student_name, group_id, description, website } = req.body;
|
const { student_name, group_id, description, module_id, website } = req.body;
|
||||||
const photos = req.files?.photo || [];
|
const photos = req.files?.photo || [];
|
||||||
const projectFiles = req.files?.files || [];
|
const projectFiles = req.files?.files || [];
|
||||||
if (website) {
|
if (website) {
|
||||||
@@ -3579,6 +3685,20 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
|||||||
projectFiles.forEach(removeUpload);
|
projectFiles.forEach(removeUpload);
|
||||||
return res.status(400).json({ error: 'Группа не найдена' });
|
return res.status(400).json({ error: 'Группа не найдена' });
|
||||||
}
|
}
|
||||||
|
const mid = module_id === undefined || module_id === null || module_id === '' ? null : Number.parseInt(module_id, 10);
|
||||||
|
if (mid !== null && !Number.isInteger(mid)) {
|
||||||
|
photos.forEach(removeUpload);
|
||||||
|
projectFiles.forEach(removeUpload);
|
||||||
|
return res.status(400).json({ error: 'Модуль не найден' });
|
||||||
|
}
|
||||||
|
if (mid !== null) {
|
||||||
|
const modCheck = await pool.query('SELECT id FROM modules WHERE id = $1', [mid]);
|
||||||
|
if (!modCheck.rows.length) {
|
||||||
|
photos.forEach(removeUpload);
|
||||||
|
projectFiles.forEach(removeUpload);
|
||||||
|
return res.status(400).json({ error: 'Модуль не найден' });
|
||||||
|
}
|
||||||
|
}
|
||||||
const intervalMin = parseInt(await getSetting('spam_interval_min', '30'), 10) || 0;
|
const intervalMin = parseInt(await getSetting('spam_interval_min', '30'), 10) || 0;
|
||||||
if (intervalMin > 0) {
|
if (intervalMin > 0) {
|
||||||
const dup = await pool.query(
|
const dup = await pool.query(
|
||||||
@@ -3603,9 +3723,9 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
|||||||
);
|
);
|
||||||
const mainPhotoPath = photos.length ? `/uploads/${photos[0].filename}` : null;
|
const mainPhotoPath = photos.length ? `/uploads/${photos[0].filename}` : null;
|
||||||
const { rows } = await client.query(
|
const { rows } = await client.query(
|
||||||
`INSERT INTO entries (student_name, group_id, description, description_original, photo_path)
|
`INSERT INTO entries (student_name, group_id, module_id, description, description_original, photo_path)
|
||||||
VALUES ($1, $2, $3, $3, $4) RETURNING *`,
|
VALUES ($1, $2, $3, $4, $4, $5) RETURNING *`,
|
||||||
[student_name.trim(), gid, description.trim(), mainPhotoPath]
|
[student_name.trim(), gid, mid, description.trim(), mainPhotoPath]
|
||||||
);
|
);
|
||||||
for (let i = 0; i < photos.length; i++) {
|
for (let i = 0; i < photos.length; i++) {
|
||||||
const p = photos[i];
|
const p = photos[i];
|
||||||
@@ -3644,12 +3764,27 @@ app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req,
|
|||||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||||
}
|
}
|
||||||
const { student_name, group_id, description } = req.body;
|
const { student_name, group_id, description, module_id } = req.body;
|
||||||
const newPhotos = req.files || [];
|
const newPhotos = req.files || [];
|
||||||
for (const p of newPhotos) {
|
for (const p of newPhotos) {
|
||||||
await convertPhoto(p);
|
await convertPhoto(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasModule = Object.prototype.hasOwnProperty.call(req.body, 'module_id');
|
||||||
|
let mid = null;
|
||||||
|
if (hasModule && module_id !== undefined && module_id !== null && module_id !== '') {
|
||||||
|
mid = Number.parseInt(module_id, 10);
|
||||||
|
if (!Number.isInteger(mid)) {
|
||||||
|
newPhotos.forEach(p => safeUnlink(p.path));
|
||||||
|
return res.status(400).json({ error: 'Модуль не найден' });
|
||||||
|
}
|
||||||
|
const modCheck = await pool.query('SELECT id FROM modules WHERE id = $1', [mid]);
|
||||||
|
if (!modCheck.rows.length) {
|
||||||
|
newPhotos.forEach(p => safeUnlink(p.path));
|
||||||
|
return res.status(400).json({ error: 'Модуль не найден' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`UPDATE entries SET
|
`UPDATE entries SET
|
||||||
student_name = COALESCE($1, student_name),
|
student_name = COALESCE($1, student_name),
|
||||||
@@ -3659,13 +3794,16 @@ app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req,
|
|||||||
description_ai = CASE WHEN $3 IS NULL THEN description_ai ELSE NULL END,
|
description_ai = CASE WHEN $3 IS NULL THEN description_ai ELSE NULL END,
|
||||||
ai_status = CASE WHEN $3 IS NULL THEN ai_status ELSE 'pending' END,
|
ai_status = CASE WHEN $3 IS NULL THEN ai_status ELSE 'pending' END,
|
||||||
ai_error = CASE WHEN $3 IS NULL THEN ai_error ELSE NULL END,
|
ai_error = CASE WHEN $3 IS NULL THEN ai_error ELSE NULL END,
|
||||||
ai_checked_at = CASE WHEN $3 IS NULL THEN ai_checked_at ELSE NULL END
|
ai_checked_at = CASE WHEN $3 IS NULL THEN ai_checked_at ELSE NULL END,
|
||||||
|
module_id = CASE WHEN $5 THEN $6 ELSE module_id END
|
||||||
WHERE id = $4 RETURNING *`,
|
WHERE id = $4 RETURNING *`,
|
||||||
[
|
[
|
||||||
student_name ? student_name.trim() : null,
|
student_name ? student_name.trim() : null,
|
||||||
group_id || null,
|
group_id || null,
|
||||||
description ? description.trim() : null,
|
description ? description.trim() : null,
|
||||||
req.params.id,
|
req.params.id,
|
||||||
|
hasModule,
|
||||||
|
mid,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
@@ -4674,6 +4812,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
|||||||
try { await ensureBannedIpsTable(); } catch (err) { console.error('Banned IPs table:', err); }
|
try { await ensureBannedIpsTable(); } catch (err) { console.error('Banned IPs table:', err); }
|
||||||
try { await loadBans(); } catch (err) { console.error('Load bans:', err); }
|
try { await loadBans(); } catch (err) { console.error('Load bans:', err); }
|
||||||
setInterval(() => { loadBans().catch(err => console.error('Load bans:', err)); }, 60 * 1000).unref();
|
setInterval(() => { loadBans().catch(err => console.error('Load bans:', err)); }, 60 * 1000).unref();
|
||||||
|
try { await ensureModulesTable(); } catch (err) { console.error('Modules table:', err); }
|
||||||
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
|
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
|
||||||
try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
|
try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
|
||||||
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
|
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }
|
||||||
|
|||||||
Reference in New Issue
Block a user