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
+3 -2
View File
@@ -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()` - **`resetTime` в `rateLimitStore.increment` обязан быть `Date`** — express-rate-limit v8 вызывает `resetTime.getTime()`
- **Пабликация всегда отдаёт подписчикам строку** (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату - **Пабликация всегда отдаёт подписчикам строку** (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату
- **Инвалидация — по префиксу** (`SCAN` + `DEL`), точечного удаления по ключу избегайте - **Инвалидация — по префиксу** (`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:<token>`, иначе деактивированный пользователь сохранит доступ - **Сессии**: `loadUserByToken` кэширует пользователя на 30 с. Любая мутация `users` / `sessions` / `user_branches` обязана вызывать `invalidateSessions()` или удалять `session:<token>`, иначе деактивированный пользователь сохранит доступ
- **Секреты**: пароль только в `REDIS_URL` / `REDIS_PASSWORD`, порт 6379 публикуется лишь на `127.0.0.1` - **Секреты**: пароль только в `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 | | `docker-compose.minio.yml` | Override: S3 service backed by MinIO instead of SeaweedFS |
| `Dockerfile` | App image build | | `Dockerfile` | App image build |
| `public/*.html` | Frontend pages | | `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/backup.sh` | Host-level backup script (DB dump + storage export) |
| `scripts/restore.sh` | Host-level restore script (DB dump + storage import) | | `scripts/restore.sh` | Host-level restore script (DB dump + storage import) |
| `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) | | `scripts/storage-sync.js` | Export/import all storage objects (used by backup/restore) |
+15
View File
@@ -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_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); 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 ( CREATE TABLE IF NOT EXISTS photo_jobs (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
+16
View File
@@ -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_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_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_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);
+28
View File
@@ -658,4 +658,32 @@ body[data-page=settings] .page-head{align-items:flex-end}
.notify-panel{left:12px;right:12px;width:auto;bottom:12px} .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}
+227
View File
@@ -49,6 +49,7 @@ function buildSidebar(active) {
const base = [ const base = [
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' }, { page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
{ page: 'journal', label: 'Журнал', icon: 'book-open' }, { page: 'journal', label: 'Журнал', icon: 'book-open' },
{ page: 'lessons', label: 'Отчёты о занятиях', icon: 'notebook-pen' },
{ page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true }, { page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true },
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' }, { page: 'students', label: 'Ученики', icon: 'graduation-cap' },
{ page: 'groups', label: 'Группы', icon: 'users' }, { page: 'groups', label: 'Группы', icon: 'users' },
@@ -176,6 +177,7 @@ const NOTIFY_ICONS = {
'backup.restore': 'database', 'backup.restore': 'database',
'backup.create': 'download', 'backup.create': 'download',
'system.test': 'send', 'system.test': 'send',
'lesson.report': 'notebook-pen',
}; };
const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' }; 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) { function renderPager(el, page, totalPages, onPage) {
if (!el) return; if (!el) return;
if (totalPages <= 1) { el.innerHTML = ''; return; } if (totalPages <= 1) { el.innerHTML = ''; return; }
+6
View File
@@ -28,6 +28,11 @@
<div class="recent" id="recentList"></div> <div class="recent" id="recentList"></div>
<div class="dash-link-row"><a href="journal.html">Все записи →</a></div> <div class="dash-link-row"><a href="journal.html">Все записи →</a></div>
</div> </div>
<div class="dash-card">
<h3><span class="ic"><i data-lucide="notebook-pen"></i></span> Что прошли на занятиях</h3>
<div class="recent" id="lessonList"></div>
<div class="dash-link-row"><a href="lessons.html">Все отчёты →</a></div>
</div>
<div class="dash-card"> <div class="dash-card">
<h3><span class="ic"><i data-lucide="camera"></i></span> Фото групп</h3> <h3><span class="ic"><i data-lucide="camera"></i></span> Фото групп</h3>
<div class="photos-grid-dash" id="dashPhotos"></div> <div class="photos-grid-dash" id="dashPhotos"></div>
@@ -40,6 +45,7 @@
<h3><span class="ic"><i data-lucide="zap"></i></span> Быстрые действия</h3> <h3><span class="ic"><i data-lucide="zap"></i></span> Быстрые действия</h3>
<div class="actions-grid"> <div class="actions-grid">
<a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a> <a class="action-btn" href="journal.html"><span class="aic"><i data-lucide="book-open"></i></span>Журнал</a>
<button type="button" class="action-btn" id="lessonReportBtn" style="border:1px solid var(--border);cursor:pointer;font-family:inherit"><span class="aic"><i data-lucide="notebook-pen"></i></span>Что прошли на занятии</button>
<a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a> <a class="action-btn" href="files.html"><span class="aic"><i data-lucide="folder"></i></span>Файлы</a>
<a class="action-btn" href="photos.html"><span class="aic"><i data-lucide="Image"></i></span>Фото</a> <a class="action-btn" href="photos.html"><span class="aic"><i data-lucide="Image"></i></span>Фото</a>
<a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a> <a class="action-btn" href="groups.html"><span class="aic"><i data-lucide="users"></i></span>Группы</a>
+1
View File
@@ -50,6 +50,7 @@
<div><label>По</label><input type="date" id="dateTo"></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> <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>
<div><label>&nbsp;</label><button type="button" id="createLinkBtn" 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">Ссылка</button></div> <div><label>&nbsp;</label><button type="button" id="createLinkBtn" 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">Ссылка</button></div>
<div><label>&nbsp;</label><button type="button" id="lessonReportBtn" style="background:var(--card);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 16px;font-size:.85rem;font-weight:600;cursor:pointer;white-space:nowrap">Что прошли на занятии</button></div>
<div><label>&nbsp;</label> <div><label>&nbsp;</label>
<div class="view-toggle" id="viewToggle"> <div class="view-toggle" id="viewToggle">
<button data-view="list" type="button">Список</button> <button data-view="list" type="button">Список</button>
+31
View File
@@ -100,6 +100,24 @@ function renderPhotos(rows) {
</div>`).join(''); </div>`).join('');
} }
function renderLessons(rows) {
const el = document.getElementById('lessonList');
if (!el) return;
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Отчётов пока нет</div>'; 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 `
<div class="lesson-dash-item" data-lesson="${r.id}">
<div class="lesson-dash-head">
<span class="lesson-dash-group">${esc(r.group_name)}</span>
<span class="lesson-dash-when">${esc(date + (time ? ' · ' + time : ''))}</span>
</div>
<div class="lesson-dash-text">${esc(r.text)}</div>
</div>`;
}).join('');
}
async function loadDashboard() { async function loadDashboard() {
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() }); const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; } if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
@@ -110,11 +128,24 @@ async function loadDashboard() {
renderActive(d.active_groups); renderActive(d.active_groups);
renderTop(d.top_students); renderTop(d.top_students);
renderPhotos(d.photos); renderPhotos(d.photos);
renderLessons(d.recent_lessons || []);
renderIcons(); renderIcons();
} }
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open')); 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 () => { (async () => {
if (await checkAuth()) { if (await checkAuth()) {
buildSidebar(document.body.dataset.page); buildSidebar(document.body.dataset.page);
+3
View File
@@ -1648,6 +1648,9 @@ document.addEventListener('click', e => {
document.getElementById('exportCsvBtn').addEventListener('click', exportCSV); document.getElementById('exportCsvBtn').addEventListener('click', exportCSV);
document.getElementById('createLinkBtn').addEventListener('click', openLinkCreate); 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.querySelectorAll('#viewToggle button').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
document.getElementById('editCancelBtn').addEventListener('click', closeEdit); document.getElementById('editCancelBtn').addEventListener('click', closeEdit);
document.getElementById('linkCreateCloseBtn').addEventListener('click', closeLinkCreate); document.getElementById('linkCreateCloseBtn').addEventListener('click', closeLinkCreate);
+139
View File
@@ -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 `
<div class="lesson-item" data-lesson="${r.id}">
<div class="lesson-item-head">
<span class="lesson-group">${esc(r.group_name || '')}</span>
<span class="lesson-when"><i data-lucide="clock-3"></i>${esc(lessonWhenText(r))}</span>
${isToday ? '<span class="lesson-badge today">сегодня</span>' : ''}
<div class="lesson-actions">
<button type="button" class="btn-link" data-edit="${r.id}">Редактировать</button>
${isAdmin() ? `<button type="button" class="btn-link danger" data-del="${r.id}">Удалить</button>` : ''}
</div>
</div>
<div class="lesson-text">${esc(r.text || '')}</div>
${author ? `<div class="lesson-author">${esc(author)}</div>` : ''}
</div>`;
}
function renderLessons(items) {
const list = document.getElementById('lessonsList');
const state = document.getElementById('lessonsState');
list.innerHTML = items.length
? items.map(lessonItemHtml).join('')
: '<div class="empty">Отчётов пока нет</div>';
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 = `<div class="empty">Ошибка загрузки (${res.status})</div>`;
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 = '<div class="empty">Ошибка сети</div>';
}
}
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();
})();
+32
View File
@@ -0,0 +1,32 @@
<title>Отчёты о занятиях — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="lessons">
<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="filters">
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Текст отчёта..."></div>
<div><label>Группа</label><select id="groupFilter"><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" class="btn-primary" id="addLessonBtn"><i data-lucide="notebook-pen"></i> Что прошли на занятии</button></div>
</div>
<div class="hint" id="lessonsState" style="margin-bottom:12px"></div>
<div class="lesson-list" id="lessonsList"><div class="empty">Загрузка…</div></div>
<div class="pager" id="pager"></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="admin.js"></script>
<script src="js/lessons.js"></script>
</body>
</html>
+241 -6
View File
@@ -95,6 +95,7 @@ function invalidateSettings() { cacheDrop('setting:'); cacheDrop('share:payload:
function invalidateStudents() { cacheDrop('students:'); } function invalidateStudents() { cacheDrop('students:'); }
function invalidateGroups() { cacheDrop('groups:'); cacheDrop('students:'); cacheDrop('share:payload:'); } function invalidateGroups() { cacheDrop('groups:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
function invalidateEntries() { cacheDrop('entries:'); 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:'); } function invalidateSessions() { cacheDrop('session:'); }
const EVENTS_CHANNEL = 'whatido:events'; 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.restore': { label: 'Восстановление из бэкапа', hint: 'Данные системы заменены содержимым архива', icon: 'database', level: 'critical', enabled: true, admin: true },
'backup.create': { label: 'Создан архив бэкапа', hint: 'Архив данных скачан из админ-панели', icon: 'download', level: 'info', enabled: false, 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 }, '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) { function notifySettingKey(type) {
@@ -381,7 +383,7 @@ function notificationsScope(user) {
const params = []; const params = [];
let cond = 'n.admin_only = false'; let cond = 'n.admin_only = false';
if (s.ids.length) { 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 { } else {
cond += ' AND n.branch_id IS NULL'; 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)'); 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() { async function ensurePhotoOriginalColumn() {
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255)`); 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; return v;
} }
function reqDate(v) {
const s = optDate(v);
if (!s) throw new Error('Invalid date');
return s;
}
function optBool(v) { function optBool(v) {
if (v === null || v === undefined) return null; if (v === null || v === undefined) return null;
return !!v; return !!v;
@@ -2305,6 +2333,17 @@ function normalizeRestoreData(data) {
show_group_photos: optBool(x.show_group_photos), show_group_photos: optBool(x.show_group_photos),
created_at: optTs(x.created_at), 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 = {}; const settings = {};
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);
@@ -2324,7 +2363,7 @@ function normalizeRestoreData(data) {
created_at: optTs(x.created_at), created_at: optTs(x.created_at),
finished_at: optTs(x.finished_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; const BACKUP_TTL_MS = 30 * 60 * 1000;
@@ -2358,7 +2397,7 @@ function sweepBackupStorage() {
async function buildBackupArchive() { async function buildBackupArchive() {
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, 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 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'),
@@ -2373,10 +2412,11 @@ async function buildBackupArchive() {
pool.query('SELECT * FROM student_photos ORDER BY id'), pool.query('SELECT * FROM student_photos ORDER BY id'),
pool.query('SELECT * FROM share_links 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 photo_jobs ORDER BY id'),
pool.query('SELECT * FROM lesson_reports 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, 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)); fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
await storage.downloadAll(path.join(staging, 'uploads')); await storage.downloadAll(path.join(staging, 'uploads'));
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-'); 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 { try {
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 lesson_reports');
await client.query('DELETE FROM entries'); await client.query('DELETE FROM entries');
await client.query('DELETE FROM modules'); await client.query('DELETE FROM modules');
await client.query('DELETE FROM students'); 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] [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) { for (const x of ndata.photo_jobs) {
const ex = await client.query('SELECT 1 FROM entries WHERE id = $1', [x.entry_id]); const ex = await client.query('SELECT 1 FROM entries WHERE id = $1', [x.entry_id]);
if (!ex.rowCount) continue; if (!ex.rowCount) continue;
@@ -2630,7 +2679,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', '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); 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]);
} }
@@ -3129,12 +3178,14 @@ async function hardDeleteGroup(groupId) {
); );
const files = eres.rows.concat(gres.rows); const files = eres.rows.concat(gres.rows);
await client.query('DELETE FROM group_photos WHERE group_id = $1', [groupId]); 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('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('UPDATE students SET group_id = NULL WHERE group_id = $1', [groupId]);
await client.query('DELETE FROM share_links 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]); const d = await client.query('DELETE FROM groups WHERE id = $1', [groupId]);
await client.query('COMMIT'); await client.query('COMMIT');
files.forEach(r => safeUnlink(r.p)); files.forEach(r => safeUnlink(r.p));
invalidateLessonReports();
return { entries: eres.rowCount, photos: gres.rowCount }; return { entries: eres.rowCount, photos: gres.rowCount };
} catch (e) { } catch (e) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -3574,6 +3625,182 @@ app.delete('/api/modules/:id/photo', requireAdmin, async (req, res) => {
res.json({ ok: true }); 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 --- // --- 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 () => {
@@ -4993,7 +5220,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
const whereGroupParams = isAdmin ? [] : branchIds; const whereGroupParams = isAdmin ? [] : branchIds;
const whereEntry = isAdmin ? '' : (branchIds.length ? ` AND g.branch_id IN (${branchIds.map((_, i) => `$${i + 1}`).join(',')})` : ' AND 1 = 0'); 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 () => { (async () => {
const ew = !!whereEntry; 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 : ''}`; 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`, ORDER BY gp.sort_order ASC, gp.taken_at DESC NULLS LAST, gp.created_at DESC LIMIT 1`,
whereGroup ? whereGroupParams : [] 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 => ({ const activeGroups = active.rows.map(g => ({
id: g.id, id: g.id,
@@ -5059,6 +5292,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
recent_entries: recent.rows, recent_entries: recent.rows,
top_students: top.rows, top_students: top.rows,
photos: photos.rows, photos: photos.rows,
recent_lessons: lessons.rows,
latest_photo_taken: (latestPhotos.rows[0] || {}).taken_at || null, latest_photo_taken: (latestPhotos.rows[0] || {}).taken_at || null,
disk: getDiskInfo(), 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 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); }
try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', 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 ensureNotificationsTable(); } catch (err) { console.error('Notifications table:', err); }
try { await purgeOldNotifications(); } catch (err) { console.error('Notifications purge:', err); } try { await purgeOldNotifications(); } catch (err) { console.error('Notifications purge:', err); }
setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref(); setInterval(() => { purgeOldNotifications().catch(err => console.error('Notifications purge:', err)); }, 60 * 60 * 1000).unref();