Files
WhatIDo/public/admin.js
T
dev c994edbed7 feat(lessons): тема занятия в отчёте о занятии
В модалку #lessonModal добавлено текстовое поле «Тема занятия» (#lessonTopic)
между датой/временем и текстом отчёта. Поле необязательное, лимит 300 символов.

Протянуто по всему срезу:
- колонка lesson_reports.topic в db/init.sql, db/migration.sql и
  ensureLessonReportsTable() — миграция идемпотентная, ALTER IF NOT EXISTS
- POST /api/lesson-reports принимает topic, PUT обновляет; в GET-списке
  колонка добавлена в явный SELECT (там не SELECT *, без правки не пришла бы)
- тема в normalizeRestoreData, чтобы бэкап/restore её не теряли
- тема уходит в ИИ-контекст воркера строкой «Тема занятия: ...»
- вывод в списке отчётов (lessons.js + .lesson-topic в admin.css)

В PUT пустая строка очищает тему, а отсутствие поля в теле запроса её не
трогает: CASE WHEN $6::boolean THEN $3::text ELSE topic END, а не COALESCE —
при COALESCE пустая тема затиралась бы старым значением и поле нельзя было бы
очистить.

Версии не трогаем: lesson_report_versions хранит только text.

Тесты: +5 проверок (создание, список, редактирование, очистка, >300 символов),
api.smoketest.js — 76 PASS / 0 FAIL.
2026-10-04 11:56:55 +03:00

930 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const API = location.origin;
let token = sessionStorage.getItem('authToken');
let currentUser = null;
function hdr() { return { 'X-Auth-Token': token }; }
function hdrJson() { return { 'Content-Type': 'application/json', 'X-Auth-Token': token }; }
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function thumbSrc(p) {
if (!p) return p;
if (p.startsWith('/uploads/')) return `${API}/uploads/thumb/${p.slice('/uploads/'.length)}`;
if (p.includes('/api/files/')) return p + (p.includes('?') ? '&' : '?') + 'thumb=1';
return p;
}
// --- Auth check ---
async function checkAuth() {
if (!token) { location.href = 'login.html'; return false; }
try {
const res = await fetch(`${API}/api/auth/me`, { headers: hdr() });
if (!res.ok) { cleanupSession(); return false; }
currentUser = await res.json();
await initDateTime();
return true;
} catch { cleanupSession(); return false; }
}
function cleanupSession() {
sessionStorage.removeItem('authToken');
token = null;
currentUser = null;
location.href = 'login.html';
}
function isAdmin() { return currentUser && currentUser.role === 'admin'; }
async function requireAdminPage() {
if (!(await checkAuth())) return false;
if (!isAdmin()) { location.href = 'journal.html'; return false; }
return true;
}
async function doLogout() {
try { await fetch(`${API}/api/auth/logout`, { method: 'POST', headers: hdr() }); } catch {}
cleanupSession();
}
// --- Sidebar ---
function buildSidebar(active) {
const el = document.getElementById('sidebar');
if (!el) return;
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' },
{ page: 'photos', label: 'Фото', icon: 'Image' },
{ page: 'files', label: 'Файлы', icon: 'folder' },
{ page: 'links', label: 'Ссылки', icon: 'link' },
{ page: 'trash', label: 'Корзина', icon: 'trash-2' },
];
const adminNav = [
{ page: 'modules', label: 'Темы модулей', icon: 'layers' },
{ page: 'branches', label: 'Филиалы', icon: 'building-2' },
{ page: 'users', label: 'Пользователи', icon: 'user-cog' },
{ page: 'worker', label: 'Воркер ИИ', icon: 'bot' },
{ page: 'audit', label: 'Аудит', icon: 'scroll-text' },
{ page: 'bans', label: 'Блокировки', icon: 'shield-off' },
{ page: 'settings', label: 'Настройки', icon: 'settings' },
];
const userName = currentUser?.name || currentUser?.username || '';
const roleLabel = isAdmin() ? 'Администратор' : 'Тутор';
const renderItem = n => {
const isAct = n.page === active;
const badgeHtml = n.badge ? `<span class="sidebar-badge" id="sidebarNotifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>` : '';
return `<a href="${n.page}.html" data-nav="${n.page}" class="${isAct ? 'active' : ''}" title="${n.label}">
<i data-lucide="${n.icon}"></i>
<span class="nav-text">${n.label}</span>
${badgeHtml}
</a>`;
};
el.innerHTML = `
<div class="logo">
<span class="logo-ic" id="sidebarLogo"></span>
<span id="sidebarName">Админ-панель</span>
</div>
<div class="user-box">
<div class="user-avatar"><i data-lucide="${isAdmin() ? 'shield-check' : 'user'}"></i></div>
<div class="user-meta">
<div class="user-name" title="${esc(userName)}">${esc(userName)}</div>
<div class="user-role">${roleLabel}</div>
</div>
</div>
<div class="nav-scroll-wrap">
<nav>
<div class="nav-group">
<div class="nav-group-title">Основное</div>
<div class="nav-items">
${base.map(renderItem).join('')}
</div>
</div>
${isAdmin() ? `
<div class="nav-group">
<div class="nav-group-title">Администрирование</div>
<div class="nav-items">
${adminNav.map(renderItem).join('')}
</div>
</div>` : ''}
</nav>
</div>
<div class="bottom">
<div class="bottom-actions">
<a href="#" class="notify-bell" id="notifyBell" title="Быстрые уведомления">
<i data-lucide="bell"></i>
<span>Уведомления</span>
<span class="notify-badge" id="notifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>
</a>
<a href="/" data-nav="home" title="Перейти на главную страницу">
<i data-lucide="home"></i>
<span>На главную</span>
</a>
</div>
<div class="bottom-footer">
<a class="logout-btn" href="#" data-action="logout" title="Выйти из аккаунта">
<i data-lucide="log-out"></i>
<span>Выйти</span>
</a>
<span class="version" id="sidebarVersion"></span>
</div>
</div>`;
renderIcons();
loadVersion();
loadBrand();
initNotifications();
}
async function loadBrand() {
const logoEl = document.getElementById('sidebarLogo');
const nameEl = document.getElementById('sidebarName');
if (!logoEl || !nameEl) return;
try {
const res = await fetch(`${API}/api/public-settings`);
if (!res.ok) return;
const s = await res.json();
if ((s.system_name || '').trim()) nameEl.textContent = s.system_name;
if (s.system_logo) {
const img = document.createElement('img');
img.src = s.system_logo;
img.alt = '';
logoEl.classList.add('has-logo');
logoEl.appendChild(img);
}
} catch {}
}
async function loadVersion() {
const el = document.getElementById('sidebarVersion');
if (!el) return;
try {
const res = await fetch(`${API}/version.json?t=${Date.now()}`);
if (!res.ok) { el.textContent = ''; return; }
const v = await res.json();
if (!v.short) { el.textContent = ''; return; }
const d = (v.date || '').slice(0, 10);
el.textContent = `v${v.short}${d ? ' · ' + d : ''}`;
el.title = `${v.short} · ${v.date || ''}`;
} catch { el.textContent = ''; }
}
// --- Notifications ---
const NOTIFY_ICONS = {
'entry.new': 'book-open',
'entry.ai.corrected': 'sparkles',
'entry.ai.error': 'bot',
'photo.job.done': 'image',
'photo.job.error': 'image-off',
'ip.ban': 'shield-off',
'backup.restore': 'database',
'backup.create': 'download',
'system.test': 'send',
'lesson.report': 'notebook-pen',
'lesson.ai.formatted': 'file-check',
};
const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' };
let notifyList = [];
let notifyUnread = 0;
let notifyStream = null;
let notifyReconnectTimer = null;
let desktopNotify = localStorage.getItem('notifyDesktop') === '1';
function notifyIcon(type) { return NOTIFY_ICONS[type] || 'bell'; }
function attrEsc(s) { return esc(String(s === null || s === undefined ? '' : s).replace(/"/g, '&quot;')); }
function notifyTime(ts) {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '';
const diff = Date.now() - d.getTime();
if (diff < 60000) return 'только что';
if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} ч назад`;
return fmtDateTime(d);
}
function setNotifyUnread(n) {
notifyUnread = Number(n) || 0;
const badge = document.getElementById('notifyBadge');
if (badge) {
badge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread);
badge.hidden = notifyUnread <= 0;
}
const navBadge = document.getElementById('sidebarNotifyBadge');
if (navBadge) {
navBadge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread);
navBadge.hidden = notifyUnread <= 0;
}
}
function notifyItemHtml(n, expanded) {
const level = NOTIFY_LEVELS[n.level] ? ` level-${n.level}` : '';
return `<a href="#" class="notify-item${n.read ? '' : ' unread'}${level}" data-notify-id="${n.id}" data-notify-link="${attrEsc(n.link || '')}">
<span class="notify-ic"><i data-lucide="${notifyIcon(n.type)}"></i></span>
<span class="notify-body">
<span class="notify-title">${esc(n.title)}</span>
${n.body ? `<span class="notify-text">${esc(n.body)}</span>` : ''}
<span class="notify-time">${esc(notifyTime(n.created_at))}</span>
</span>
</a>`;
}
function notifyPanelEl() {
let panel = document.getElementById('notifyPanel');
if (panel) return panel;
panel = document.createElement('div');
panel.className = 'notify-panel';
panel.id = 'notifyPanel';
panel.hidden = true;
document.body.appendChild(panel);
return panel;
}
function renderNotifyPanel() {
const panel = notifyPanelEl();
if (!panel) return;
const items = notifyList.slice(0, 12);
panel.innerHTML = `
<div class="notify-head">
<b>Уведомления</b>
<button type="button" class="notify-link" id="notifyReadAll">Прочитать все</button>
</div>
<div class="notify-list">
${items.length ? items.map(n => notifyItemHtml(n, false)).join('') : '<div class="notify-empty">Пока уведомлений нет</div>'}
</div>
<div class="notify-foot">
<a href="notifications.html">Все уведомления${notifyUnread ? ` (${notifyUnread})` : ''}</a>
<button type="button" class="notify-link" id="notifyDesktopToggle">${desktopNotify ? 'В браузере: вкл' : 'В браузере: выкл'}</button>
</div>`;
renderIcons();
}
async function loadNotifications() {
try {
const res = await fetch(`${API}/api/notifications?limit=20`, { headers: hdr() });
if (!res.ok) return;
const data = await res.json();
notifyList = data.items || [];
setNotifyUnread(data.unread);
renderNotifyPanel();
} catch {}
}
function toggleNotifyPanel(force) {
const panel = notifyPanelEl();
if (!panel) return;
const open = force === undefined ? panel.hidden : !!force;
panel.hidden = !open;
if (open) { renderNotifyPanel(); loadNotifications(); }
}
async function markNotifyRead(id) {
const item = notifyList.find(n => n.id === id);
if (item && !item.read) {
item.read = true;
setNotifyUnread(Math.max(0, notifyUnread - 1));
renderNotifyPanel();
}
try { await fetch(`${API}/api/notifications/${id}/read`, { method: 'POST', headers: hdr() }); } catch {}
}
async function markAllNotifyRead() {
try {
const res = await fetch(`${API}/api/notifications/read-all`, { method: 'POST', headers: hdr() });
if (!res.ok) return;
notifyList.forEach(n => { n.read = true; });
setNotifyUnread(0);
renderNotifyPanel();
if (typeof onNotifyListChanged === 'function') onNotifyListChanged();
} catch {}
}
function showDesktopNotification(n) {
if (!desktopNotify) return;
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
try {
const note = new Notification(n.title, { body: n.body || '', tag: 'whatido-' + n.id });
note.onclick = () => {
window.focus();
if (n.link) location.href = n.link;
note.close();
};
} catch {}
}
async function toggleDesktopNotifications() {
if (desktopNotify) {
desktopNotify = false;
localStorage.setItem('notifyDesktop', '0');
renderNotifyPanel();
return;
}
if (typeof Notification === 'undefined') { showToast('Браузер не поддерживает уведомления'); return; }
let perm = Notification.permission;
if (perm === 'default') {
try { perm = await Notification.requestPermission(); } catch {}
}
if (perm !== 'granted') { showToast('Разрешение на уведомления не выдано'); return; }
desktopNotify = true;
localStorage.setItem('notifyDesktop', '1');
renderNotifyPanel();
showToast('Уведомления в браузере включены');
}
function handleIncomingNotification(n) {
notifyList.unshift(n);
if (notifyList.length > 40) notifyList.length = 40;
setNotifyUnread(notifyUnread + 1);
renderNotifyPanel();
if (n.level === 'warning' || n.level === 'critical') showToast(n.title);
showDesktopNotification(n);
if (typeof onNotificationArrived === 'function') onNotificationArrived(n);
}
function connectNotifyStream() {
if (notifyStream) { notifyStream.close(); notifyStream = null; }
const connect = () => {
const t = sessionStorage.getItem('authToken');
if (!t) return;
notifyStream = new EventSource(`${API}/api/notifications/stream?token=${encodeURIComponent(t)}`);
notifyStream.addEventListener('ready', ev => {
try { setNotifyUnread(JSON.parse(ev.data).unread); } catch {}
});
notifyStream.addEventListener('notification', ev => {
let n = null;
try { n = JSON.parse(ev.data); } catch { return; }
if (!n || !n.id) return;
handleIncomingNotification(n);
});
notifyStream.onerror = () => {
if (notifyStream) { notifyStream.close(); notifyStream = null; }
clearTimeout(notifyReconnectTimer);
notifyReconnectTimer = setTimeout(connect, 5000);
};
};
connect();
window.addEventListener('beforeunload', () => { if (notifyStream) notifyStream.close(); });
}
function initNotifications() {
const bell = document.getElementById('notifyBell');
const panel = notifyPanelEl();
if (!bell || !panel) return;
bell.addEventListener('click', e => { e.preventDefault(); toggleNotifyPanel(); });
panel.addEventListener('click', e => {
if (e.target.closest('#notifyReadAll')) { markAllNotifyRead(); return; }
if (e.target.closest('#notifyDesktopToggle')) { toggleDesktopNotifications(); return; }
const item = e.target.closest('[data-notify-id]');
if (!item) return;
e.preventDefault();
const id = parseInt(item.dataset.notifyId, 10);
const link = item.dataset.notifyLink;
toggleNotifyPanel(false);
markNotifyRead(id);
if (link) location.href = link;
});
document.addEventListener('click', e => {
if (panel.hidden) return;
if (e.target.closest('#notifyPanel') || e.target.closest('#notifyBell')) return;
toggleNotifyPanel(false);
});
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) toggleNotifyPanel(false); });
loadNotifications();
connectNotifyStream();
}
// --- Common UI ---
function renderIcons() {
if (window.lucide && typeof window.lucide.createIcons === 'function') {
window.lucide.createIcons();
}
}
function showToast(msg) {
const t = document.getElementById('toast');
if (!t) return;
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3000);
}
function showImg(src) {
const img = document.getElementById('imgModalSrc');
const m = document.getElementById('imgModal');
if (img && m) { img.src = src; m.classList.add('open'); }
}
async function fillAutocomplete() {
const res = await fetch(`${API}/api/students`, { headers: hdr() });
const students = await res.json();
const names = students.map(s => s.name);
const dlEdit = document.getElementById('studentEditList');
if (dlEdit) {
dlEdit.innerHTML = '';
names.forEach(n => { const o = document.createElement('option'); o.value = n; dlEdit.appendChild(o); });
}
const dlAdd = document.getElementById('studentAddList');
if (dlAdd) {
dlAdd.innerHTML = '';
names.forEach(n => { const o = document.createElement('option'); o.value = n; dlAdd.appendChild(o); });
}
}
function copyStudentLink(name) {
const url = `${location.origin}/?nameInput=${encodeURIComponent(name)}`;
const done = () => showToast('Ссылка скопирована: ' + url);
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(url).then(done).catch(() => fallbackCopy(url, done));
} else {
fallbackCopy(url, done);
}
}
function fallbackCopy(text, done) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); done(); } catch { prompt('Скопируйте вручную:', text); }
document.body.removeChild(ta);
}
function studentUrlContent(name) {
const url = `${location.origin}/?nameInput=${encodeURIComponent(name)}`;
return `[InternetShortcut]\r\nURL=${url}\r\n`;
}
function safeWindowsName(name) {
return String(name).replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_');
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
async function exportStudentUrlZip(students, zipName) {
if (typeof JSZip === 'undefined') { alert('JSZip не загружен'); return; }
const zip = new JSZip();
students.forEach(s => {
zip.file(safeWindowsName(s.name) + '.url', studentUrlContent(s.name));
});
const blob = await zip.generateAsync({ type: 'blob' });
downloadBlob(blob, zipName);
}
function copyText(text) {
const done = () => showToast('Скопировано: ' + text);
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done));
} else {
fallbackCopy(text, done);
}
}
let lessonModalGroups = null;
let lessonModalGroupsPromise = null;
function lessonTodayIso() {
return todayIso();
}
function lessonNowTime() {
return nowHm();
}
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>Тема занятия</label>
<input type="text" id="lessonTopic" class="settings-input" maxlength="300"
placeholder="Например: циклы for и while, задачи на списки">
</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="lesson-dup" id="lessonDup" hidden>
<span>За эту группу и дату отчёт уже есть — создать второй нельзя.</span>
<button type="button" class="btn-link" id="lessonDupGo">Перейти к существующему отчёту</button>
</div>
<label class="toggle lesson-ai-toggle" id="lessonAiRow">
<input type="checkbox" id="lessonAiCheck" checked>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
<span class="toggle-text"><b>Проверить по шаблону делового сообщения</b><small>ИИ сверит текст с шаблоном из настроек и, если не совпадёт, оформит его как деловое сообщение</small></span>
</label>
<div class="lesson-ai-status" id="lessonAiStatus" hidden></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="lessonVersionsBtn" hidden>История версий</button>
<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);
modal.querySelector('#lessonVersionsBtn').addEventListener('click', () => {
const id = modal.querySelector('#lessonId').value;
if (id) openLessonVersions(id);
});
groupSel.addEventListener('change', () => prefetchLessonForGroupDate());
modal.querySelector('#lessonDate').addEventListener('change', () => prefetchLessonForGroupDate());
modal.querySelector('#lessonDupGo').addEventListener('click', () => {
const id = modal.querySelector('#lessonDup').dataset.id;
if (!id) return;
closeLessonModal();
openLessonModal({ id });
});
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 topicEl = modal.querySelector('#lessonTopic');
const submitBtn = modal.querySelector('#lessonSubmit');
const aiCheckEl = modal.querySelector('#lessonAiCheck');
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;
const aiCheck = !!aiCheckEl && aiCheckEl.checked;
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, topic: topicEl.value.trim(), text, ai_check: aiCheck }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 409 && data.id) {
showLessonDuplicate(modal, data.id);
showToast('За эту группу и дату отчёт уже есть — новый создать нельзя');
return;
}
showToast(data.error || 'Не удалось сохранить отчёт');
return;
}
closeLessonModal();
if (data.ai_status === 'pending' || data.ai_status === 'processing') {
showToast('Отчёт сохранён — ИИ проверяет текст по шаблону');
} else if (data.ai_status === 'error') {
showToast(`Отчёт сохранён, проверка по шаблону не удалась: ${data.ai_error || 'ошибка ИИ'}`);
} else {
showToast(id ? 'Отчёт о занятии обновлён' : 'Отчёт о занятии сохранён');
}
if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
} catch {
showToast('Ошибка сети');
} finally {
submitBtn.disabled = false;
}
}
let lessonDupSeq = 0;
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) { showLessonDuplicate(modal, null); return; }
const seq = ++lessonDupSeq;
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 (seq !== lessonDupSeq) return;
if (!res.ok) { showLessonDuplicate(modal, null); return; }
const data = await res.json();
if (seq !== lessonDupSeq) return;
const item = (data.items || [])[0];
showLessonDuplicate(modal, item ? item.id : null);
} catch {
if (seq === lessonDupSeq) showLessonDuplicate(modal, null);
}
}
function showLessonDuplicate(modal, existingId) {
const row = modal.querySelector('#lessonDup');
if (!row) return;
row.dataset.id = existingId ? String(existingId) : '';
row.hidden = !existingId;
}
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'));
}
if (!modal.querySelector('#lessonTopic').value) {
modal.querySelector('#lessonTopic').value = r.topic || '';
}
const aiCheck = modal.querySelector('#lessonAiCheck');
if (aiCheck) aiCheck.checked = false;
updateLessonAiRow(modal, r);
modal.querySelector('#lessonModalTitle').textContent = 'Редактирование отчёта';
modal.querySelector('#lessonSubmit').textContent = 'Обновить';
}
function lessonAiStatusText(r) {
if (!r || !r.ai_status || r.ai_status === 'none') return '';
if (r.ai_status === 'pending') return 'ИИ проверяет текст по шаблону…';
if (r.ai_status === 'processing') return 'ИИ оформляет текст по шаблону…';
if (r.ai_status === 'done' && r.text_ai) return 'ИИ оформил текст по шаблону';
if (r.ai_status === 'skipped') return 'Текст уже соответствует шаблону';
if (r.ai_status === 'reverted') return 'Возвращена исходная версия текста';
if (r.ai_status === 'error') return `Проверка по шаблону не удалась: ${r.ai_error || 'ошибка ИИ'}`;
return `Статус ИИ: ${r.ai_status}`;
}
function updateLessonAiRow(modal, r) {
const row = modal.querySelector('#lessonAiRow');
const status = modal.querySelector('#lessonAiStatus');
const versionsBtn = modal.querySelector('#lessonVersionsBtn');
if (row) row.hidden = false;
const text = lessonAiStatusText(r);
if (status) {
status.textContent = text;
status.hidden = !text;
status.classList.toggle('err', !!r && r.ai_status === 'error');
}
if (versionsBtn) versionsBtn.hidden = !modal.querySelector('#lessonId').value;
}
function resetLessonModalFields(modal) {
lessonDupSeq++;
modal.querySelector('#lessonId').value = '';
showLessonDuplicate(modal, null);
modal.querySelector('#lessonDate').value = lessonTodayIso();
modal.querySelector('#lessonTime').value = lessonNowTime();
modal.querySelector('#lessonTopic').value = '';
const textEl = modal.querySelector('#lessonText');
textEl.value = '';
textEl.dispatchEvent(new Event('input'));
const aiCheck = modal.querySelector('#lessonAiCheck');
if (aiCheck) aiCheck.checked = true;
updateLessonAiRow(modal, null);
modal.querySelector('#lessonModalTitle').textContent = 'Что прошли на занятии';
modal.querySelector('#lessonSubmit').textContent = 'Сохранить';
}
const LESSON_VERSION_LABELS = {
manual: 'Тьютор',
ai: 'ИИ по шаблону',
restore: 'Восстановлено'
};
let currentLessonVersionId = '';
function ensureLessonVersionsModal() {
let modal = document.getElementById('lessonVersionsModal');
if (modal) return modal;
modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'lessonVersionsModal';
modal.innerHTML = `
<div class="edit-modal lesson-modal">
<h3>История версий отчёта</h3>
<div class="lesson-versions" id="lessonVersionsList"><div class="empty">Загрузка…</div></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="lessonVersionsClose">Закрыть</button>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', e => { if (e.target === modal) closeLessonVersions(); });
modal.querySelector('#lessonVersionsClose').addEventListener('click', closeLessonVersions);
return modal;
}
function closeLessonVersions() {
const modal = document.getElementById('lessonVersionsModal');
if (modal) modal.classList.remove('open');
}
function lessonVersionItemHtml(v, index) {
const label = LESSON_VERSION_LABELS[v.source] || v.source || 'Версия';
const who = v.author_name || v.author_username || '';
return `
<div class="lesson-version${index === 0 ? ' current' : ''}">
<div class="lesson-version-head">
<span class="lesson-version-label">${esc(label)}</span>
<span class="lesson-version-time">${esc(notifyTime(v.created_at))}</span>
</div>
<div class="lesson-version-text">${esc(v.text || '')}</div>
<div class="lesson-version-foot">
${who ? `<span class="lesson-version-author">${esc(who)}</span>` : ''}
${index === 0 ? '<span class="lesson-badge">текущая</span>' : ''}
${index === 0 ? '' : `<button type="button" class="btn-link" data-restore="${v.id}" data-report="${v.lesson_report_id || currentLessonVersionId || ''}">Восстановить</button>`}
</div>
</div>`;
}
async function openLessonVersions(id) {
currentLessonVersionId = String(id || '');
const modal = ensureLessonVersionsModal();
const list = modal.querySelector('#lessonVersionsList');
list.innerHTML = '<div class="empty">Загрузка…</div>';
modal.classList.add('open');
renderIcons();
try {
const res = await fetch(`${API}/api/lesson-reports/${id}/versions`, { headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) { list.innerHTML = `<div class="empty">${esc(data.error || 'Не удалось загрузить историю')}</div>`; return; }
const items = data.items || [];
if (!items.length) { list.innerHTML = '<div class="empty">Версий пока нет</div>'; return; }
list.innerHTML = items.map(lessonVersionItemHtml).join('');
renderIcons();
} catch {
list.innerHTML = '<div class="empty">Ошибка сети</div>';
}
}
async function restoreLessonVersion(id, versionId, btn) {
if (!confirm('Восстановить эту версию отчёта?')) return;
if (btn) btn.disabled = true;
try {
const res = await fetch(`${API}/api/lesson-reports/${id}/versions/${versionId}/restore`, { method: 'POST', headers: hdr() });
const data = await res.json().catch(() => ({}));
if (!res.ok) { showToast(data.error || 'Не удалось восстановить версию'); return; }
closeLessonVersions();
showToast('Версия отчёта восстановлена');
if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
} catch {
showToast('Ошибка сети');
} finally {
if (btn) btn.disabled = false;
}
}
document.addEventListener('click', e => {
const btn = e.target.closest('[data-restore]');
if (!btn) return;
e.preventDefault();
let id = btn.dataset.report || currentLessonVersionId;
if (!id) {
const lessonModal = document.getElementById('lessonModal');
id = (lessonModal && lessonModal.querySelector('#lessonId').value) || '';
}
if (!id) return;
restoreLessonVersion(id, btn.dataset.restore, btn);
});
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; }
let html = `<button ${page === 1 ? 'disabled' : ''} data-nav="-1">‹</button>`;
for (let i = 1; i <= totalPages; i++) {
if (totalPages > 9 && i > 2 && i < totalPages - 1 && Math.abs(i - page) > 2) {
if (i === 3) html += `<button disabled>…</button>`;
continue;
}
html += `<button class="${i === page ? 'active' : ''}" data-page="${i}">${i}</button>`;
}
html += `<button ${page === totalPages ? 'disabled' : ''} data-nav="1">›</button>`;
el.innerHTML = html;
el._onPage = onPage;
el._page = page;
if (!el._bound) {
el._bound = true;
el.addEventListener('click', e => {
const btn = e.target.closest('button');
if (!btn || btn.disabled) return;
const p = btn.dataset.page ? parseInt(btn.dataset.page, 10) : el._page + parseInt(btn.dataset.nav, 10);
if (el._onPage) el._onPage(p);
});
}
}
document.addEventListener('click', e => {
const imgEl = e.target.closest('[data-img]');
if (imgEl) { e.preventDefault(); showImg(imgEl.dataset.img); return; }
const out = e.target.closest('[data-action="logout"]');
if (out) { e.preventDefault(); doLogout(); }
});