diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..f9598c3 --- /dev/null +++ b/auth.js @@ -0,0 +1,49 @@ +const axios = require('axios'); + +async function authenticateWithLDAP(username, password) { + try { + const response = await axios.post(process.env.LDAP_AUTH_URL, { + username, + password + }, { + headers: { 'Content-Type': 'application/json' }, + timeout: 5000 + }); + + if (response.data && response.data.success === true) { + return { + success: true, + username: response.data.username, + full_name: response.data.full_name, + groups: response.data.groups || [], + description: response.data.description || '' + }; + } else { + return { success: false, message: 'Неверные учетные данные' }; + } + } catch (error) { + console.error('LDAP auth error:', error.message); + return { success: false, message: 'Ошибка соединения с сервером авторизации' }; + } +} + +function checkUserAccess(groups) { + const allowedGroups = process.env.ALLOWED_GROUPS ? process.env.ALLOWED_GROUPS.split(',') : []; + const tasksGroups = process.env.TASKS_GROUPS ? process.env.TASKS_GROUPS.split(',') : []; + + const isAdmin = groups.some(group => allowedGroups.includes(group)); + const isAllowed = groups.some(group => tasksGroups.includes(group)); + + if (isAdmin) { + return { allowed: true, role: 'admin' }; + } else if (isAllowed) { + return { allowed: true, role: 'user' }; + } else { + return { allowed: false, role: null, message: 'Доступ запрещён. Обратитесь к администрации.' }; + } +} + +module.exports = { + authenticateWithLDAP, + checkUserAccess +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..afb8725 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "dependencies": { + "axios": "^1.15.0", + "connect-sqlite3": "^0.9.16", + "dotenv": "^17.4.1", + "express": "^5.2.1", + "express-session": "^1.19.0", + "sqlite3": "^6.0.1" + } +} diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..02b76ab --- /dev/null +++ b/public/admin.html @@ -0,0 +1,97 @@ + + + + + Управление уроками + + + +
+

Панель администратора

+
+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+
+
+ + +
+

Импорт уроков из JSON

+ +
+ + +
+ +
+ + + + + + +
+ + + \ No newline at end of file diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..04ba4b9 --- /dev/null +++ b/public/admin.js @@ -0,0 +1,322 @@ +// public/admin.js – панель администратора + +let currentUser = null; +let currentPreviewLessons = []; +let allLessonsForFilters = []; // храним все уроки для построения зависимых фильтров + +document.addEventListener('DOMContentLoaded', async () => { + await checkAuth(); + await loadFilterOptions(); + loadLessons(); + setupEventListeners(); +}); + +async function checkAuth() { + try { + const res = await fetch('/api/me'); + const data = await res.json(); + if (!data.authenticated || data.user.role !== 'admin') { + window.location.href = '/login.html'; + return; + } + currentUser = data.user; + document.getElementById('userInfo').innerHTML = `👋 ${currentUser.full_name} (${currentUser.role})`; + } catch (err) { + window.location.href = '/login.html'; + } +} + +// Загрузка опций для выпадающих списков фильтров (начальные) +async function loadFilterOptions() { + try { + const [classes, teachers, topics] = await Promise.all([ + fetch('/api/filter-options/class-names').then(r => r.json()), + fetch('/api/filter-options/teachers').then(r => r.json()), + fetch('/api/filter-options/topics').then(r => r.json()) + ]); + window.allClassNames = classes; + window.allTeachers = teachers; + window.allTopics = topics; + + populateSelect('filterClass', window.allClassNames, 'Все классы'); + populateSelect('filterTeacher', window.allTeachers, 'Все учителя'); + populateSelect('filterTopic', window.allTopics, 'Все темы'); + } catch (err) { + console.error('Ошибка загрузки опций фильтров', err); + } +} + +function populateSelect(selectId, options, defaultLabel) { + const select = document.getElementById(selectId); + if (!select) return; + const currentValue = select.value; + select.innerHTML = ``; + options.forEach(opt => { + const option = document.createElement('option'); + option.value = opt; + option.textContent = opt; + select.appendChild(option); + }); + if (currentValue && options.includes(currentValue)) { + select.value = currentValue; + } else { + select.value = ''; + } +} + +// Обновление зависимых фильтров на основе текущих значений и allLessonsForFilters +function updateDependentFilters() { + const selectedClass = document.getElementById('filterClass').value; + const selectedTeacher = document.getElementById('filterTeacher').value; + const selectedTopic = document.getElementById('filterTopic').value; + + let filteredLessons = allLessonsForFilters; + if (selectedClass) { + filteredLessons = filteredLessons.filter(l => l.class_name === selectedClass); + } + if (selectedTeacher) { + filteredLessons = filteredLessons.filter(l => l.teacher === selectedTeacher); + } + if (selectedTopic) { + filteredLessons = filteredLessons.filter(l => l.topic === selectedTopic); + } + + const availableClasses = [...new Set(filteredLessons.map(l => l.class_name))].sort(); + const availableTeachers = [...new Set(filteredLessons.map(l => l.teacher))].sort(); + const availableTopics = [...new Set(filteredLessons.map(l => l.topic).filter(t => t))].sort(); + + const oldClass = document.getElementById('filterClass').value; + const oldTeacher = document.getElementById('filterTeacher').value; + const oldTopic = document.getElementById('filterTopic').value; + + populateSelect('filterClass', availableClasses, 'Все классы'); + populateSelect('filterTeacher', availableTeachers, 'Все учителя'); + populateSelect('filterTopic', availableTopics, 'Все темы'); + + if (oldClass && availableClasses.includes(oldClass)) document.getElementById('filterClass').value = oldClass; + else document.getElementById('filterClass').value = ''; + if (oldTeacher && availableTeachers.includes(oldTeacher)) document.getElementById('filterTeacher').value = oldTeacher; + else document.getElementById('filterTeacher').value = ''; + if (oldTopic && availableTopics.includes(oldTopic)) document.getElementById('filterTopic').value = oldTopic; + else document.getElementById('filterTopic').value = ''; +} + +async function loadLessons(filters = {}) { + const params = new URLSearchParams(filters); + const res = await fetch(`/api/admin/lessons?${params}`); + const lessons = await res.json(); + // Сохраняем полный список для фильтрации (без учёта фильтров) + allLessonsForFilters = lessons; + updateDependentFilters(); // обновить списки в select на основе всех уроков + + const container = document.getElementById('lessonsList'); + if (!container) return; + + const grouped = {}; + lessons.forEach(lesson => { + if (!grouped[lesson.class_name]) grouped[lesson.class_name] = []; + grouped[lesson.class_name].push(lesson); + }); + + container.innerHTML = ''; + for (const [className, classLessons] of Object.entries(grouped)) { + const section = document.createElement('div'); + section.className = 'class-group'; + section.innerHTML = `

${className}

`; + classLessons.forEach(lesson => { + const div = document.createElement('div'); + div.className = 'lesson-item'; + div.innerHTML = ` +
+ ${lesson.subject} — ${lesson.teacher}
+ Тема: ${lesson.topic || '—'}
+ ${lesson.date} ${lesson.time} | Места: ${lesson.current_slots}/${lesson.max_slots} +
+
+ + + +
+ `; + section.appendChild(div); + }); + container.appendChild(section); + } + + document.querySelectorAll('.viewRegBtn').forEach(btn => btn.addEventListener('click', () => showRegistrations(btn.dataset.id))); + document.querySelectorAll('.editBtn').forEach(btn => btn.addEventListener('click', () => openLessonModal(btn.dataset.id))); + document.querySelectorAll('.deleteBtn').forEach(btn => btn.addEventListener('click', () => deleteLesson(btn.dataset.id))); +} + +async function showRegistrations(lessonId) { + const res = await fetch(`/api/admin/registrations?lesson_id=${lessonId}`); + const registrations = await res.json(); + const modal = document.getElementById('registrationsModal'); + const listDiv = document.getElementById('registrationsList'); + listDiv.innerHTML = registrations.length ? '' : '

Нет записей

'; + modal.style.display = 'flex'; + modal.querySelector('.close').onclick = () => modal.style.display = 'none'; +} + +async function deleteLesson(id) { + if (!confirm('Удалить урок? Все записи будут удалены.')) return; + await fetch(`/api/admin/lessons/${id}`, { method: 'DELETE' }); + loadLessons(getCurrentFilters()); +} + +function openLessonModal(id = null) { + const modal = document.getElementById('lessonModal'); + const form = document.getElementById('lessonForm'); + form.reset(); + document.getElementById('lessonId').value = ''; + document.getElementById('modalTitle').innerText = id ? 'Редактирование урока' : 'Новый урок'; + if (id) { + fetch(`/api/admin/lessons`).then(res => res.json()).then(lessons => { + const lesson = lessons.find(l => l.id == id); + if (lesson) { + document.getElementById('lessonId').value = lesson.id; + document.getElementById('className').value = lesson.class_name; + document.getElementById('parallel').value = lesson.parallel; + document.getElementById('subject').value = lesson.subject; + document.getElementById('teacher').value = lesson.teacher; + document.getElementById('topic').value = lesson.topic || ''; + document.getElementById('maxSlots').value = lesson.max_slots; + document.getElementById('date').value = lesson.date; + document.getElementById('time').value = lesson.time; + } + }); + } + modal.style.display = 'flex'; + modal.querySelector('.close').onclick = () => modal.style.display = 'none'; +} + +document.getElementById('lessonForm')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const id = document.getElementById('lessonId').value; + const payload = { + id: id || undefined, + class_name: document.getElementById('className').value, + parallel: parseInt(document.getElementById('parallel').value), + subject: document.getElementById('subject').value, + teacher: document.getElementById('teacher').value, + topic: document.getElementById('topic').value, + max_slots: parseInt(document.getElementById('maxSlots').value), + date: document.getElementById('date').value, + time: document.getElementById('time').value + }; + await fetch('/api/admin/lessons', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + document.getElementById('lessonModal').style.display = 'none'; + loadLessons(getCurrentFilters()); +}); + +function getCurrentFilters() { + return { + class_name: document.getElementById('filterClass').value, + parallel: document.getElementById('filterParallel').value, + teacher: document.getElementById('filterTeacher').value, + topic: document.getElementById('filterTopic').value + }; +} + +function setupEventListeners() { + const classSelect = document.getElementById('filterClass'); + const teacherSelect = document.getElementById('filterTeacher'); + const topicSelect = document.getElementById('filterTopic'); + + function handleFilterChange() { + // Обновить зависимые списки, затем применить фильтрацию списка уроков + updateDependentFilters(); + loadLessons(getCurrentFilters()); + } + + classSelect?.addEventListener('change', handleFilterChange); + teacherSelect?.addEventListener('change', handleFilterChange); + topicSelect?.addEventListener('change', handleFilterChange); + + document.getElementById('applyFilters')?.addEventListener('click', () => { + loadLessons(getCurrentFilters()); + }); + + document.getElementById('resetFilters')?.addEventListener('click', () => { + document.getElementById('filterClass').value = ''; + document.getElementById('filterParallel').value = ''; + document.getElementById('filterTeacher').value = ''; + document.getElementById('filterTopic').value = ''; + updateDependentFilters(); + loadLessons({}); + }); + + document.getElementById('addLessonBtn')?.addEventListener('click', () => openLessonModal()); + document.getElementById('logoutBtn')?.addEventListener('click', async () => { + await fetch('/api/logout', { method: 'POST' }); + window.location.href = '/'; + }); + + // Импорт JSON (без изменений) + document.getElementById('previewImportBtn')?.addEventListener('click', async () => { + const file = document.getElementById('jsonFileInput').files[0]; + if (!file) return alert('Выберите JSON-файл'); + const importMaxSlots = document.getElementById('importMaxSlots').value; + if (!importMaxSlots) return alert('Укажите максимальное количество мест'); + + const text = await file.text(); + try { + const response = await fetch('/api/admin/import/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonData: JSON.parse(text) }) + }); + const data = await response.json(); + if (data.success) { + currentPreviewLessons = data.preview; + const tbody = document.querySelector('#previewTable tbody'); + tbody.innerHTML = ''; + data.preview.slice(0,20).forEach(lesson => { + const row = tbody.insertRow(); + row.insertCell(0).innerText = lesson.className; + row.insertCell(1).innerText = lesson.subject; + row.insertCell(2).innerText = lesson.teacher; + row.insertCell(3).innerText = lesson.topic || ''; + }); + if (data.preview.length > 20) { + const row = tbody.insertRow(); + const cell = row.insertCell(0); + cell.colSpan = 4; + cell.innerText = `... и ещё ${data.preview.length - 20} записей`; + } + document.getElementById('importPreview').style.display = 'block'; + } else { + alert('Ошибка предпросмотра: ' + (data.error || '')); + } + } catch (err) { + alert('Ошибка при разборе JSON: ' + err.message); + } + }); + + document.getElementById('confirmImportBtn')?.addEventListener('click', async () => { + if (!confirm(`Импортировать ${currentPreviewLessons.length} уроков?`)) return; + const importMaxSlots = document.getElementById('importMaxSlots').value; + const response = await fetch('/api/admin/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + lessons: currentPreviewLessons, + defaultMaxSlots: importMaxSlots + }) + }); + const result = await response.json(); + if (result.success) { + const created = result.results.filter(r => r.status === 'created').length; + const skipped = result.results.filter(r => r.status === 'skipped').length; + document.getElementById('importResult').innerHTML = `Импорт завершён: создано ${created}, пропущено (дубликаты) ${skipped}`; + loadLessons(getCurrentFilters()); + document.getElementById('importPreview').style.display = 'none'; + } else { + alert('Ошибка импорта: ' + (result.error || '')); + } + }); +} \ No newline at end of file diff --git a/public/help.html b/public/help.html new file mode 100644 index 0000000..39e068f --- /dev/null +++ b/public/help.html @@ -0,0 +1,111 @@ + + + + + + Справка по системе + + + + +
+

Справка по системе

+
+ +
+
+
+

📅 Запись на открытые уроки доступ всем

+

Ссылка: / (главная страница)

+

Родители могут просматривать доступные уроки, фильтровать их по классу, учителю или теме, и записываться, указав свои ФИО и телефон. После записи количество свободных мест уменьшается.

+

Уроки, на которых нет свободных мест, не отображаются.

+
+ +
+

🔐 Панель администратора только admin

+

Ссылка: /admin

+

Доступна после авторизации с правами администратора (локальный пользователь или группа из Администраторов).

+ +
+ +
+

📊 Список записавшихся родителей admin + Администрация

+

Ссылка: /info

+

Доступен администраторам и пользователям, входящим в группы, указанные в переменной окружения Администрация.

+ +
+ + +
+ + + \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..4e1b230 --- /dev/null +++ b/public/index.html @@ -0,0 +1,55 @@ + + + + + + Запись на открытые уроки + + + +
+

День открытых дверей

+

Выберите урок, на который хотите записаться

+
+
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ + + + \ No newline at end of file diff --git a/public/info.html b/public/info.html new file mode 100644 index 0000000..8796ae5 --- /dev/null +++ b/public/info.html @@ -0,0 +1,134 @@ + + + + + + Список записавшихся родителей + + + + +
+

Список записавшихся родителей

+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+
+

Все записи

+ +
+
+ + + + + + + + + + + + + + + + + +
ФИО родителяТелефонКлассПредметУчительТема урокаДата урокаВремяДата регистрации
Загрузка...
+
+
+ + + \ No newline at end of file diff --git a/public/info.js b/public/info.js new file mode 100644 index 0000000..0a0d310 --- /dev/null +++ b/public/info.js @@ -0,0 +1,151 @@ +// public/info.js – страница просмотра записей + +let currentUser = null; +let currentRegistrations = []; + +document.addEventListener('DOMContentLoaded', async () => { + await checkAuth(); + await loadFilterOptions(); + loadRegistrations(); + setupEventListeners(); +}); + +async function checkAuth() { + try { + const res = await fetch('/api/me'); + const data = await res.json(); + if (!data.authenticated || (data.user.role !== 'admin' && data.user.role !== 'user')) { + window.location.href = '/login.html'; + return; + } + currentUser = data.user; + document.getElementById('userInfo').innerHTML = `👋 ${currentUser.full_name} (${currentUser.role})`; + } catch (err) { + window.location.href = '/login.html'; + } +} + +async function loadFilterOptions() { + try { + const [classes, teachers, subjects] = await Promise.all([ + fetch('/api/filter-options/class-names').then(r => r.json()), + fetch('/api/filter-options/teachers').then(r => r.json()), + fetch('/api/filter-options/subjects').then(r => r.json()) + ]); + populateSelect('filterClass', classes, 'Все классы'); + populateSelect('filterTeacher', teachers, 'Все учителя'); + populateSelect('filterSubject', subjects, 'Все предметы'); + } catch (err) { + console.error('Ошибка загрузки опций', err); + } +} + +function populateSelect(selectId, options, defaultLabel) { + const select = document.getElementById(selectId); + if (!select) return; + select.innerHTML = ``; + options.forEach(opt => { + const option = document.createElement('option'); + option.value = opt; + option.textContent = opt; + select.appendChild(option); + }); +} + +async function loadRegistrations() { + const params = new URLSearchParams({ + parent_name: document.getElementById('filterParentName').value, + class_name: document.getElementById('filterClass').value, + subject: document.getElementById('filterSubject').value, + teacher: document.getElementById('filterTeacher').value + }); + try { + const res = await fetch(`/api/info/registrations?${params}`); + currentRegistrations = await res.json(); + renderTable(currentRegistrations); + document.getElementById('recordsCount').innerText = `Найдено: ${currentRegistrations.length}`; + } catch (err) { + console.error(err); + document.getElementById('tableBody').innerHTML = 'Ошибка загрузки'; + } +} + +function renderTable(registrations) { + const tbody = document.getElementById('tableBody'); + if (!registrations.length) { + tbody.innerHTML = 'Нет записей'; + return; + } + tbody.innerHTML = registrations.map(reg => ` + + ${escapeHtml(reg.parent_name)} + ${escapeHtml(reg.parent_phone)} + ${escapeHtml(reg.class_name)} + ${escapeHtml(reg.subject)} + ${escapeHtml(reg.teacher)} + ${escapeHtml(reg.topic || '—')} + ${escapeHtml(reg.date)} + ${escapeHtml(reg.time)} + ${new Date(reg.created_at).toLocaleString()} + + `).join(''); +} + +function escapeHtml(str) { + if (!str) return ''; + return str.replace(/[&<>]/g, function(m) { + if (m === '&') return '&'; + if (m === '<') return '<'; + if (m === '>') return '>'; + return m; + }); +} + +function exportToCSV() { + if (!currentRegistrations.length) { + alert('Нет данных для экспорта'); + return; + } + // Заголовки + const headers = ['ФИО родителя', 'Телефон', 'Класс', 'Предмет', 'Учитель', 'Тема урока', 'Дата урока', 'Время', 'Дата регистрации']; + const rows = currentRegistrations.map(reg => [ + reg.parent_name, + reg.parent_phone, + reg.class_name, + reg.subject, + reg.teacher, + reg.topic || '', + reg.date, + reg.time, + new Date(reg.created_at).toLocaleString() + ]); + const csvContent = [headers, ...rows] + .map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(';')) + .join('\n'); + // Добавляем BOM для корректной поддержки кириллицы в Excel + const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.href = url; + link.setAttribute('download', 'zapis_roditelei.csv'); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +function setupEventListeners() { + document.getElementById('applyFiltersBtn')?.addEventListener('click', () => loadRegistrations()); + document.getElementById('resetFiltersBtn')?.addEventListener('click', () => { + document.getElementById('filterParentName').value = ''; + document.getElementById('filterClass').value = ''; + document.getElementById('filterSubject').value = ''; + document.getElementById('filterTeacher').value = ''; + loadRegistrations(); + }); + document.getElementById('exportBtn')?.addEventListener('click', exportToCSV); + document.getElementById('logoutBtn')?.addEventListener('click', async () => { + await fetch('/api/logout', { method: 'POST' }); + window.location.href = '/'; + }); +} \ No newline at end of file diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..777254f --- /dev/null +++ b/public/login.html @@ -0,0 +1,53 @@ + + + + + Вход в админку + + + + +
+

Авторизация администратора

+
+ + + +
+
+
+ + + \ No newline at end of file diff --git a/public/main.js b/public/main.js new file mode 100644 index 0000000..768d6f7 --- /dev/null +++ b/public/main.js @@ -0,0 +1,246 @@ +// public/main.js – страница записи родителей + +let allLessons = []; + +// Загрузка опций для выпадающих списков +async function loadFilterOptions() { + try { + const [classes, teachers, topics] = await Promise.all([ + fetch('/api/filter-options/class-names').then(r => r.json()), + fetch('/api/filter-options/teachers').then(r => r.json()), + fetch('/api/filter-options/topics').then(r => r.json()) + ]); + // Сохраняем полные списки для дальнейшей фильтрации + window.allClassNames = classes; + window.allTeachers = teachers; + window.allTopics = topics; + + populateSelect('filterClass', window.allClassNames, 'Все классы'); + populateSelect('filterTeacher', window.allTeachers, 'Все учителя'); + populateSelect('filterTopic', window.allTopics, 'Все темы'); + } catch (err) { + console.error('Ошибка загрузки опций фильтров', err); + } +} + +function populateSelect(selectId, options, defaultLabel) { + const select = document.getElementById(selectId); + if (!select) return; + const currentValue = select.value; + select.innerHTML = ``; + options.forEach(opt => { + const option = document.createElement('option'); + option.value = opt; + option.textContent = opt; + select.appendChild(option); + }); + // Восстанавливаем выбранное значение, если оно ещё допустимо + if (currentValue && options.includes(currentValue)) { + select.value = currentValue; + } else { + select.value = ''; + } +} + +// Загрузка всех уроков с сервера +async function loadLessons() { + try { + const res = await fetch('/api/lessons'); + allLessons = await res.json(); + updateDependentFilters(); // первоначальное построение зависимых списков + applyFilters(); + } catch (err) { + console.error('Ошибка загрузки уроков', err); + document.getElementById('lessonsContainer').innerHTML = '

Ошибка загрузки данных

'; + } +} + +// Обновление зависимых выпадающих списков на основе текущих фильтров +function updateDependentFilters() { + const selectedClass = document.getElementById('filterClass').value; + const selectedTeacher = document.getElementById('filterTeacher').value; + const selectedTopic = document.getElementById('filterTopic').value; + + // Фильтруем уроки по выбранным значениям (если они не пустые) + let filteredLessons = allLessons; + if (selectedClass) { + filteredLessons = filteredLessons.filter(l => l.class_name === selectedClass); + } + if (selectedTeacher) { + filteredLessons = filteredLessons.filter(l => l.teacher === selectedTeacher); + } + if (selectedTopic) { + filteredLessons = filteredLessons.filter(l => l.topic === selectedTopic); + } + + // Извлекаем уникальные значения для каждого поля + const availableClasses = [...new Set(filteredLessons.map(l => l.class_name))].sort(); + const availableTeachers = [...new Set(filteredLessons.map(l => l.teacher))].sort(); + const availableTopics = [...new Set(filteredLessons.map(l => l.topic).filter(t => t))].sort(); + + // Обновляем select, сохраняя текущие значения, если они допустимы + const classSelect = document.getElementById('filterClass'); + const teacherSelect = document.getElementById('filterTeacher'); + const topicSelect = document.getElementById('filterTopic'); + + const oldClass = classSelect.value; + const oldTeacher = teacherSelect.value; + const oldTopic = topicSelect.value; + + populateSelect('filterClass', availableClasses, 'Все классы'); + populateSelect('filterTeacher', availableTeachers, 'Все учителя'); + populateSelect('filterTopic', availableTopics, 'Все темы'); + + // Если старое значение не было сброшено populateSelect, восстанавливаем + if (oldClass && availableClasses.includes(oldClass)) classSelect.value = oldClass; + else classSelect.value = ''; + if (oldTeacher && availableTeachers.includes(oldTeacher)) teacherSelect.value = oldTeacher; + else teacherSelect.value = ''; + if (oldTopic && availableTopics.includes(oldTopic)) topicSelect.value = oldTopic; + else topicSelect.value = ''; +} + +// Применение фильтров + скрытие уроков без свободных мест +function applyFilters() { + const classFilter = document.getElementById('filterClass').value; + const teacherFilter = document.getElementById('filterTeacher').value; + const topicFilter = document.getElementById('filterTopic').value; + + // Сначала отбираем только доступные уроки (есть свободные места) + let filtered = allLessons.filter(lesson => lesson.available === true); + + // Точное совпадение для select (не частичное) + if (classFilter) filtered = filtered.filter(lesson => lesson.class_name === classFilter); + if (teacherFilter) filtered = filtered.filter(lesson => lesson.teacher === teacherFilter); + if (topicFilter) filtered = filtered.filter(lesson => lesson.topic === topicFilter); + + renderLessons(filtered); +} + +// Отрисовка карточек уроков + обновление счётчика +function renderLessons(lessons) { + const container = document.getElementById('lessonsContainer'); + const counterSpan = document.getElementById('availableCount'); + if (!container) return; + + if (lessons.length === 0) { + container.innerHTML = '

Нет доступных уроков

'; + if (counterSpan) counterSpan.textContent = '0'; + return; + } + + container.innerHTML = lessons.map(lesson => ` +
+

${escapeHtml(lesson.class_name)} | ${escapeHtml(lesson.subject)}

+

Учитель: ${escapeHtml(lesson.teacher)}

+

Тема: ${escapeHtml(lesson.topic || '—')}

+
+ Свободных мест: ${lesson.max_slots - lesson.current_slots} из ${lesson.max_slots} +
+
+ `).join(''); + + if (counterSpan) counterSpan.textContent = `${lessons.length}`; + + document.querySelectorAll('.lesson-card').forEach(card => { + card.addEventListener('click', () => openModal(card.dataset.id)); + }); +} + +// Модальное окно записи (без изменений) +function setupModal() { + const modal = document.getElementById('modal'); + const closeSpan = modal.querySelector('.close'); + const form = document.getElementById('registrationForm'); + + closeSpan.onclick = () => modal.style.display = 'none'; + window.onclick = (e) => { if (e.target === modal) modal.style.display = 'none'; }; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const lessonId = document.getElementById('lessonId').value; + const parentName = document.getElementById('parentName').value.trim(); + const parentPhone = document.getElementById('parentPhone').value.trim(); + const messageDiv = document.getElementById('modalMessage'); + + if (!parentName || !parentPhone) { + messageDiv.innerHTML = 'Заполните все поля'; + return; + } + + try { + const res = await fetch('/api/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lesson_id: lessonId, parent_name: parentName, parent_phone: parentPhone }) + }); + const data = await res.json(); + if (res.ok) { + messageDiv.innerHTML = '✅ Вы успешно записаны!'; + setTimeout(() => { + modal.style.display = 'none'; + loadLessons(); + }, 1500); + } else { + messageDiv.innerHTML = `${data.error || 'Ошибка'}`; + } + } catch (err) { + messageDiv.innerHTML = 'Ошибка сервера'; + } + }); +} + +function openModal(lessonId) { + const modal = document.getElementById('modal'); + const lesson = allLessons.find(l => l.id == lessonId); + if (!lesson) return; + + document.getElementById('lessonId').value = lessonId; + document.getElementById('modalLessonInfo').innerHTML = ` + ${escapeHtml(lesson.class_name)}
+ ${escapeHtml(lesson.subject)} — ${escapeHtml(lesson.teacher)}
+ ${lesson.date} ${lesson.time} + `; + document.getElementById('modalMessage').innerHTML = ''; + document.getElementById('registrationForm').reset(); + modal.style.display = 'flex'; +} + +function escapeHtml(str) { + if (!str) return ''; + return str.replace(/[&<>]/g, function(m) { + if (m === '&') return '&'; + if (m === '<') return '<'; + if (m === '>') return '>'; + return m; + }); +} + +// Инициализация +document.addEventListener('DOMContentLoaded', async () => { + await loadFilterOptions(); + await loadLessons(); + setupModal(); + + // Обработчики изменений фильтров + const classSelect = document.getElementById('filterClass'); + const teacherSelect = document.getElementById('filterTeacher'); + const topicSelect = document.getElementById('filterTopic'); + + function handleFilterChange() { + updateDependentFilters(); + applyFilters(); + } + + classSelect?.addEventListener('change', handleFilterChange); + teacherSelect?.addEventListener('change', handleFilterChange); + topicSelect?.addEventListener('change', handleFilterChange); + + document.getElementById('resetFilter')?.addEventListener('click', () => { + classSelect.value = ''; + teacherSelect.value = ''; + topicSelect.value = ''; + updateDependentFilters(); + applyFilters(); + }); +}); \ No newline at end of file diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..226edfa --- /dev/null +++ b/public/style.css @@ -0,0 +1,247 @@ +* { + box-sizing: border-box; + font-family: system-ui, 'Segoe UI', Roboto, sans-serif; +} +body { + margin: 0; + background: #f5f7fb; + color: #1e293b; +} +header { + background: white; + padding: 1rem 2rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.05); + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; +} +h1 { margin: 0; font-size: 1.5rem; } +.lessons-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; + padding: 2rem; +} +.lesson-card { + background: white; + border-radius: 1rem; + box-shadow: 0 4px 12px rgba(0,0,0,0.08); + padding: 1.2rem; + transition: transform 0.1s ease; + cursor: pointer; + border: 1px solid #e2e8f0; +} +.lesson-card:hover { + transform: translateY(-4px); + border-color: #cbd5e1; +} +.lesson-card.disabled { + opacity: 0.6; + cursor: not-allowed; + background: #f1f5f9; +} +.lesson-card h3 { margin-top: 0; color: #0f172a; } +.lesson-card p { margin: 0.5rem 0; color: #334155; } +.slots { + font-weight: bold; + margin-top: 0.8rem; + color: #2d6a4f; +} +.slots.full { color: #b91c1c; } +.filters { + margin: 1rem 2rem; + display: flex; + gap: 0.8rem; + flex-wrap: wrap; +} +.filters input, .filters button { + padding: 0.5rem 1rem; + border-radius: 2rem; + border: 1px solid #cbd5e1; + background: white; +} +button { + background: #1e3a8a; + color: white; + border: none; + padding: 0.5rem 1.2rem; + border-radius: 2rem; + cursor: pointer; + font-weight: 500; + transition: background 0.2s; +} +button:hover { background: #1e40af; } +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); + justify-content: center; + align-items: center; +} +.modal-content { + background: white; + padding: 2rem; + border-radius: 1.5rem; + width: 90%; + max-width: 500px; + position: relative; +} +.modal-content.large { max-width: 800px; } +.close { + position: absolute; + right: 1.5rem; + top: 1rem; + font-size: 1.8rem; + cursor: pointer; + color: #64748b; +} +form label { + display: block; + margin: 1rem 0 0.3rem; + font-weight: 500; +} +form input, form textarea { + width: 100%; + padding: 0.6rem; + border: 1px solid #cbd5e1; + border-radius: 0.8rem; +} +.lessons-list .lesson-item { + background: white; + margin: 1rem 2rem; + padding: 1rem; + border-radius: 1rem; + display: flex; + justify-content: space-between; + flex-wrap: wrap; + align-items: center; + gap: 1rem; +} +.lesson-actions button { + margin-left: 0.5rem; + background: #475569; +} +.lesson-actions button.danger { background: #b91c1c; } +.import-section { + background: #f9fafb; + padding: 1rem; + border-radius: 1rem; + margin: 1rem 2rem; +} +#importPreview table { + width: 100%; + border-collapse: collapse; + background: white; +} +#importPreview th, #importPreview td { + padding: 8px; + border: 1px solid #ddd; +} +@media (max-width: 640px) { + .lessons-grid { padding: 1rem; } + .filters { margin: 1rem; } +} +.filters { + display: flex; + align-items: center; + gap: 0.8rem; + flex-wrap: wrap; +} +#availableCount { + margin-left: auto; + white-space: nowrap; +} +/* Группа фильтров */ +.filters { + display: flex; + align-items: flex-end; + gap: 1rem; + flex-wrap: wrap; + background: white; + padding: 1rem 1.5rem; + border-radius: 1.5rem; + margin: 1rem 2rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.05); +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 150px; + max-width: 450px; +} + +.filter-group label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #475569; +} + +.filter-group select, +.filter-group input { + padding: 0.5rem 1rem; + border-radius: 2rem; + border: 1px solid #cbd5e1; + background-color: white; + font-size: 0.9rem; + cursor: pointer; + transition: 0.2s; +} + +.filter-group select:hover, +.filter-group input:hover { + border-color: #94a3b8; +} + +.filter-group select:focus, +.filter-group input:focus { + outline: none; + border-color: #1e3a8a; + box-shadow: 0 0 0 2px rgba(30,58,138,0.2); +} + +.reset-btn { + background: #f1f5f9; + color: #1e293b; + border: 1px solid #cbd5e1; +} + +.reset-btn:hover { + background: #e2e8f0; + border-color: #94a3b8; +} + +.lessons-count { + background: #e2e8f0; + padding: 0.4rem 1rem; + border-radius: 2rem; + font-weight: bold; + font-size: 0.9rem; + margin-left: auto; + white-space: nowrap; +} + +/* Адаптивность */ +@media (max-width: 640px) { + .filters { + flex-direction: column; + align-items: stretch; + margin: 1rem; + } + .filter-group { + min-width: auto; + } + .lessons-count { + margin-left: 0; + text-align: center; + } +} \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..c68f40d --- /dev/null +++ b/server.js @@ -0,0 +1,363 @@ +require('dotenv').config(); +const express = require('express'); +const session = require('express-session'); +const path = require('path'); +const db = require('./sqllite'); +const auth = require('./auth'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(express.json({ limit: '50mb' })); +app.use(express.urlencoded({ extended: true })); +app.use(express.static(path.join(__dirname, 'public'))); + +const SQLiteStore = require('connect-sqlite3')(session); +app.use(session({ + store: new SQLiteStore({ db: 'sessions.db', dir: './data' }), + secret: process.env.SESSION_SECRET || 'default_secret', + resave: false, + saveUninitialized: false, + cookie: { + secure: false, + httpOnly: true, + maxAge: 1000 * 60 * 60 * 24 + } +})); + +function isAuthenticated(req, res, next) { + if (req.session.user) { + next(); + } else { + res.status(401).json({ error: 'Не авторизован' }); + } +} + +function isAdmin(req, res, next) { + if (req.session.user && req.session.user.role === 'admin') { + next(); + } else { + res.status(403).json({ error: 'Требуются права администратора' }); + } +} + +function isAllowed(req, res, next) { + if (req.session.user && (req.session.user.role === 'admin' || req.session.user.role === 'user')) { + next(); + } else { + res.status(403).json({ error: 'Доступ запрещён' }); + } +} + +// --------------------- API авторизации --------------------- +app.post('/api/auth', async (req, res) => { + const { username, password } = req.body; + if (!username || !password) { + return res.status(400).json({ success: false, message: 'Логин и пароль обязательны' }); + } + + if (username === process.env.USER_1_LOGIN && password === process.env.USER_1_PASSWORD) { + req.session.user = { + username: process.env.USER_1_LOGIN, + full_name: process.env.USER_1_NAME, + role: process.env.USER_1_ROLE || 'admin', + groups: ['local'] + }; + return res.json({ success: true, full_name: process.env.USER_1_NAME, role: req.session.user.role }); + } + + const ldapResult = await auth.authenticateWithLDAP(username, password); + if (!ldapResult.success) { + return res.status(401).json({ success: false, message: ldapResult.message }); + } + + const access = auth.checkUserAccess(ldapResult.groups); + if (!access.allowed) { + return res.status(403).json({ success: false, message: access.message }); + } + + req.session.user = { + username: ldapResult.username, + full_name: ldapResult.full_name, + role: access.role, + groups: ldapResult.groups + }; + + res.json({ success: true, full_name: ldapResult.full_name, role: access.role }); +}); + +app.post('/api/logout', (req, res) => { + req.session.destroy(); + res.json({ success: true }); +}); + +app.get('/api/me', (req, res) => { + if (req.session.user) { + res.json({ authenticated: true, user: req.session.user }); + } else { + res.json({ authenticated: false }); + } +}); + +// --------------------- API для родителей --------------------- +app.get('/api/lessons', async (req, res) => { + try { + const filter = { + class_name: req.query.class_name, + parallel: req.query.parallel, + teacher: req.query.teacher, + topic: req.query.topic + }; + const lessons = await db.getLessons(filter); + const enriched = lessons.map(l => ({ + ...l, + available: l.current_slots < l.max_slots + })); + res.json(enriched); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Ошибка получения уроков' }); + } +}); + +app.post('/api/register', async (req, res) => { + const { lesson_id, parent_name, parent_phone } = req.body; + if (!lesson_id || !parent_name || !parent_phone) { + return res.status(400).json({ error: 'Все поля обязательны' }); + } + + try { + const lesson = await db.getLessonById(lesson_id); + if (!lesson) return res.status(404).json({ error: 'Урок не найден' }); + if (lesson.current_slots >= lesson.max_slots) { + return res.status(400).json({ error: 'Нет свободных мест' }); + } + + const result = await db.incrementCurrentSlots(lesson_id); + if (result.changes === 0) { + return res.status(400).json({ error: 'Места закончились, попробуйте позже' }); + } + + await db.addRegistration(lesson_id, parent_name, parent_phone); + res.json({ success: true, message: 'Вы успешно записаны!' }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Ошибка при записи' }); + } +}); + +// --------------------- Административные API --------------------- +app.get('/api/admin/lessons', isAuthenticated, isAdmin, async (req, res) => { + try { + const filter = { + class_name: req.query.class_name, + parallel: req.query.parallel, + teacher: req.query.teacher, + topic: req.query.topic + }; + const lessons = await db.getLessons(filter); + res.json(lessons); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Ошибка получения уроков' }); + } +}); + +app.post('/api/admin/lessons', isAuthenticated, isAdmin, async (req, res) => { + const { id, class_name, parallel, subject, teacher, topic, max_slots, date, time } = req.body; + if (!class_name || !parallel || !subject || !teacher || !max_slots || !date || !time) { + return res.status(400).json({ error: 'Все поля обязательны' }); + } + + try { + if (id) { + const existing = await db.getLessonById(id); + if (!existing) return res.status(404).json({ error: 'Урок не найден' }); + await db.updateLesson(id, { class_name, parallel, subject, teacher, topic, max_slots, date, time }); + res.json({ success: true, message: 'Урок обновлён' }); + } else { + const newId = await db.createLesson({ class_name, parallel, subject, teacher, topic, max_slots, date, time }); + res.json({ success: true, message: 'Урок создан', id: newId }); + } + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Ошибка сохранения урока' }); + } +}); + +app.delete('/api/admin/lessons/:id', isAuthenticated, isAdmin, async (req, res) => { + try { + await db.deleteLesson(req.params.id); + res.json({ success: true }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Ошибка удаления' }); + } +}); + +app.get('/api/admin/registrations', isAuthenticated, isAdmin, async (req, res) => { + const lessonId = req.query.lesson_id; + try { + if (lessonId) { + const regs = await db.getRegistrationsByLesson(lessonId); + res.json(regs); + } else { + const allRegs = await db.getAllRegistrations(); + res.json(allRegs); + } + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Ошибка получения записей' }); + } +}); + +// --------------------- API для страницы /info --------------------- +app.get('/api/info/registrations', isAllowed, async (req, res) => { + try { + const filters = { + parent_name: req.query.parent_name, + class_name: req.query.class_name, + subject: req.query.subject, + teacher: req.query.teacher + }; + const registrations = await db.getRegistrationsWithFilters(filters); + res.json(registrations); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Ошибка получения данных' }); + } +}); + +app.get('/api/filter-options/subjects', async (req, res) => { + try { + const rows = await db.allQuery('SELECT DISTINCT subject FROM lessons ORDER BY subject'); + res.json(rows.map(r => r.subject)); + } catch (err) { + res.status(500).json([]); + } +}); + +// --------------------- Импорт уроков из JSON (без даты/времени) --------------------- +function parseLessonFromJsonRecord(record) { + const fields = {}; + for (const [key, value] of record) { + fields[key.trim()] = (value || '').trim(); + } + const lastName = fields["Фамилия Учителя"] || ""; + const firstName = fields["Имя Учителя"] || ""; + const patronymic = fields["Отчество Учителя"] || ""; + const teacher = `${lastName} ${firstName} ${patronymic}`.trim().replace(/\s+/g, ' '); + const subject = fields["Предмет"] || ""; + const topic = fields["Тема Урока"] || ""; + const parallelRaw = fields["Класс (параллель)"] || ""; + const classLetter = fields["Класс (буква)"] || ""; + const parallel = parseInt(parallelRaw, 10); + const className = `${parallelRaw}${classLetter}`.trim(); + return { teacher, subject, topic, parallel, className }; +} + +app.post('/api/admin/import/preview', isAuthenticated, isAdmin, async (req, res) => { + try { + let records = req.body.jsonData; + if (typeof records === 'string') records = JSON.parse(records); + if (!Array.isArray(records)) return res.status(400).json({ error: 'Данные должны быть массивом' }); + + const preview = []; + for (const record of records) { + if (!Array.isArray(record)) continue; + const lesson = parseLessonFromJsonRecord(record); + if (lesson.teacher && lesson.subject && !isNaN(lesson.parallel)) { + preview.push(lesson); + } + } + res.json({ success: true, preview }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Ошибка разбора JSON' }); + } +}); + +app.post('/api/admin/import', isAuthenticated, isAdmin, async (req, res) => { + try { + const { lessons, defaultMaxSlots } = req.body; + if (!Array.isArray(lessons) || !defaultMaxSlots) { + return res.status(400).json({ error: 'Недостаточно параметров' }); + } + + const maxSlots = parseInt(defaultMaxSlots, 10); + const defaultDate = new Date().toISOString().slice(0,10); + const defaultTime = "12:00"; + + const results = []; + for (const lesson of lessons) { + const { className, parallel, subject, teacher, topic } = lesson; + const existing = await db.getQuery( + `SELECT id FROM lessons WHERE class_name=? AND subject=? AND teacher=? AND date=? AND time=?`, + [className, subject, teacher, defaultDate, defaultTime] + ); + if (existing) { + results.push({ className, subject, status: 'skipped', reason: 'уже существует' }); + continue; + } + const newId = await db.createLesson({ + class_name: className, + parallel: parallel, + subject: subject, + teacher: teacher, + topic: topic, + max_slots: maxSlots, + date: defaultDate, + time: defaultTime + }); + results.push({ className, subject, id: newId, status: 'created' }); + } + res.json({ success: true, results }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Ошибка импорта: ' + err.message }); + } +}); + +// --------------------- Статические страницы --------------------- +app.get('/admin', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'admin.html')); +}); +app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); +app.get('/info', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'info.html')); +}); +app.get('/help', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'help.html')); +}); + +// API для выпадающих списков (общие) +app.get('/api/filter-options/class-names', async (req, res) => { + try { + const rows = await db.allQuery('SELECT DISTINCT class_name FROM lessons ORDER BY class_name'); + res.json(rows.map(r => r.class_name)); + } catch (err) { + res.status(500).json([]); + } +}); +app.get('/api/filter-options/teachers', async (req, res) => { + try { + const rows = await db.allQuery('SELECT DISTINCT teacher FROM lessons ORDER BY teacher'); + res.json(rows.map(r => r.teacher)); + } catch (err) { + res.status(500).json([]); + } +}); +app.get('/api/filter-options/topics', async (req, res) => { + try { + const rows = await db.allQuery('SELECT DISTINCT topic FROM lessons WHERE topic IS NOT NULL AND topic != "" ORDER BY topic'); + res.json(rows.map(r => r.topic)); + } catch (err) { + res.status(500).json([]); + } +}); + +app.listen(PORT, () => { + console.log(`Сервер запущен на http://localhost:${PORT}`); +}); \ No newline at end of file diff --git a/sqllite.js b/sqllite.js new file mode 100644 index 0000000..859ca16 --- /dev/null +++ b/sqllite.js @@ -0,0 +1,180 @@ +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); +const fs = require('fs'); + +const dbPath = path.join(__dirname, 'data', 'school.db'); +const dataDir = path.join(__dirname, 'data'); + +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir); +} + +const db = new sqlite3.Database(dbPath); + +// Инициализация таблиц +db.serialize(() => { + db.run(` + CREATE TABLE IF NOT EXISTS lessons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + class_name TEXT NOT NULL, + parallel INTEGER NOT NULL, + subject TEXT NOT NULL, + teacher TEXT NOT NULL, + topic TEXT, + max_slots INTEGER NOT NULL DEFAULT 4, + current_slots INTEGER NOT NULL DEFAULT 0, + date TEXT NOT NULL, + time TEXT NOT NULL + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS registrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lesson_id INTEGER NOT NULL, + parent_name TEXT NOT NULL, + parent_phone TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE + ) + `); +}); + +function runQuery(sql, params = []) { + return new Promise((resolve, reject) => { + db.run(sql, params, function(err) { + if (err) reject(err); + else resolve(this); + }); + }); +} + +function getQuery(sql, params = []) { + return new Promise((resolve, reject) => { + db.get(sql, params, (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); +} + +function allQuery(sql, params = []) { + return new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); +} + +module.exports = { + db, + runQuery, + getQuery, + allQuery, + + getLessons: (filter = {}) => { + let sql = 'SELECT * FROM lessons WHERE 1=1'; + const params = []; + if (filter.class_name) { + sql += ' AND class_name LIKE ?'; + params.push(`%${filter.class_name}%`); + } + if (filter.parallel) { + sql += ' AND parallel = ?'; + params.push(filter.parallel); + } + if (filter.teacher) { + sql += ' AND teacher LIKE ?'; + params.push(`%${filter.teacher}%`); + } + if (filter.topic) { + sql += ' AND topic LIKE ?'; + params.push(`%${filter.topic}%`); + } + sql += ' ORDER BY parallel, class_name, time'; + return allQuery(sql, params); + }, + + getLessonById: (id) => { + return getQuery('SELECT * FROM lessons WHERE id = ?', [id]); + }, + + createLesson: (lesson) => { + const { class_name, parallel, subject, teacher, topic, max_slots, date, time } = lesson; + return runQuery( + `INSERT INTO lessons (class_name, parallel, subject, teacher, topic, max_slots, current_slots, date, time) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)`, + [class_name, parallel, subject, teacher, topic || '', max_slots, date, time] + ).then(result => result.lastID); + }, + + updateLesson: (id, lesson) => { + const { class_name, parallel, subject, teacher, topic, max_slots, date, time } = lesson; + return runQuery( + `UPDATE lessons SET class_name=?, parallel=?, subject=?, teacher=?, topic=?, max_slots=?, date=?, time=? WHERE id=?`, + [class_name, parallel, subject, teacher, topic || '', max_slots, date, time, id] + ); + }, + + deleteLesson: (id) => { + return runQuery('DELETE FROM lessons WHERE id = ?', [id]); + }, + + incrementCurrentSlots: (lessonId) => { + return runQuery( + `UPDATE lessons SET current_slots = current_slots + 1 WHERE id = ? AND current_slots < max_slots`, + [lessonId] + ); + }, + + addRegistration: (lessonId, parentName, parentPhone) => { + return runQuery( + `INSERT INTO registrations (lesson_id, parent_name, parent_phone) VALUES (?, ?, ?)`, + [lessonId, parentName, parentPhone] + ); + }, + + getRegistrationsByLesson: (lessonId) => { + return allQuery('SELECT * FROM registrations WHERE lesson_id = ? ORDER BY created_at DESC', [lessonId]); + }, + + getAllRegistrations: () => { + return allQuery(` + SELECT r.*, l.class_name, l.subject, l.teacher, l.date, l.time + FROM registrations r + JOIN lessons l ON r.lesson_id = l.id + ORDER BY r.created_at DESC + `); + }, + + // Новая функция для фильтрации записей на странице /info + getRegistrationsWithFilters: (filters) => { + let sql = ` + SELECT r.id, r.parent_name, r.parent_phone, r.created_at, + l.class_name, l.subject, l.teacher, l.topic, l.date, l.time + FROM registrations r + JOIN lessons l ON r.lesson_id = l.id + WHERE 1=1 + `; + const params = []; + if (filters.parent_name) { + sql += ' AND r.parent_name LIKE ?'; + params.push(`%${filters.parent_name}%`); + } + if (filters.class_name) { + sql += ' AND l.class_name LIKE ?'; + params.push(`%${filters.class_name}%`); + } + if (filters.subject) { + sql += ' AND l.subject LIKE ?'; + params.push(`%${filters.subject}%`); + } + if (filters.teacher) { + sql += ' AND l.teacher LIKE ?'; + params.push(`%${filters.teacher}%`); + } + sql += ' ORDER BY r.created_at DESC'; + return allQuery(sql, params); + } +}; \ No newline at end of file