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:
committed by
GitVerse
parent
458b1fa927
commit
3fbf7311d8
49
auth.js
Normal file
49
auth.js
Normal 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
10
package.json
Normal 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
97
public/admin.html
Normal 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">×</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">×</span>
|
||||||
|
<h3>Записи на урок</h3>
|
||||||
|
<div id="registrationsList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
322
public/admin.js
Normal file
322
public/admin.js
Normal 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
111
public/help.html
Normal 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
55
public/index.html
Normal 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">×</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
134
public/info.html
Normal 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
151
public/info.js
Normal 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 '&';
|
||||||
|
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 = '/';
|
||||||
|
});
|
||||||
|
}
|
||||||
53
public/login.html
Normal file
53
public/login.html
Normal 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
246
public/main.js
Normal 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 '&';
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
247
public/style.css
Normal file
247
public/style.css
Normal 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
363
server.js
Normal 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
180
sqllite.js
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user