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

This commit is contained in:
Калугин Олег Александрович
2026-04-12 19:05:12 +00:00
committed by GitVerse
parent 458b1fa927
commit 3fbf7311d8
13 changed files with 2018 additions and 0 deletions

49
auth.js Normal file
View File

@@ -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
};

10
package.json Normal file
View File

@@ -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"
}
}

97
public/admin.html Normal file
View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Управление уроками</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>Панель администратора</h1>
<div id="userInfo"></div>
<button id="logoutBtn">Выйти</button>
</header>
<main>
<div class="admin-controls">
<button id="addLessonBtn">+ Добавить урок</button>
<div class="filters">
<div class="filter-group">
<label>Класс</label>
<select id="filterClass">
<option value="">Все классы</option>
</select>
</div>
<div class="filter-group">
<label>Учитель</label>
<select id="filterTeacher">
<option value="">Все учителя</option>
</select>
</div>
<div class="filter-group">
<label>Тема урока</label>
<select id="filterTopic">
<option value="">Все темы</option>
</select>
</div>
<input type="text" id="filterParallel" placeholder="Параллель (цифра)">
<button id="applyFilters">Применить</button>
<button id="resetFilters" class="reset-btn">Сбросить</button>
</div>
</div>
<div id="lessonsList" class="lessons-list"></div>
<!-- Импорт JSON (без даты/времени) -->
<div class="import-section">
<h3>Импорт уроков из JSON</h3>
<input type="file" id="jsonFileInput" accept=".json">
<div class="import-params">
<label>Макс. мест (сколько родителей может записаться):
<input type="number" id="importMaxSlots" value="4" required>
</label>
<button id="previewImportBtn">Предпросмотр</button>
</div>
<div id="importPreview" style="display:none;">
<h4>Предпросмотр (первые 20 записей)</h4>
<div style="overflow-x:auto;">
<table id="previewTable" border="1" cellpadding="5">
<thead><tr><th>Класс</th><th>Предмет</th><th>Учитель</th><th>Тема</th></tr></thead>
<tbody></tbody>
</table>
</div>
<button id="confirmImportBtn" style="margin-top:10px;">✅ Импортировать выбранные</button>
<p id="importResult"></p>
</div>
</div>
<!-- Модалка урока -->
<div id="lessonModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h3 id="modalTitle">Редактирование урока</h3>
<form id="lessonForm">
<input type="hidden" id="lessonId">
<label>Класс: <input type="text" id="className" required></label>
<label>Параллель (цифра): <input type="number" id="parallel" required></label>
<label>Предмет: <input type="text" id="subject" required></label>
<label>Учитель: <input type="text" id="teacher" required></label>
<label>Тема урока: <input type="text" id="topic"></label>
<label>Макс. мест: <input type="number" id="maxSlots" required></label>
<label>Дата: <input type="date" id="date" required></label>
<label>Время: <input type="time" id="time" required></label>
<button type="submit">Сохранить</button>
</form>
</div>
</div>
<!-- Модалка записей -->
<div id="registrationsModal" class="modal">
<div class="modal-content large">
<span class="close">&times;</span>
<h3>Записи на урок</h3>
<div id="registrationsList"></div>
</div>
</div>
</main>
<script src="admin.js"></script>
</body>
</html>

322
public/admin.js Normal file
View File

@@ -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 = `<option value="">${defaultLabel}</option>`;
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 = `<h2>${className}</h2>`;
classLessons.forEach(lesson => {
const div = document.createElement('div');
div.className = 'lesson-item';
div.innerHTML = `
<div>
<strong>${lesson.subject}</strong> — ${lesson.teacher}<br>
<em>Тема: ${lesson.topic || '—'}</em><br>
${lesson.date} ${lesson.time} | Места: ${lesson.current_slots}/${lesson.max_slots}
</div>
<div class="lesson-actions">
<button class="viewRegBtn" data-id="${lesson.id}">Записи</button>
<button class="editBtn" data-id="${lesson.id}">✏️</button>
<button class="deleteBtn danger" data-id="${lesson.id}">🗑️</button>
</div>
`;
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 ? '<ul>' + registrations.map(r => `<li><strong>${r.parent_name}</strong> — ${r.parent_phone} (${new Date(r.created_at).toLocaleString()})</li>`).join('') + '</ul>' : '<p>Нет записей</p>';
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 = `<span style="color:green">Импорт завершён: создано ${created}, пропущено (дубликаты) ${skipped}</span>`;
loadLessons(getCurrentFilters());
document.getElementById('importPreview').style.display = 'none';
} else {
alert('Ошибка импорта: ' + (result.error || ''));
}
});
}

111
public/help.html Normal file
View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Справка по системе</title>
<link rel="stylesheet" href="style.css">
<style>
.help-container {
max-width: 1000px;
margin: 2rem auto;
padding: 0 1rem;
}
.card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.card h2 {
margin-top: 0;
color: #1e3a8a;
}
.card a {
color: #1e3a8a;
text-decoration: none;
font-weight: bold;
}
.card a:hover {
text-decoration: underline;
}
.badge {
display: inline-block;
background: #e2e8f0;
padding: 0.2rem 0.6rem;
border-radius: 1rem;
font-size: 0.8rem;
margin-left: 0.5rem;
}
.note {
background: #fef9c3;
padding: 1rem;
border-radius: 0.8rem;
margin-top: 1rem;
}
footer {
text-align: center;
margin-top: 2rem;
color: #64748b;
}
</style>
</head>
<body>
<header>
<h1>Справка по системе</h1>
<div id="userInfo"></div>
<button id="logoutBtn" style="display:none;">Выйти</button>
</header>
<main class="help-container">
<div class="card">
<h2>📅 Запись на открытые уроки <span class="badge">доступ всем</span></h2>
<p><strong>Ссылка:</strong> <a href="/" target="_blank">/</a> (главная страница)</p>
<p>Родители могут просматривать доступные уроки, фильтровать их по классу, учителю или теме, и записываться, указав свои ФИО и телефон. После записи количество свободных мест уменьшается.</p>
<p>Уроки, на которых нет свободных мест, не отображаются.</p>
</div>
<div class="card">
<h2>🔐 Панель администратора <span class="badge">только admin</span></h2>
<p><strong>Ссылка:</strong> <a href="/admin" target="_blank">/admin</a></p>
<p>Доступна после авторизации с правами администратора (локальный пользователь или группа из <code>Администраторов</code>).</p>
<ul>
<li> Добавление, редактирование и удаление уроков.</li>
<li>📋 Просмотр записей родителей на каждый урок.</li>
<li>🔍 Фильтрация уроков по классу, учителю, теме, параллели.</li>
<li>📂 Импорт уроков из JSON (структура: массив записей с полями "Класс (параллель)", "Класс (буква)", "Предмет", "Фамилия Учителя", "Имя Учителя", "Отчество Учителя", "Тема Урока").</li>
</ul>
</div>
<div class="card">
<h2>📊 Список записавшихся родителей <span class="badge">admin + Администрация</span></h2>
<p><strong>Ссылка:</strong> <a href="/info" target="_blank">/info</a></p>
<p>Доступен администраторам и пользователям, входящим в группы, указанные в переменной окружения <code>Администрация</code>.</p>
<ul>
<li>📋 Таблица со всеми записями родителей (ФИО, телефон, класс, предмет, учитель, тема, дата/время урока, дата регистрации).</li>
<li>🔎 Фильтрация по ФИО родителя, классу, предмету, учителю.</li>
<li>📎 Выгрузка отфильтрованных данных в CSV (совместим с Excel).</li>
</ul>
</div>
<footer>
Система записи на открытые уроки. При возникновении вопросов обращайтесь к Калугину О.А..
</footer>
</main>
<script>
// Проверяем, авторизован ли пользователь, чтобы показать кнопку выхода
fetch('/api/me').then(res => res.json()).then(data => {
if (data.authenticated) {
const userInfo = document.getElementById('userInfo');
const logoutBtn = document.getElementById('logoutBtn');
userInfo.innerHTML = `👋 ${data.user.full_name} (${data.user.role})`;
logoutBtn.style.display = 'inline-block';
logoutBtn.onclick = async () => {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/';
};
}
});
</script>
</body>
</html>

55
public/index.html Normal file
View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Запись на открытые уроки</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>День открытых дверей</h1>
<p>Выберите урок, на который хотите записаться</p>
</header>
<main>
<div class="filters">
<div class="filter-group">
<label>Класс</label>
<select id="filterClass">
<option value="">Все классы</option>
</select>
</div>
<div class="filter-group">
<label>Учитель</label>
<select id="filterTeacher">
<option value="">Все учителя</option>
</select>
</div>
<div class="filter-group">
<label>Тема урока</label>
<select id="filterTopic">
<option value="">Все темы</option>
</select>
</div>
<button id="resetFilter" class="reset-btn">Сбросить фильтры</button>
<span id="availableCount" class="lessons-count"></span>
</div>
<div id="lessonsContainer" class="lessons-grid"></div>
</main>
<div id="modal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h3>Запись на урок</h3>
<p id="modalLessonInfo"></p>
<form id="registrationForm">
<input type="hidden" id="lessonId">
<label>Ваше ФИО: <input type="text" id="parentName" required></label>
<label>Номер телефона: <input type="tel" id="parentPhone" required></label>
<button type="submit">Записаться</button>
</form>
<div id="modalMessage"></div>
</div>
</div>
<script src="main.js"></script>
</body>
</html>

134
public/info.html Normal file
View File

@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Список записавшихся родителей</title>
<link rel="stylesheet" href="style.css">
<style>
.info-container {
padding: 2rem;
}
.filters-info {
background: white;
padding: 1.5rem;
border-radius: 1rem;
margin-bottom: 2rem;
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 180px;
}
.filter-group label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: #475569;
}
.filter-group input, .filter-group select {
padding: 0.5rem 0.8rem;
border-radius: 0.5rem;
border: 1px solid #cbd5e1;
}
.registrations-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 1rem;
overflow: auto;
display: block;
}
.registrations-table th, .registrations-table td {
border: 1px solid #e2e8f0;
padding: 0.75rem;
text-align: left;
}
.registrations-table th {
background: #f1f5f9;
font-weight: 600;
}
.export-btn {
background: #2d6a4f;
margin-left: auto;
}
.header-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.reset-btn {
background: #f1f5f9;
color: #1e293b;
border: 1px solid #cbd5e1;
}
</style>
</head>
<body>
<header>
<h1>Список записавшихся родителей</h1>
<div id="userInfo"></div>
<button id="logoutBtn">Выйти</button>
</header>
<main class="info-container">
<div class="filters-info">
<div class="filter-group">
<label>ФИО родителя</label>
<input type="text" id="filterParentName" placeholder="Введите имя">
</div>
<div class="filter-group">
<label>Класс</label>
<select id="filterClass">
<option value="">Все классы</option>
</select>
</div>
<div class="filter-group">
<label>Предмет</label>
<select id="filterSubject">
<option value="">Все предметы</option>
</select>
</div>
<div class="filter-group">
<label>Учитель</label>
<select id="filterTeacher">
<option value="">Все учителя</option>
</select>
</div>
<button id="applyFiltersBtn" class="primary">Применить</button>
<button id="resetFiltersBtn" class="reset-btn">Сбросить</button>
<button id="exportBtn" class="export-btn">📎 Выгрузить в Excel (CSV)</button>
</div>
<div class="header-actions">
<h2>Все записи</h2>
<span id="recordsCount"></span>
</div>
<div style="overflow-x: auto;">
<table class="registrations-table" id="registrationsTable">
<thead>
<tr>
<th>ФИО родителя</th>
<th>Телефон</th>
<th>Класс</th>
<th>Предмет</th>
<th>Учитель</th>
<th>Тема урока</th>
<th>Дата урока</th>
<th>Время</th>
<th>Дата регистрации</th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="9">Загрузка...</td></tr>
</tbody>
</table>
</div>
</main>
<script src="info.js"></script>
</body>
</html>

151
public/info.js Normal file
View File

@@ -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 = `<option value="">${defaultLabel}</option>`;
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 = '<tr><td colspan="9">Ошибка загрузки</td></tr>';
}
}
function renderTable(registrations) {
const tbody = document.getElementById('tableBody');
if (!registrations.length) {
tbody.innerHTML = '<tr><td colspan="9">Нет записей</td></tr>';
return;
}
tbody.innerHTML = registrations.map(reg => `
<tr>
<td>${escapeHtml(reg.parent_name)}</td>
<td>${escapeHtml(reg.parent_phone)}</td>
<td>${escapeHtml(reg.class_name)}</td>
<td>${escapeHtml(reg.subject)}</td>
<td>${escapeHtml(reg.teacher)}</td>
<td>${escapeHtml(reg.topic || '—')}</td>
<td>${escapeHtml(reg.date)}</td>
<td>${escapeHtml(reg.time)}</td>
<td>${new Date(reg.created_at).toLocaleString()}</td>
</tr>
`).join('');
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
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 = '/';
});
}

53
public/login.html Normal file
View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Вход в админку</title>
<link rel="stylesheet" href="style.css">
<style>
.login-container {
max-width: 400px;
margin: 4rem auto;
background: white;
padding: 2rem;
border-radius: 1.5rem;
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
}
.error { color: #b91c1c; margin-top: 1rem; }
</style>
</head>
<body>
<div class="login-container">
<h2>Авторизация администратора</h2>
<form id="loginForm">
<label>Логин: <input type="text" id="username" required></label>
<label>Пароль: <input type="password" id="password" required></label>
<button type="submit">Войти</button>
<div id="errorMsg" class="error"></div>
</form>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorDiv = document.getElementById('errorMsg');
try {
const res = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.ok && data.success) {
window.location.href = '/admin';
} else {
errorDiv.innerText = data.message || 'Ошибка входа';
}
} catch (err) {
errorDiv.innerText = 'Ошибка соединения';
}
});
</script>
</body>
</html>

246
public/main.js Normal file
View File

@@ -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 = `<option value="">${defaultLabel}</option>`;
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 = '<p>Ошибка загрузки данных</p>';
}
}
// Обновление зависимых выпадающих списков на основе текущих фильтров
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 = '<p style="text-align:center; grid-column:1/-1;">Нет доступных уроков</p>';
if (counterSpan) counterSpan.textContent = '0';
return;
}
container.innerHTML = lessons.map(lesson => `
<div class="lesson-card" data-id="${lesson.id}">
<h3>${escapeHtml(lesson.class_name)} | ${escapeHtml(lesson.subject)}</h3>
<p><strong>Учитель:</strong> ${escapeHtml(lesson.teacher)}</p>
<p><strong>Тема:</strong> ${escapeHtml(lesson.topic || '—')}</p>
<div class="slots">
Свободных мест: ${lesson.max_slots - lesson.current_slots} из ${lesson.max_slots}
</div>
</div>
`).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 = '<span style="color:red">Заполните все поля</span>';
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 = '<span style="color:green">✅ Вы успешно записаны!</span>';
setTimeout(() => {
modal.style.display = 'none';
loadLessons();
}, 1500);
} else {
messageDiv.innerHTML = `<span style="color:red">${data.error || 'Ошибка'}</span>`;
}
} catch (err) {
messageDiv.innerHTML = '<span style="color:red">Ошибка сервера</span>';
}
});
}
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 = `
<strong>${escapeHtml(lesson.class_name)}</strong><br>
${escapeHtml(lesson.subject)}${escapeHtml(lesson.teacher)}<br>
<small>${lesson.date} ${lesson.time}</small>
`;
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 '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
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();
});
});

247
public/style.css Normal file
View File

@@ -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;
}
}

363
server.js Normal file
View File

@@ -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}`);
});

180
sqllite.js Normal file
View File

@@ -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);
}
};