From a84f33307eff15bdd719194c52c41c94a8b50db3 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 3 Oct 2026 23:33:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(lessons):=20=D0=BE=D1=82=D1=87=D1=91=D1=82?= =?UTF-8?q?=D1=8B=20=D0=BE=20=D0=B7=D0=B0=D0=BD=D1=8F=D1=82=D0=B8=D1=8F?= =?UTF-8?q?=D1=85=20=E2=80=94=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0?= =?UTF-8?q?,=20CRUD,=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=B1=D0=BB=D0=BE=D0=BA=20=D0=BD=D0=B0=20=D0=B4=D0=B0?= =?UTF-8?q?=D1=88=D0=B1=D0=BE=D1=80=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена сущность «что прошли на занятии»: - 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 без смещения. --- AGENTS.md | 5 +- db/init.sql | 15 +++ db/migration.sql | 16 +++ public/admin.css | 28 +++++ public/admin.js | 227 +++++++++++++++++++++++++++++++++++++ public/dashboard.html | 6 + public/journal.html | 1 + public/js/dashboard.js | 31 ++++++ public/js/journal.js | 3 + public/js/lessons.js | 139 +++++++++++++++++++++++ public/lessons.html | 32 ++++++ server.js | 247 ++++++++++++++++++++++++++++++++++++++++- 12 files changed, 742 insertions(+), 8 deletions(-) create mode 100644 public/js/lessons.js create mode 100644 public/lessons.html diff --git a/AGENTS.md b/AGENTS.md index c47847f..c8a0e02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo - **`resetTime` в `rateLimitStore.increment` обязан быть `Date`** — express-rate-limit v8 вызывает `resetTime.getTime()` - **Пабликация всегда отдаёт подписчикам строку** (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату - **Инвалидация — по префиксу** (`SCAN` + `DEL`), точечного удаления по ключу избегайте -- **Ключевые пространства**: `setting:`, `groups:`, `students:`, `entries:`, `stats:`, `dashboard:`, `share:payload:`, `public-settings`, `system-info`, `session:`, `ban:`, `fail:`, `rl:` +- **Ключевые пространства**: `setting:`, `groups:`, `students:`, `entries:`, `lessons:`, `stats:`, `dashboard:`, `share:payload:`, `public-settings`, `system-info`, `session:`, `ban:`, `fail:`, `rl:` - **Сессии**: `loadUserByToken` кэширует пользователя на 30 с. Любая мутация `users` / `sessions` / `user_branches` обязана вызывать `invalidateSessions()` или удалять `session:`, иначе деактивированный пользователь сохранит доступ - **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1` @@ -250,7 +250,8 @@ guards against. | `docker-compose.minio.yml` | Override: S3 service backed by MinIO instead of SeaweedFS | | `Dockerfile` | App image build | | `public/*.html` | Frontend pages | -| `public/admin.js` | Shared frontend logic | +| `public/admin.js` | Shared frontend logic, модалка отчёта о занятии (`openLessonModal`) | +| `public/lessons.html` | Отчёты о занятиях: список, фильтры, редактирование | | `scripts/backup.sh` | Host-level backup script (DB dump + storage export) | | `scripts/restore.sh` | Host-level restore script (DB dump + storage import) | | `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) | diff --git a/db/init.sql b/db/init.sql index 9f671fb..33f83dd 100644 --- a/db/init.sql +++ b/db/init.sql @@ -221,6 +221,21 @@ CREATE TABLE IF NOT EXISTS sessions ( CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); +CREATE TABLE IF NOT EXISTS lesson_reports ( + id SERIAL PRIMARY KEY, + group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + lesson_date DATE NOT NULL, + lesson_time TIME, + text TEXT NOT NULL, + author_id INT REFERENCES users(id) ON DELETE SET NULL, + branch_id INT REFERENCES branches(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date); +CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC); + CREATE TABLE IF NOT EXISTS photo_jobs ( id SERIAL PRIMARY KEY, entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, diff --git a/db/migration.sql b/db/migration.sql index 16c6684..9551dc9 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -304,3 +304,19 @@ INSERT INTO settings (key, value) VALUES ('notify_ip_ban', 'true') ON CONFLICT ( INSERT INTO settings (key, value) VALUES ('notify_backup_restore', 'true') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_lesson_report', 'true') ON CONFLICT (key) DO NOTHING; + +CREATE TABLE IF NOT EXISTS lesson_reports ( + id SERIAL PRIMARY KEY, + group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + lesson_date DATE NOT NULL, + lesson_time TIME, + text TEXT NOT NULL, + author_id INT REFERENCES users(id) ON DELETE SET NULL, + branch_id INT REFERENCES branches(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date); +CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC); diff --git a/public/admin.css b/public/admin.css index 21ac7d6..e36a464 100644 --- a/public/admin.css +++ b/public/admin.css @@ -658,4 +658,32 @@ body[data-page=settings] .page-head{align-items:flex-end} .notify-panel{left:12px;right:12px;width:auto;bottom:12px} } +.lesson-modal{max-width:560px;width:92vw} +.lesson-modal .lesson-row{display:grid;grid-template-columns:1fr 1fr;gap:12px} +.lesson-modal .hint{font-size:.78rem;color:var(--muted);text-align:right} +.lesson-modal textarea{min-height:130px} +@media(max-width:560px){ + .lesson-modal .lesson-row{grid-template-columns:1fr} +} +.lesson-item{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;display:flex;flex-direction:column;gap:8px} +.lesson-item-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.lesson-group{font-weight:600;font-size:.95rem} +.lesson-when{color:var(--muted);font-size:.78rem;display:inline-flex;align-items:center;gap:5px} +.lesson-when i{width:13px;height:13px} +.lesson-badge{font-size:.7rem;font-weight:700;padding:2px 9px;border-radius:999px;background:rgba(37,99,235,.1);color:var(--accent)} +.lesson-badge.today{background:rgba(16,185,129,.14);color:#059669} +.lesson-text{font-size:.88rem;color:var(--text);white-space:pre-wrap;word-break:break-word;line-height:1.55} +.lesson-author{font-size:.75rem;color:var(--muted)} +.lesson-actions{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto} +.lesson-item .btn-link{padding:6px 12px;font-size:.78rem} +.lesson-item .btn-link.danger{color:#ef4444;border-color:rgba(239,68,68,.35)} +.lesson-item .btn-link.danger:hover{border-color:#ef4444} +.lesson-list{display:flex;flex-direction:column;gap:10px} +.lesson-dash-item{display:flex;flex-direction:column;gap:3px;padding:8px 6px;border-radius:10px;transition:background .15s;cursor:pointer} +.lesson-dash-item:hover{background:var(--bg)} +.lesson-dash-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.lesson-dash-group{font-weight:600;font-size:.86rem} +.lesson-dash-when{color:var(--muted);font-size:.74rem;margin-left:auto;white-space:nowrap} +.lesson-dash-text{font-size:.8rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.45} + diff --git a/public/admin.js b/public/admin.js index 009201e..f858141 100644 --- a/public/admin.js +++ b/public/admin.js @@ -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 = ` +
+

Что прошли на занятии

+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + + 0 / 5000 +
+
+ + +
+
+
`; + 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; } diff --git a/public/dashboard.html b/public/dashboard.html index 4bf81b1..8b5993a 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -28,6 +28,11 @@
+
+

Что прошли на занятиях

+
+ +

Фото групп

@@ -40,6 +45,7 @@

Быстрые действия

Журнал + Файлы Фото Группы diff --git a/public/journal.html b/public/journal.html index 87ebd67..3f9a6d6 100644 --- a/public/journal.html +++ b/public/journal.html @@ -50,6 +50,7 @@
+
diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 293a0cc..df6e672 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -100,6 +100,24 @@ function renderPhotos(rows) {
`).join(''); } +function renderLessons(rows) { + const el = document.getElementById('lessonList'); + if (!el) return; + if (!rows.length) { el.innerHTML = '
Отчётов пока нет
'; return; } + el.innerHTML = rows.map(r => { + const date = String(r.lesson_date || '').slice(0, 10).split('-').reverse().join('.'); + const time = r.lesson_time ? String(r.lesson_time).slice(0, 5) : ''; + return ` +
+
+ ${esc(r.group_name)} + ${esc(date + (time ? ' · ' + time : ''))} +
+
${esc(r.text)}
+
`; + }).join(''); +} + async function loadDashboard() { const res = await fetch(`${API}/api/dashboard`, { headers: hdr() }); if (!res.ok) { document.getElementById('stats').innerHTML = '
Не удалось загрузить данные
'; return; } @@ -110,11 +128,24 @@ async function loadDashboard() { renderActive(d.active_groups); renderTop(d.top_students); renderPhotos(d.photos); + renderLessons(d.recent_lessons || []); renderIcons(); } document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open')); +const lessonBtn = document.getElementById('lessonReportBtn'); +if (lessonBtn) lessonBtn.addEventListener('click', () => openLessonModal()); + +function onLessonReportSaved() { + loadDashboard(); +} + +document.addEventListener('click', e => { + const item = e.target.closest('[data-lesson]'); + if (item && !e.target.closest('button')) openLessonModal({ id: item.dataset.lesson }); +}); + (async () => { if (await checkAuth()) { buildSidebar(document.body.dataset.page); diff --git a/public/js/journal.js b/public/js/journal.js index a77d2bf..57f394d 100644 --- a/public/js/journal.js +++ b/public/js/journal.js @@ -1648,6 +1648,9 @@ document.addEventListener('click', e => { document.getElementById('exportCsvBtn').addEventListener('click', exportCSV); document.getElementById('createLinkBtn').addEventListener('click', openLinkCreate); +document.getElementById('lessonReportBtn').addEventListener('click', () => { + openLessonModal({ groupId: document.getElementById('groupFilter').value }); +}); document.querySelectorAll('#viewToggle button').forEach(b => b.addEventListener('click', () => setView(b.dataset.view))); document.getElementById('editCancelBtn').addEventListener('click', closeEdit); document.getElementById('linkCreateCloseBtn').addEventListener('click', closeLinkCreate); diff --git a/public/js/lessons.js b/public/js/lessons.js new file mode 100644 index 0000000..21f87b9 --- /dev/null +++ b/public/js/lessons.js @@ -0,0 +1,139 @@ +const PAGE_SIZE = 20; +let page = 1; +let total = 0; +let searchTimer = null; +let groupsLoaded = false; + +function lessonDateText(iso) { + const s = String(iso || '').slice(0, 10); + if (!s) return ''; + const d = new Date(s + 'T00:00:00'); + if (Number.isNaN(d.getTime())) return s; + return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' }); +} + +function lessonWhenText(r) { + const date = lessonDateText(r.lesson_date); + const time = r.lesson_time ? String(r.lesson_time).slice(0, 5) : ''; + return time ? `${date}, ${time}` : date; +} + +function lessonAuthorText(r) { + const name = r.author_name || r.author_username; + if (!name) return ''; + const upd = r.updated_at && r.created_at && new Date(r.updated_at).getTime() - new Date(r.created_at).getTime() > 60000; + return upd ? `${name} · изменён ${notifyTime(r.updated_at)}` : name; +} + +function lessonItemHtml(r) { + const isToday = String(r.lesson_date).slice(0, 10) === lessonTodayIso(); + const author = lessonAuthorText(r); + return ` +
+
+ ${esc(r.group_name || '')} + ${esc(lessonWhenText(r))} + ${isToday ? 'сегодня' : ''} +
+ + ${isAdmin() ? `` : ''} +
+
+
${esc(r.text || '')}
+ ${author ? `
${esc(author)}
` : ''} +
`; +} + +function renderLessons(items) { + const list = document.getElementById('lessonsList'); + const state = document.getElementById('lessonsState'); + list.innerHTML = items.length + ? items.map(lessonItemHtml).join('') + : '
Отчётов пока нет
'; + if (state) { + state.textContent = total ? `Показано ${items.length} из ${total}` : ''; + } + renderIcons(); +} + +async function loadLessons() { + const q = new URLSearchParams(); + const gid = document.getElementById('groupFilter').value; + const df = document.getElementById('dateFrom').value; + const dt = document.getElementById('dateTo').value; + const search = document.getElementById('searchInput').value.trim(); + if (gid) q.set('group_id', gid); + if (df) q.set('date_from', df); + if (dt) q.set('date_to', dt); + if (search) q.set('search', search); + q.set('limit', String(PAGE_SIZE)); + q.set('offset', String((page - 1) * PAGE_SIZE)); + const list = document.getElementById('lessonsList'); + try { + const res = await fetch(`${API}/api/lesson-reports?${q.toString()}`, { headers: hdr() }); + if (!res.ok) { + list.innerHTML = `
Ошибка загрузки (${res.status})
`; + return; + } + const data = await res.json(); + total = Number(data.total) || 0; + const maxPage = Math.max(1, Math.ceil(total / PAGE_SIZE)); + if (page > maxPage) { page = maxPage; return loadLessons(); } + renderLessons(data.items || []); + renderPager(document.getElementById('pager'), page, maxPage, p => { page = p; loadLessons(); }); + } catch { + list.innerHTML = '
Ошибка сети
'; + } +} + +async function loadGroupFilter() { + if (groupsLoaded) return; + const sel = document.getElementById('groupFilter'); + const groups = await loadLessonGroups(); + groups.forEach(g => { + const o = document.createElement('option'); + o.value = g.id; + o.textContent = g.branch_name ? `${g.name} (${g.branch_name})` : g.name; + sel.appendChild(o); + }); + groupsLoaded = true; +} + +async function deleteLesson(id) { + if (!confirm('Удалить отчёт о занятии?')) return; + const res = await fetch(`${API}/api/lesson-reports/${id}`, { method: 'DELETE', headers: hdr() }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { showToast(data.error || 'Не удалось удалить отчёт'); return; } + showToast('Отчёт удалён'); + loadLessons(); +} + +function onLessonReportSaved() { + loadLessons(); +} + +document.getElementById('lessonsList').addEventListener('click', e => { + const edit = e.target.closest('[data-edit]'); + if (edit) { openLessonModal({ id: edit.dataset.edit }); return; } + const del = e.target.closest('[data-del]'); + if (del) { deleteLesson(del.dataset.del); } +}); + +document.getElementById('addLessonBtn').addEventListener('click', () => { + openLessonModal({ groupId: document.getElementById('groupFilter').value }); +}); + +document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadLessons(); }); +document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadLessons(); }); +document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadLessons(); }); +document.getElementById('searchInput').addEventListener('input', () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { page = 1; loadLessons(); }, 350); +}); + +(async () => { + if (!(await checkAuth())) return; + buildSidebar(document.body.dataset.page); + await loadGroupFilter(); + loadLessons(); +})(); \ No newline at end of file diff --git a/public/lessons.html b/public/lessons.html new file mode 100644 index 0000000..5f55dc1 --- /dev/null +++ b/public/lessons.html @@ -0,0 +1,32 @@ +Отчёты о занятиях — Админ-панель + + + +
+ +
+
+

Отчёты о занятиях

+

Что прошли на занятиях — тьютор заполняет в конце занятия

+
+ +
+
+
+
+
+
+
+ +
+
Загрузка…
+
+
+
+ +
+ + + + + \ No newline at end of file diff --git a/server.js b/server.js index 80e6ca2..b3754a2 100644 --- a/server.js +++ b/server.js @@ -95,6 +95,7 @@ function invalidateSettings() { cacheDrop('setting:'); cacheDrop('share:payload: function invalidateStudents() { cacheDrop('students:'); } function invalidateGroups() { cacheDrop('groups:'); cacheDrop('students:'); cacheDrop('share:payload:'); } function invalidateEntries() { cacheDrop('entries:'); cacheDrop('students:'); cacheDrop('share:payload:'); } +function invalidateLessonReports() { cacheDrop('lessons:'); cacheDrop('stats:'); cacheDrop('dashboard:'); } function invalidateSessions() { cacheDrop('session:'); } const EVENTS_CHANNEL = 'whatido:events'; @@ -192,6 +193,7 @@ const NOTIFY_TYPES = { 'backup.restore': { label: 'Восстановление из бэкапа', hint: 'Данные системы заменены содержимым архива', icon: 'database', level: 'critical', enabled: true, admin: true }, 'backup.create': { label: 'Создан архив бэкапа', hint: 'Архив данных скачан из админ-панели', icon: 'download', level: 'info', enabled: false, admin: true }, 'system.test': { label: 'Тестовое уведомление', hint: 'Проверка доставки уведомлений из настроек', icon: 'send', level: 'info', enabled: true, admin: true, hidden: true }, + 'lesson.report': { label: 'Отчёт о занятии', hint: 'Тьютор сохранил отчёт о том, что прошли на занятии', icon: 'notebook-pen', level: 'info', enabled: true, admin: false }, }; function notifySettingKey(type) { @@ -381,7 +383,7 @@ function notificationsScope(user) { const params = []; let cond = 'n.admin_only = false'; if (s.ids.length) { - cond += ` AND (n.branch_id IS NULL OR n.branch_id IN (${s.ids.map(id => '$' + params.push(id)).join(',')}))`; + cond += ` AND (n.branch_id IS NULL OR n.branch_id IN (${s.ids.map(id => '$' + (params.push(id) + 1)).join(',')}))`; } else { cond += ' AND n.branch_id IS NULL'; } @@ -1227,6 +1229,26 @@ async function ensureStudentPhotosTable() { await pool.query('CREATE INDEX IF NOT EXISTS idx_student_photos_student_id ON student_photos(student_id)'); } +async function ensureLessonReportsTable() { + await pool.query(`CREATE TABLE IF NOT EXISTS lesson_reports ( + id SERIAL PRIMARY KEY, + group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + lesson_date DATE NOT NULL, + lesson_time TIME, + text TEXT NOT NULL, + author_id INT REFERENCES users(id) ON DELETE SET NULL, + branch_id INT REFERENCES branches(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() + )`); + await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL'); + await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL'); + await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS lesson_time TIME'); + await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()'); + await pool.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date)'); + await pool.query('CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC)'); +} + async function ensurePhotoOriginalColumn() { await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255)`); } @@ -2030,6 +2052,12 @@ function optDate(v) { return v; } +function reqDate(v) { + const s = optDate(v); + if (!s) throw new Error('Invalid date'); + return s; +} + function optBool(v) { if (v === null || v === undefined) return null; return !!v; @@ -2305,6 +2333,17 @@ function normalizeRestoreData(data) { show_group_photos: optBool(x.show_group_photos), created_at: optTs(x.created_at), })); + const lesson_reports = (data.lesson_reports || []).map(x => ({ + id: reqInt(x.id), + group_id: reqInt(x.group_id), + lesson_date: reqDate(x.lesson_date), + lesson_time: optTime(x.lesson_time), + text: reqStr(x.text, LESSON_REPORT_TEXT_MAX), + author_id: optInt(x.author_id, 0, 2147483647), + branch_id: optInt(x.branch_id, 0, 2147483647), + created_at: optTs(x.created_at), + updated_at: optTs(x.updated_at), + })); const settings = {}; for (const [k, v] of Object.entries(data.settings || {})) { settings[reqStr(k, 100)] = reqStr(String(v), 10000); @@ -2324,7 +2363,7 @@ function normalizeRestoreData(data) { created_at: optTs(x.created_at), finished_at: optTs(x.finished_at), })); - return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs }; + return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs, lesson_reports }; } const BACKUP_TTL_MS = 30 * 60 * 1000; @@ -2358,7 +2397,7 @@ function sweepBackupStorage() { async function buildBackupArchive() { const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); try { - const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj] = await Promise.all([ + const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj, lr] = await Promise.all([ pool.query('SELECT * FROM groups ORDER BY id'), pool.query('SELECT * FROM students ORDER BY id'), pool.query('SELECT * FROM entries ORDER BY id'), @@ -2373,10 +2412,11 @@ async function buildBackupArchive() { pool.query('SELECT * FROM student_photos ORDER BY id'), pool.query('SELECT * FROM share_links ORDER BY id'), pool.query('SELECT * FROM photo_jobs ORDER BY id'), + pool.query('SELECT * FROM lesson_reports ORDER BY id'), ]); const settings = {}; 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, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.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, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows, lesson_reports: lr.rows }; fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload)); await storage.downloadAll(path.join(staging, 'uploads')); const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-'); @@ -2533,6 +2573,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req try { await client.query('BEGIN'); await client.query('DELETE FROM project_files'); + await client.query('DELETE FROM lesson_reports'); await client.query('DELETE FROM entries'); await client.query('DELETE FROM modules'); await client.query('DELETE FROM students'); @@ -2590,6 +2631,14 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req [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.photo_original_path, x.deleted_at, x.purge_at || null, x.created_at] ); } + for (const x of ndata.lesson_reports) { + const ex = await client.query('SELECT 1 FROM groups WHERE id = $1', [x.group_id]); + if (!ex.rowCount) continue; + await client.query( + 'INSERT INTO lesson_reports (id, group_id, lesson_date, lesson_time, text, author_id, branch_id, created_at, updated_at) VALUES ($1,$2,$3::date,$4,$5,$6,$7,$8,$9)', + [x.id, x.group_id, x.lesson_date, x.lesson_time, x.text, x.author_id, x.branch_id, x.created_at, x.updated_at] + ); + } for (const x of ndata.photo_jobs) { const ex = await client.query('SELECT 1 FROM entries WHERE id = $1', [x.entry_id]); if (!ex.rowCount) continue; @@ -2630,7 +2679,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req [k, String(v ?? '')] ); } - for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs']) { + for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs', 'lesson_reports']) { 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]); } @@ -3129,12 +3178,14 @@ async function hardDeleteGroup(groupId) { ); const files = eres.rows.concat(gres.rows); await client.query('DELETE FROM group_photos WHERE group_id = $1', [groupId]); + await client.query('DELETE FROM lesson_reports WHERE group_id = $1', [groupId]); await client.query('DELETE FROM entries WHERE group_id = $1', [groupId]); await client.query('UPDATE students SET group_id = NULL WHERE group_id = $1', [groupId]); await client.query('DELETE FROM share_links WHERE group_id = $1', [groupId]); const d = await client.query('DELETE FROM groups WHERE id = $1', [groupId]); await client.query('COMMIT'); files.forEach(r => safeUnlink(r.p)); + invalidateLessonReports(); return { entries: eres.rowCount, photos: gres.rowCount }; } catch (e) { await client.query('ROLLBACK'); @@ -3574,6 +3625,182 @@ app.delete('/api/modules/:id/photo', requireAdmin, async (req, res) => { res.json({ ok: true }); }); +// --- Lesson reports (отчёт о занятии) --- +const LESSON_REPORT_TEXT_MAX = 5000; +const LESSON_REPORT_LIST_TTL_MS = 30 * 1000; + +async function lessonReportGroup(user, groupId) { + const gid = Number(groupId); + if (!Number.isInteger(gid) || gid < 1) return { error: 'Группа не выбрана', code: 400 }; + const { rows } = await pool.query( + 'SELECT id, name, branch_id FROM groups WHERE id = $1 AND deleted_at IS NULL', + [gid] + ); + if (!rows.length) return { error: 'Группа не найдена', code: 404 }; + if (user.role !== 'admin' && !(await groupBelongsToBranches(user, gid))) { + return { error: 'Нет доступа к этой группе', code: 403 }; + } + return { group: rows[0] }; +} + +async function lessonReportById(user, id) { + const rid = Number(id); + if (!Number.isInteger(rid) || rid < 1) return { error: 'Не найдено', code: 404 }; + const { rows } = await pool.query( + `SELECT lr.*, g.name AS group_name, u.name AS author_name, u.username AS author_username + FROM lesson_reports lr + JOIN groups g ON g.id = lr.group_id + LEFT JOIN users u ON u.id = lr.author_id + WHERE lr.id = $1`, + [rid] + ); + if (!rows.length) return { error: 'Не найдено', code: 404 }; + if (user.role !== 'admin' && !(await groupBelongsToBranches(user, rows[0].group_id))) { + return { error: 'Нет доступа к этому отчёту', code: 403 }; + } + return { report: rows[0] }; +} + +function parseLessonReportDate(v) { + const s = typeof v === 'string' ? v.trim() : ''; + if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null; + const d = new Date(s + 'T00:00:00Z'); + if (Number.isNaN(d.getTime())) return null; + return s; +} + +function parseLessonReportTime(v) { + if (v === null || v === undefined || v === '') return null; + const s = typeof v === 'string' ? v.trim() : ''; + if (!/^\d{2}:\d{2}(:\d{2})?$/.test(s)) return undefined; + return s.length === 5 ? s + ':00' : s; +} + +app.get('/api/lesson-reports', requireAuth, async (req, res) => { + const { date_from, date_to, group_id, search, limit, offset } = req.query; + const conditions = []; + const params = []; + if (group_id) { params.push(group_id); conditions.push(`lr.group_id = $${params.length}`); } + if (date_from) { params.push(date_from); conditions.push(`lr.lesson_date >= $${params.length}::date`); } + if (date_to) { params.push(date_to); conditions.push(`lr.lesson_date <= $${params.length}::date`); } + if (search?.trim()) { params.push(`%${search.trim()}%`); conditions.push(`lr.text ILIKE $${params.length}`); } + const s = branchScope(req.user); + if (!s.admin) { + if (!s.ids.length) conditions.push('1 = 0'); + else conditions.push(`g.branch_id IN (${s.ids.map(id => `$${params.push(id)}`).join(',')})`); + } + const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : ''; + const from = `FROM lesson_reports lr JOIN groups g ON g.id = lr.group_id LEFT JOIN users u ON u.id = lr.author_id${where}`; + const { rows: crows } = await pool.query(`SELECT count(*)::int AS n ${from}`, params); + const total = crows[0].n; + const key = ['lessons:list:', scopeKey(req.user), date_from || '', date_to || '', group_id || '', search || '', limit || '', offset || ''].join(':'); + const rows = await cacheWrap(key, LESSON_REPORT_LIST_TTL_MS, async () => { + const qparams = params.slice(); + let q = `SELECT lr.id, lr.group_id, lr.lesson_date, lr.lesson_time, lr.text, lr.author_id, + lr.created_at, lr.updated_at, g.name AS group_name, + u.name AS author_name, u.username AS author_username + ${from} + ORDER BY lr.lesson_date DESC, lr.lesson_time DESC NULLS LAST, lr.id DESC`; + const lim = parseInt(limit, 10); + if (lim > 0) q += ` LIMIT $${qparams.push(lim)}`; + const off = parseInt(offset, 10); + if (off > 0) q += ` OFFSET $${qparams.push(off)}`; + const r = await pool.query(q, qparams); + return r.rows; + }); + res.json({ items: rows, total }); +}); + +app.get('/api/lesson-reports/:id', requireAuth, async (req, res) => { + const { report, error, code } = await lessonReportById(req.user, req.params.id); + if (!report) return res.status(code || 404).json({ error }); + res.json(report); +}); + +app.post('/api/lesson-reports', requireAuth, async (req, res) => { + const { group_id, lesson_date, lesson_time, text } = req.body || {}; + const grp = await lessonReportGroup(req.user, group_id); + if (grp.error) return res.status(grp.code || 400).json({ error: grp.error }); + const date = parseLessonReportDate(lesson_date); + if (!date) return res.status(400).json({ error: 'Некорректная дата занятия' }); + const time = parseLessonReportTime(lesson_time); + if (time === undefined) return res.status(400).json({ error: 'Некорректное время занятия' }); + const body = typeof text === 'string' ? text.trim() : ''; + if (!body) return res.status(400).json({ error: 'Введите текст отчёта' }); + if (body.length > LESSON_REPORT_TEXT_MAX) return res.status(400).json({ error: `Текст отчёта длиннее ${LESSON_REPORT_TEXT_MAX} символов` }); + const { rows: existing } = await pool.query( + 'SELECT id FROM lesson_reports WHERE group_id = $1 AND lesson_date = $2', + [grp.group.id, date] + ); + if (existing.length) { + return res.status(409).json({ error: 'За эту группу и дату отчёт уже есть — откройте его для редактирования', id: existing[0].id }); + } + const { rows } = await pool.query( + `INSERT INTO lesson_reports (group_id, lesson_date, lesson_time, text, author_id, branch_id) + VALUES ($1, $2::date, $3, $4, $5, $6) RETURNING *`, + [grp.group.id, date, time, body, req.user.id || null, grp.group.branch_id || null] + ); + await logAudit(req, 'lesson_report.create', { id: rows[0].id, group_id: grp.group.id, lesson_date: date }); + invalidateLessonReports(); + await pushNotification({ + type: 'lesson.report', + title: `Отчёт о занятии: ${grp.group.name}`, + body: body.slice(0, 300), + link: 'lessons.html', + target: { lesson_report_id: rows[0].id, group_id: grp.group.id, lesson_date: date }, + branchId: grp.group.branch_id, + }); + res.status(201).json(rows[0]); +}); + +app.put('/api/lesson-reports/:id', requireAuth, async (req, res) => { + const { report, error, code } = await lessonReportById(req.user, req.params.id); + if (!report) return res.status(code || 404).json({ error }); + const { lesson_date, lesson_time, text } = req.body || {}; + const curDate = String(report.lesson_date).slice(0, 10); + let date = curDate; + if (lesson_date !== undefined && lesson_date !== null && lesson_date !== '') { + date = parseLessonReportDate(lesson_date); + if (!date) return res.status(400).json({ error: 'Некорректная дата занятия' }); + } + let time; + if (lesson_time !== undefined) { + time = parseLessonReportTime(lesson_time); + if (time === undefined) return res.status(400).json({ error: 'Некорректное время занятия' }); + } + const body = text === undefined ? null : (typeof text === 'string' ? text.trim() : ''); + if (text !== undefined && !body) return res.status(400).json({ error: 'Введите текст отчёта' }); + if (body && body.length > LESSON_REPORT_TEXT_MAX) return res.status(400).json({ error: `Текст отчёта длиннее ${LESSON_REPORT_TEXT_MAX} символов` }); + if (date !== curDate) { + const { rows: clash } = await pool.query( + 'SELECT id FROM lesson_reports WHERE group_id = $1 AND lesson_date = $2 AND id <> $3', + [report.group_id, date, report.id] + ); + if (clash.length) return res.status(409).json({ error: 'За эту группу и дату уже есть другой отчёт' }); + } + const { rows } = await pool.query( + `UPDATE lesson_reports SET + lesson_date = $1::date, + lesson_time = $2, + text = COALESCE($3, text), + updated_at = now() + WHERE id = $4 RETURNING *`, + [date, time === undefined ? report.lesson_time : time, body, report.id] + ); + await logAudit(req, 'lesson_report.update', { id: report.id, lesson_date: date }); + invalidateLessonReports(); + res.json(rows[0]); +}); + +app.delete('/api/lesson-reports/:id', requireAdmin, async (req, res) => { + const { report, error, code } = await lessonReportById(req.user, req.params.id); + if (!report) return res.status(code || 404).json({ error }); + await pool.query('DELETE FROM lesson_reports WHERE id = $1', [report.id]); + await logAudit(req, 'lesson_report.delete', { id: report.id, group_id: report.group_id, lesson_date: report.lesson_date }); + invalidateLessonReports(); + res.json({ ok: true }); +}); + // --- Students CRUD --- app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => { const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => { @@ -4993,7 +5220,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => { const whereGroupParams = isAdmin ? [] : branchIds; const whereEntry = isAdmin ? '' : (branchIds.length ? ` AND g.branch_id IN (${branchIds.map((_, i) => `$${i + 1}`).join(',')})` : ' AND 1 = 0'); - const [stats, activity, active, recent, top, photos, latestPhotos] = await Promise.all([ + const [stats, activity, active, recent, top, photos, latestPhotos, lessons] = await Promise.all([ (async () => { const ew = !!whereEntry; const entriesP = `SELECT count(*)::int AS n FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.deleted_at IS NULL${ew ? whereEntry : ''}`; @@ -5044,6 +5271,12 @@ app.get('/api/dashboard', requireAuth, async (req, res) => { ORDER BY gp.sort_order ASC, gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 1`, whereGroup ? whereGroupParams : [] ), + pool.query( + `SELECT lr.id, lr.group_id, lr.lesson_date, lr.lesson_time, lr.text, g.name AS group_name + FROM lesson_reports lr JOIN groups g ON g.id = lr.group_id${whereGroup} + ORDER BY lr.lesson_date DESC, lr.lesson_time DESC NULLS LAST, lr.id DESC LIMIT 5`, + whereGroup ? whereGroupParams : [] + ), ]); const activeGroups = active.rows.map(g => ({ id: g.id, @@ -5059,6 +5292,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => { recent_entries: recent.rows, top_students: top.rows, photos: photos.rows, + recent_lessons: lessons.rows, latest_photo_taken: (latestPhotos.rows[0] || {}).taken_at || null, disk: getDiskInfo(), }; @@ -6454,6 +6688,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { 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 ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', err); } + try { await ensureLessonReportsTable(); } catch (err) { console.error('Lesson reports table:', err); } try { await ensureNotificationsTable(); } catch (err) { console.error('Notifications table:', err); } try { await purgeOldNotifications(); } catch (err) { console.error('Notifications purge:', err); } setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref();