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:
dev
2026-09-18 18:56:42 +03:00
parent d434732f41
commit e6291a0235
10 changed files with 455 additions and 18 deletions
+1
View File
@@ -56,6 +56,7 @@ function buildSidebar(active) {
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
];
const adminNav = [
{ page: 'modules', label: 'Темы модулей', icon: 'layers' },
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
+7
View File
@@ -127,6 +127,13 @@ nav a:hover{color:var(--text)}
</select>
</div>
<div class="field" id="moduleField" style="display:none">
<label>Тема модуля</label>
<select id="moduleInput">
<option value="">— выбери тему —</option>
</select>
</div>
<div class="field">
<label>Что сделал</label>
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
+5
View File
@@ -43,6 +43,7 @@
<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><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="dateTo"></div>
<div><label>&nbsp;</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>
<select id="editGroup" class="settings-input" required></select>
</div>
<div class="settings-field">
<label>Тема модуля</label>
<select id="editModule" class="settings-input"></select>
</div>
<div class="settings-field full">
<label>Что делала(а) <span style="color:#ef4444">*</span>
<button type="button" id="aiCorrectBtn" class="ai-btn" title="Исправить ошибки ИИ">✨</button>
+26
View File
@@ -4,6 +4,8 @@ const preview = document.getElementById('preview');
const placeholderSrc = preview.src;
const removePhotoBtn = document.getElementById('removePhotoBtn');
const groupInput = document.getElementById('groupInput');
const moduleInput = document.getElementById('moduleInput');
const moduleField = document.getElementById('moduleField');
const toast = document.getElementById('toast');
const camVideo = document.getElementById('camVideo');
const camCanvas = document.getElementById('camCanvas');
@@ -133,6 +135,23 @@ if (params.get('nameInput')) {
}
(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 students = await res.json();
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);
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');
btn.disabled = true;
btn.textContent = 'Отправка...';
@@ -228,6 +253,7 @@ form.addEventListener('submit', async (e) => {
selectedFiles.forEach(f => fd.append('files', f, f.name));
fd.append('student_name', studentName || document.getElementById('nameInput').value);
fd.append('group_id', groupInput.value);
fd.append('module_id', moduleInput.value);
fd.append('description', document.getElementById('descInput').value);
fd.append('website', document.getElementById('hpWebsite').value);
+40 -2
View File
@@ -4,6 +4,8 @@ let highlightNewIds = new Set();
let highlightClearTimer = null;
let editId = null;
let editGroups = [];
let editModules = [];
let modulesLoaded = false;
let groupsLoaded = false;
let studentsLoaded = false;
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="">` : ''}
<div class="info">
<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>
${filesHTML(e.files)}
<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="">`
: `<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.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="name">${esc(e.student_name)}</div>
<div class="desc">${esc(e.description)}${aiBadge(e)}</div>
@@ -216,6 +219,31 @@ async function loadGroupOptions() {
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() {
if (studentsLoaded) return;
const dl = document.getElementById('studentFilterList');
@@ -237,6 +265,8 @@ async function loadEntries() {
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
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('offset', (page - 1) * PAGE_SIZE);
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.value = e.group_id;
if (!modulesLoaded) await loadModuleOptions();
fillEditModuleSelect(e.module_id);
renderEditPhotos(e.photos || [], e.photo_path);
document.getElementById('editModal').classList.add('open');
}
@@ -1130,6 +1162,7 @@ async function saveEdit() {
formData.append('student_name', name);
formData.append('group_id', group_id);
formData.append('description', description);
formData.append('module_id', document.getElementById('editModule').value);
const photoFiles = document.getElementById('editPhoto').files;
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('groupFilter').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('moduleFilter').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('searchInput').addEventListener('input', () => {
@@ -1227,14 +1261,17 @@ async function exportCSV() {
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
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 entries = (await res.json()).entries;
if (!entries.length) { alert('Нет данных для экспорта'); return; }
const rows = [['Фамилия и имя', 'Группа', 'Описание', 'Дата', 'Фото']];
const rows = [['Фамилия и имя', 'Группа', 'Тема модуля', 'Описание', 'Дата', 'Фото']];
entries.forEach(e => {
rows.push([
e.student_name,
e.group_name,
e.module_name || '',
e.description.replace(/\n/g, ' '),
new Date(e.created_at).toLocaleString('ru'),
e.photo_path ? `${API}${e.photo_path}` : ''
@@ -1440,6 +1477,7 @@ document.getElementById('linkCopyBtn').addEventListener('click', copyLink);
});
setDefaultDates();
loadGroupOptions();
loadModuleOptions();
loadStudentOptions();
loadEntries();
connectLiveUpdates();
+137
View File
@@ -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="Удалить">&times;</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();
}
})();
+64
View File
@@ -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>