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