feat(lessons): отчёты о занятиях — таблица, CRUD, страница и блок на дашборде

Добавлена сущность «что прошли на занятии»:
- lesson_reports (init.sql + migration.sql + ensureLessonReportsTable)
- GET/POST /api/lesson-reports, PUT/DELETE /api/lesson-reports/:id
  с branchScope, уникальностью (group_id, lesson_date) и лимитом текста
- уведомление lesson.report (NOTIFY_TYPES + настройка + иконка)
- кэш-префикс lessons: + инвалидация stats:/dashboard:
- восстановление lesson_reports в normalizeRestoreData
- блок recent_lessons в /api/dashboard

Фронтенд:
- public/lessons.html + public/js/lessons.js — список с фильтрами и правкой
- openLessonModal в admin.js — общая модалка из журнала и дашборда
- кнопки в журнале и быстрые действия дашборда

Также исправлен сдвиг индексов параметров в notificationsScope —
$1 уходил повторно в список филиалов из-за params.push без смещения.
This commit is contained in:
dev
2026-10-03 23:33:45 +03:00
parent 0dc8ffdb87
commit a84f33307e
12 changed files with 742 additions and 8 deletions
+227
View File
@@ -49,6 +49,7 @@ function buildSidebar(active) {
const base = [
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
{ page: 'lessons', label: 'Отчёты о занятиях', icon: 'notebook-pen' },
{ page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true },
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
{ page: 'groups', label: 'Группы', icon: 'users' },
@@ -176,6 +177,7 @@ const NOTIFY_ICONS = {
'backup.restore': 'database',
'backup.create': 'download',
'system.test': 'send',
'lesson.report': 'notebook-pen',
};
const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' };
@@ -483,6 +485,231 @@ function copyText(text) {
}
}
let lessonModalGroups = null;
let lessonModalGroupsPromise = null;
function lessonTodayIso() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function lessonNowTime() {
const d = new Date();
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
async function loadLessonGroups() {
if (lessonModalGroups) return lessonModalGroups;
if (!lessonModalGroupsPromise) {
lessonModalGroupsPromise = fetch(`${API}/api/groups`, { headers: hdr() })
.then(res => (res.ok ? res.json() : []))
.then(g => { lessonModalGroups = Array.isArray(g) ? g.filter(x => !x.deleted_at) : []; return lessonModalGroups; })
.catch(() => { lessonModalGroups = []; return lessonModalGroups; });
}
return lessonModalGroupsPromise;
}
function ensureLessonModal() {
let modal = document.getElementById('lessonModal');
if (modal) return modal;
modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'lessonModal';
modal.innerHTML = `
<div class="edit-modal lesson-modal">
<h3 id="lessonModalTitle">Что прошли на занятии</h3>
<form id="lessonForm" class="settings-stack" style="gap:12px" autocomplete="off">
<input type="hidden" id="lessonId">
<div class="settings-field">
<label>Группа <span style="color:#ef4444">*</span></label>
<select id="lessonGroup" class="settings-input" required></select>
</div>
<div class="lesson-row">
<div class="settings-field">
<label>Дата занятия <span style="color:#ef4444">*</span></label>
<input type="date" id="lessonDate" class="settings-input" required>
</div>
<div class="settings-field">
<label>Время</label>
<input type="time" id="lessonTime" class="settings-input">
</div>
</div>
<div class="settings-field">
<label>Что прошли на занятии <span style="color:#ef4444">*</span></label>
<textarea id="lessonText" class="settings-input" rows="6" maxlength="5000" required
placeholder="Например: разобрали циклы for и while, решали задачи на списки, домашнее задание — функции"></textarea>
<span class="hint" id="lessonHint">0 / 5000</span>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="lessonCancel">Отмена</button>
<button type="submit" class="btn-primary" id="lessonSubmit">Сохранить</button>
</div>
</form>
</div>`;
document.body.appendChild(modal);
const groupSel = modal.querySelector('#lessonGroup');
const textEl = modal.querySelector('#lessonText');
const hintEl = modal.querySelector('#lessonHint');
const submitBtn = modal.querySelector('#lessonSubmit');
textEl.addEventListener('input', () => { hintEl.textContent = `${textEl.value.length} / 5000`; });
modal.addEventListener('click', e => { if (e.target === modal) closeLessonModal(); });
modal.querySelector('#lessonCancel').addEventListener('click', closeLessonModal);
groupSel.addEventListener('change', () => prefetchLessonForGroupDate());
modal.querySelector('#lessonForm').addEventListener('submit', e => submitLessonModal(e));
return modal;
}
async function submitLessonModal(e) {
e.preventDefault();
const modal = document.getElementById('lessonModal');
if (!modal) return;
const groupSel = modal.querySelector('#lessonGroup');
const dateEl = modal.querySelector('#lessonDate');
const timeEl = modal.querySelector('#lessonTime');
const textEl = modal.querySelector('#lessonText');
const submitBtn = modal.querySelector('#lessonSubmit');
const groupId = groupSel.value;
const date = dateEl.value;
const text = textEl.value.trim();
if (!groupId) { showToast('Выберите группу'); groupSel.focus(); return; }
if (!date) { showToast('Укажите дату занятия'); return; }
if (!text) { showToast('Введите текст отчёта'); textEl.focus(); return; }
const id = modal.querySelector('#lessonId').value;
submitBtn.disabled = true;
try {
const res = await fetch(`${API}/api/lesson-reports${id ? '/' + id : ''}`, {
method: id ? 'PUT' : 'POST',
headers: hdrJson(),
body: JSON.stringify({ group_id: Number(groupId), lesson_date: date, lesson_time: timeEl.value || null, text }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 409 && data.id) {
closeLessonModal();
openLessonModal({ id: data.id });
showToast('За эту дату отчёт уже есть — открыт для редактирования');
return;
}
showToast(data.error || 'Не удалось сохранить отчёт');
return;
}
closeLessonModal();
showToast(id ? 'Отчёт о занятии обновлён' : 'Отчёт о занятии сохранён');
if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
} catch {
showToast('Ошибка сети');
} finally {
submitBtn.disabled = false;
}
}
async function prefetchLessonForGroupDate() {
const modal = document.getElementById('lessonModal');
if (!modal || !modal.classList.contains('open')) return;
if (modal.querySelector('#lessonId').value) return;
const groupId = modal.querySelector('#lessonGroup').value;
const date = modal.querySelector('#lessonDate').value;
if (!groupId || !date) return;
try {
const q = `group_id=${encodeURIComponent(groupId)}&date_from=${encodeURIComponent(date)}&date_to=${encodeURIComponent(date)}&limit=1`;
const res = await fetch(`${API}/api/lesson-reports?${q}`, { headers: hdr() });
if (!res.ok) return;
const data = await res.json();
const item = (data.items || [])[0];
if (!item) return;
fillLessonModalFromReport(modal, item);
} catch {}
}
function fillLessonModalFromReport(modal, r) {
modal.querySelector('#lessonId').value = r.id;
if (r.lesson_time && !modal.querySelector('#lessonTime').value) {
modal.querySelector('#lessonTime').value = String(r.lesson_time).slice(0, 5);
}
if (!modal.querySelector('#lessonText').value) {
const textEl = modal.querySelector('#lessonText');
textEl.value = r.text || '';
textEl.dispatchEvent(new Event('input'));
}
modal.querySelector('#lessonModalTitle').textContent = 'Редактирование отчёта';
modal.querySelector('#lessonSubmit').textContent = 'Обновить';
}
function resetLessonModalFields(modal) {
modal.querySelector('#lessonId').value = '';
modal.querySelector('#lessonDate').value = lessonTodayIso();
modal.querySelector('#lessonTime').value = lessonNowTime();
const textEl = modal.querySelector('#lessonText');
textEl.value = '';
textEl.dispatchEvent(new Event('input'));
modal.querySelector('#lessonModalTitle').textContent = 'Что прошли на занятии';
modal.querySelector('#lessonSubmit').textContent = 'Сохранить';
}
async function openLessonModal(opts = {}) {
const modal = ensureLessonModal();
const groupSel = modal.querySelector('#lessonGroup');
const previous = groupSel.value;
const groups = await loadLessonGroups();
groupSel.innerHTML = '';
if (!groups.length) {
const o = document.createElement('option');
o.value = '';
o.textContent = 'Нет доступных групп';
groupSel.appendChild(o);
} else {
groups.forEach(g => {
const o = document.createElement('option');
o.value = g.id;
o.textContent = g.branch_name ? `${g.name} (${g.branch_name})` : g.name;
groupSel.appendChild(o);
});
}
resetLessonModalFields(modal);
const wanted = opts.groupId ? String(opts.groupId) : previous;
if (wanted && groups.some(g => String(g.id) === wanted)) groupSel.value = wanted;
if (opts.text) {
const textEl = modal.querySelector('#lessonText');
textEl.value = opts.text;
textEl.dispatchEvent(new Event('input'));
}
modal.classList.add('open');
if (opts.id) {
try {
const res = await fetch(`${API}/api/lesson-reports/${opts.id}`, { headers: hdr() });
if (res.ok) {
const r = await res.json();
groupSel.value = String(r.group_id);
modal.querySelector('#lessonDate').value = String(r.lesson_date).slice(0, 10);
modal.querySelector('#lessonTime').value = r.lesson_time ? String(r.lesson_time).slice(0, 5) : '';
fillLessonModalFromReport(modal, r);
}
} catch {}
} else {
await prefetchLessonForGroupDate();
}
renderIcons();
setTimeout(() => modal.querySelector('#lessonText').focus(), 50);
}
function closeLessonModal() {
const modal = document.getElementById('lessonModal');
if (modal) modal.classList.remove('open');
}
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
const modal = document.getElementById('lessonModal');
if (modal && modal.classList.contains('open')) closeLessonModal();
});
function renderPager(el, page, totalPages, onPage) {
if (!el) return;
if (totalPages <= 1) { el.innerHTML = ''; return; }