From 3fbf7311d8c5825b6eb154a5897cad01fb9337a6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=9A=D0=B0=D0=BB=D1=83=D0=B3=D0=B8=D0=BD=20=D0=9E=D0=BB?=
=?UTF-8?q?=D0=B5=D0=B3=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?=
=?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?=
<134477kalugin66@users.no-reply.gitverse.ru>
Date: Sun, 12 Apr 2026 19:05:12 +0000
Subject: [PATCH] create: admin.html, admin.js, help.html, index.html,
info.html, info.js, login.html, main.js, style.css, auth.js, package.json,
server.js, sqllite.js
---
auth.js | 49 +++++++
package.json | 10 ++
public/admin.html | 97 +++++++++++++
public/admin.js | 322 ++++++++++++++++++++++++++++++++++++++++
public/help.html | 111 ++++++++++++++
public/index.html | 55 +++++++
public/info.html | 134 +++++++++++++++++
public/info.js | 151 +++++++++++++++++++
public/login.html | 53 +++++++
public/main.js | 246 +++++++++++++++++++++++++++++++
public/style.css | 247 +++++++++++++++++++++++++++++++
server.js | 363 ++++++++++++++++++++++++++++++++++++++++++++++
sqllite.js | 180 +++++++++++++++++++++++
13 files changed, 2018 insertions(+)
create mode 100644 auth.js
create mode 100644 package.json
create mode 100644 public/admin.html
create mode 100644 public/admin.js
create mode 100644 public/help.html
create mode 100644 public/index.html
create mode 100644 public/info.html
create mode 100644 public/info.js
create mode 100644 public/login.html
create mode 100644 public/main.js
create mode 100644 public/style.css
create mode 100644 server.js
create mode 100644 sqllite.js
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
+
+
+ Макс. мест (сколько родителей может записаться):
+
+
+ Предпросмотр
+
+
+
Предпросмотр (первые 20 записей)
+
+
+ Класс Предмет Учитель Тема
+
+
+
+
✅ Импортировать выбранные
+
+
+
+
+
+
+
+ ×
+
Редактирование урока
+
+
+
+
+
+
+
+
+
+
\ 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 = `${defaultLabel} `;
+ 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 ? '' + registrations.map(r => `${r.parent_name} — ${r.parent_phone} (${new Date(r.created_at).toLocaleString()}) `).join('') + ' ' : 'Нет записей
';
+ 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
+
Доступна после авторизации с правами администратора (локальный пользователь или группа из Администраторов).
+
+ ➕ Добавление, редактирование и удаление уроков.
+ 📋 Просмотр записей родителей на каждый урок.
+ 🔍 Фильтрация уроков по классу, учителю, теме, параллели.
+ 📂 Импорт уроков из JSON (структура: массив записей с полями "Класс (параллель)", "Класс (буква)", "Предмет", "Фамилия Учителя", "Имя Учителя", "Отчество Учителя", "Тема Урока").
+
+
+
+
+
📊 Список записавшихся родителей admin + Администрация
+
Ссылка: /info
+
Доступен администраторам и пользователям, входящим в группы, указанные в переменной окружения Администрация.
+
+ 📋 Таблица со всеми записями родителей (ФИО, телефон, класс, предмет, учитель, тема, дата/время урока, дата регистрации).
+ 🔎 Фильтрация по ФИО родителя, классу, предмету, учителю.
+ 📎 Выгрузка отфильтрованных данных в CSV (совместим с Excel).
+
+
+
+
+ Система записи на открытые уроки. При возникновении вопросов обращайтесь к Калугину О.А..
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+ Список записавшихся родителей
+
+
+
+
+
+ Список записавшихся родителей
+
+ Выйти
+
+
+
+
+ ФИО родителя
+
+
+
+ Класс
+
+ Все классы
+
+
+
+ Предмет
+
+ Все предметы
+
+
+
+ Учитель
+
+ Все учителя
+
+
+
Применить
+
Сбросить
+
📎 Выгрузить в Excel (CSV)
+
+
+
+
+
+
+ ФИО родителя
+ Телефон
+ Класс
+ Предмет
+ Учитель
+ Тема урока
+ Дата урока
+ Время
+ Дата регистрации
+
+
+
+ Загрузка...
+
+
+
+
+
+
+
\ 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 = `${defaultLabel} `;
+ 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 = `${defaultLabel} `;
+ 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