Добавлена сущность «что прошли на занятии»: - 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 без смещения.
744 lines
28 KiB
JavaScript
744 lines
28 KiB
JavaScript
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();
|
||
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',
|
||
};
|
||
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, '"')); }
|
||
|
||
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 d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
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() {
|
||
const d = new Date();
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
function lessonNowTime() {
|
||
const d = new Date();
|
||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||
}
|
||
|
||
async function loadLessonGroups() {
|
||
if (lessonModalGroups) return lessonModalGroups;
|
||
if (!lessonModalGroupsPromise) {
|
||
lessonModalGroupsPromise = fetch(`${API}/api/groups`, { headers: hdr() })
|
||
.then(res => (res.ok ? res.json() : []))
|
||
.then(g => { lessonModalGroups = Array.isArray(g) ? g.filter(x => !x.deleted_at) : []; return lessonModalGroups; })
|
||
.catch(() => { lessonModalGroups = []; return lessonModalGroups; });
|
||
}
|
||
return lessonModalGroupsPromise;
|
||
}
|
||
|
||
function ensureLessonModal() {
|
||
let modal = document.getElementById('lessonModal');
|
||
if (modal) return modal;
|
||
modal = document.createElement('div');
|
||
modal.className = 'modal-overlay';
|
||
modal.id = 'lessonModal';
|
||
modal.innerHTML = `
|
||
<div class="edit-modal lesson-modal">
|
||
<h3 id="lessonModalTitle">Что прошли на занятии</h3>
|
||
<form id="lessonForm" class="settings-stack" style="gap:12px" autocomplete="off">
|
||
<input type="hidden" id="lessonId">
|
||
<div class="settings-field">
|
||
<label>Группа <span style="color:#ef4444">*</span></label>
|
||
<select id="lessonGroup" class="settings-input" required></select>
|
||
</div>
|
||
<div class="lesson-row">
|
||
<div class="settings-field">
|
||
<label>Дата занятия <span style="color:#ef4444">*</span></label>
|
||
<input type="date" id="lessonDate" class="settings-input" required>
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>Время</label>
|
||
<input type="time" id="lessonTime" class="settings-input">
|
||
</div>
|
||
</div>
|
||
<div class="settings-field">
|
||
<label>Что прошли на занятии <span style="color:#ef4444">*</span></label>
|
||
<textarea id="lessonText" class="settings-input" rows="6" maxlength="5000" required
|
||
placeholder="Например: разобрали циклы for и while, решали задачи на списки, домашнее задание — функции"></textarea>
|
||
<span class="hint" id="lessonHint">0 / 5000</span>
|
||
</div>
|
||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||
<button type="button" class="btn-primary ghost" id="lessonCancel">Отмена</button>
|
||
<button type="submit" class="btn-primary" id="lessonSubmit">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>`;
|
||
document.body.appendChild(modal);
|
||
|
||
const groupSel = modal.querySelector('#lessonGroup');
|
||
const textEl = modal.querySelector('#lessonText');
|
||
const hintEl = modal.querySelector('#lessonHint');
|
||
const submitBtn = modal.querySelector('#lessonSubmit');
|
||
|
||
textEl.addEventListener('input', () => { hintEl.textContent = `${textEl.value.length} / 5000`; });
|
||
modal.addEventListener('click', e => { if (e.target === modal) closeLessonModal(); });
|
||
modal.querySelector('#lessonCancel').addEventListener('click', closeLessonModal);
|
||
groupSel.addEventListener('change', () => prefetchLessonForGroupDate());
|
||
modal.querySelector('#lessonForm').addEventListener('submit', e => submitLessonModal(e));
|
||
return modal;
|
||
}
|
||
|
||
async function submitLessonModal(e) {
|
||
e.preventDefault();
|
||
const modal = document.getElementById('lessonModal');
|
||
if (!modal) return;
|
||
const groupSel = modal.querySelector('#lessonGroup');
|
||
const dateEl = modal.querySelector('#lessonDate');
|
||
const timeEl = modal.querySelector('#lessonTime');
|
||
const textEl = modal.querySelector('#lessonText');
|
||
const submitBtn = modal.querySelector('#lessonSubmit');
|
||
const groupId = groupSel.value;
|
||
const date = dateEl.value;
|
||
const text = textEl.value.trim();
|
||
if (!groupId) { showToast('Выберите группу'); groupSel.focus(); return; }
|
||
if (!date) { showToast('Укажите дату занятия'); return; }
|
||
if (!text) { showToast('Введите текст отчёта'); textEl.focus(); return; }
|
||
const id = modal.querySelector('#lessonId').value;
|
||
submitBtn.disabled = true;
|
||
try {
|
||
const res = await fetch(`${API}/api/lesson-reports${id ? '/' + id : ''}`, {
|
||
method: id ? 'PUT' : 'POST',
|
||
headers: hdrJson(),
|
||
body: JSON.stringify({ group_id: Number(groupId), lesson_date: date, lesson_time: timeEl.value || null, text }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
if (res.status === 409 && data.id) {
|
||
closeLessonModal();
|
||
openLessonModal({ id: data.id });
|
||
showToast('За эту дату отчёт уже есть — открыт для редактирования');
|
||
return;
|
||
}
|
||
showToast(data.error || 'Не удалось сохранить отчёт');
|
||
return;
|
||
}
|
||
closeLessonModal();
|
||
showToast(id ? 'Отчёт о занятии обновлён' : 'Отчёт о занятии сохранён');
|
||
if (typeof onLessonReportSaved === 'function') onLessonReportSaved();
|
||
} catch {
|
||
showToast('Ошибка сети');
|
||
} finally {
|
||
submitBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function prefetchLessonForGroupDate() {
|
||
const modal = document.getElementById('lessonModal');
|
||
if (!modal || !modal.classList.contains('open')) return;
|
||
if (modal.querySelector('#lessonId').value) return;
|
||
const groupId = modal.querySelector('#lessonGroup').value;
|
||
const date = modal.querySelector('#lessonDate').value;
|
||
if (!groupId || !date) return;
|
||
try {
|
||
const q = `group_id=${encodeURIComponent(groupId)}&date_from=${encodeURIComponent(date)}&date_to=${encodeURIComponent(date)}&limit=1`;
|
||
const res = await fetch(`${API}/api/lesson-reports?${q}`, { headers: hdr() });
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
const item = (data.items || [])[0];
|
||
if (!item) return;
|
||
fillLessonModalFromReport(modal, item);
|
||
} catch {}
|
||
}
|
||
|
||
function fillLessonModalFromReport(modal, r) {
|
||
modal.querySelector('#lessonId').value = r.id;
|
||
if (r.lesson_time && !modal.querySelector('#lessonTime').value) {
|
||
modal.querySelector('#lessonTime').value = String(r.lesson_time).slice(0, 5);
|
||
}
|
||
if (!modal.querySelector('#lessonText').value) {
|
||
const textEl = modal.querySelector('#lessonText');
|
||
textEl.value = r.text || '';
|
||
textEl.dispatchEvent(new Event('input'));
|
||
}
|
||
modal.querySelector('#lessonModalTitle').textContent = 'Редактирование отчёта';
|
||
modal.querySelector('#lessonSubmit').textContent = 'Обновить';
|
||
}
|
||
|
||
function resetLessonModalFields(modal) {
|
||
modal.querySelector('#lessonId').value = '';
|
||
modal.querySelector('#lessonDate').value = lessonTodayIso();
|
||
modal.querySelector('#lessonTime').value = lessonNowTime();
|
||
const textEl = modal.querySelector('#lessonText');
|
||
textEl.value = '';
|
||
textEl.dispatchEvent(new Event('input'));
|
||
modal.querySelector('#lessonModalTitle').textContent = 'Что прошли на занятии';
|
||
modal.querySelector('#lessonSubmit').textContent = 'Сохранить';
|
||
}
|
||
|
||
async function openLessonModal(opts = {}) {
|
||
const modal = ensureLessonModal();
|
||
const groupSel = modal.querySelector('#lessonGroup');
|
||
const previous = groupSel.value;
|
||
const groups = await loadLessonGroups();
|
||
groupSel.innerHTML = '';
|
||
if (!groups.length) {
|
||
const o = document.createElement('option');
|
||
o.value = '';
|
||
o.textContent = 'Нет доступных групп';
|
||
groupSel.appendChild(o);
|
||
} else {
|
||
groups.forEach(g => {
|
||
const o = document.createElement('option');
|
||
o.value = g.id;
|
||
o.textContent = g.branch_name ? `${g.name} (${g.branch_name})` : g.name;
|
||
groupSel.appendChild(o);
|
||
});
|
||
}
|
||
|
||
resetLessonModalFields(modal);
|
||
const wanted = opts.groupId ? String(opts.groupId) : previous;
|
||
if (wanted && groups.some(g => String(g.id) === wanted)) groupSel.value = wanted;
|
||
|
||
if (opts.text) {
|
||
const textEl = modal.querySelector('#lessonText');
|
||
textEl.value = opts.text;
|
||
textEl.dispatchEvent(new Event('input'));
|
||
}
|
||
|
||
modal.classList.add('open');
|
||
|
||
if (opts.id) {
|
||
try {
|
||
const res = await fetch(`${API}/api/lesson-reports/${opts.id}`, { headers: hdr() });
|
||
if (res.ok) {
|
||
const r = await res.json();
|
||
groupSel.value = String(r.group_id);
|
||
modal.querySelector('#lessonDate').value = String(r.lesson_date).slice(0, 10);
|
||
modal.querySelector('#lessonTime').value = r.lesson_time ? String(r.lesson_time).slice(0, 5) : '';
|
||
fillLessonModalFromReport(modal, r);
|
||
}
|
||
} catch {}
|
||
} else {
|
||
await prefetchLessonForGroupDate();
|
||
}
|
||
|
||
renderIcons();
|
||
setTimeout(() => modal.querySelector('#lessonText').focus(), 50);
|
||
}
|
||
|
||
function closeLessonModal() {
|
||
const modal = document.getElementById('lessonModal');
|
||
if (modal) modal.classList.remove('open');
|
||
}
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key !== 'Escape') return;
|
||
const modal = document.getElementById('lessonModal');
|
||
if (modal && modal.classList.contains('open')) closeLessonModal();
|
||
});
|
||
|
||
function renderPager(el, page, totalPages, onPage) {
|
||
if (!el) return;
|
||
if (totalPages <= 1) { el.innerHTML = ''; return; }
|
||
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(); }
|
||
}); |