create: admin.html, admin.js, help.html, index.html, info.html, info.js, login.html, main.js, style.css, auth.js, package.json, server.js, sqllite.js

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

97
public/admin.html Normal file
View File

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

322
public/admin.js Normal file
View File

@@ -0,0 +1,322 @@
// public/admin.js панель администратора
let currentUser = null;
let currentPreviewLessons = [];
let allLessonsForFilters = []; // храним все уроки для построения зависимых фильтров
document.addEventListener('DOMContentLoaded', async () => {
await checkAuth();
await loadFilterOptions();
loadLessons();
setupEventListeners();
});
async function checkAuth() {
try {
const res = await fetch('/api/me');
const data = await res.json();
if (!data.authenticated || data.user.role !== 'admin') {
window.location.href = '/login.html';
return;
}
currentUser = data.user;
document.getElementById('userInfo').innerHTML = `👋 ${currentUser.full_name} (${currentUser.role})`;
} catch (err) {
window.location.href = '/login.html';
}
}
// Загрузка опций для выпадающих списков фильтров (начальные)
async function loadFilterOptions() {
try {
const [classes, teachers, topics] = await Promise.all([
fetch('/api/filter-options/class-names').then(r => r.json()),
fetch('/api/filter-options/teachers').then(r => r.json()),
fetch('/api/filter-options/topics').then(r => r.json())
]);
window.allClassNames = classes;
window.allTeachers = teachers;
window.allTopics = topics;
populateSelect('filterClass', window.allClassNames, 'Все классы');
populateSelect('filterTeacher', window.allTeachers, 'Все учителя');
populateSelect('filterTopic', window.allTopics, 'Все темы');
} catch (err) {
console.error('Ошибка загрузки опций фильтров', err);
}
}
function populateSelect(selectId, options, defaultLabel) {
const select = document.getElementById(selectId);
if (!select) return;
const currentValue = select.value;
select.innerHTML = `<option value="">${defaultLabel}</option>`;
options.forEach(opt => {
const option = document.createElement('option');
option.value = opt;
option.textContent = opt;
select.appendChild(option);
});
if (currentValue && options.includes(currentValue)) {
select.value = currentValue;
} else {
select.value = '';
}
}
// Обновление зависимых фильтров на основе текущих значений и allLessonsForFilters
function updateDependentFilters() {
const selectedClass = document.getElementById('filterClass').value;
const selectedTeacher = document.getElementById('filterTeacher').value;
const selectedTopic = document.getElementById('filterTopic').value;
let filteredLessons = allLessonsForFilters;
if (selectedClass) {
filteredLessons = filteredLessons.filter(l => l.class_name === selectedClass);
}
if (selectedTeacher) {
filteredLessons = filteredLessons.filter(l => l.teacher === selectedTeacher);
}
if (selectedTopic) {
filteredLessons = filteredLessons.filter(l => l.topic === selectedTopic);
}
const availableClasses = [...new Set(filteredLessons.map(l => l.class_name))].sort();
const availableTeachers = [...new Set(filteredLessons.map(l => l.teacher))].sort();
const availableTopics = [...new Set(filteredLessons.map(l => l.topic).filter(t => t))].sort();
const oldClass = document.getElementById('filterClass').value;
const oldTeacher = document.getElementById('filterTeacher').value;
const oldTopic = document.getElementById('filterTopic').value;
populateSelect('filterClass', availableClasses, 'Все классы');
populateSelect('filterTeacher', availableTeachers, 'Все учителя');
populateSelect('filterTopic', availableTopics, 'Все темы');
if (oldClass && availableClasses.includes(oldClass)) document.getElementById('filterClass').value = oldClass;
else document.getElementById('filterClass').value = '';
if (oldTeacher && availableTeachers.includes(oldTeacher)) document.getElementById('filterTeacher').value = oldTeacher;
else document.getElementById('filterTeacher').value = '';
if (oldTopic && availableTopics.includes(oldTopic)) document.getElementById('filterTopic').value = oldTopic;
else document.getElementById('filterTopic').value = '';
}
async function loadLessons(filters = {}) {
const params = new URLSearchParams(filters);
const res = await fetch(`/api/admin/lessons?${params}`);
const lessons = await res.json();
// Сохраняем полный список для фильтрации (без учёта фильтров)
allLessonsForFilters = lessons;
updateDependentFilters(); // обновить списки в select на основе всех уроков
const container = document.getElementById('lessonsList');
if (!container) return;
const grouped = {};
lessons.forEach(lesson => {
if (!grouped[lesson.class_name]) grouped[lesson.class_name] = [];
grouped[lesson.class_name].push(lesson);
});
container.innerHTML = '';
for (const [className, classLessons] of Object.entries(grouped)) {
const section = document.createElement('div');
section.className = 'class-group';
section.innerHTML = `<h2>${className}</h2>`;
classLessons.forEach(lesson => {
const div = document.createElement('div');
div.className = 'lesson-item';
div.innerHTML = `
<div>
<strong>${lesson.subject}</strong> — ${lesson.teacher}<br>
<em>Тема: ${lesson.topic || '—'}</em><br>
${lesson.date} ${lesson.time} | Места: ${lesson.current_slots}/${lesson.max_slots}
</div>
<div class="lesson-actions">
<button class="viewRegBtn" data-id="${lesson.id}">Записи</button>
<button class="editBtn" data-id="${lesson.id}">✏️</button>
<button class="deleteBtn danger" data-id="${lesson.id}">🗑️</button>
</div>
`;
section.appendChild(div);
});
container.appendChild(section);
}
document.querySelectorAll('.viewRegBtn').forEach(btn => btn.addEventListener('click', () => showRegistrations(btn.dataset.id)));
document.querySelectorAll('.editBtn').forEach(btn => btn.addEventListener('click', () => openLessonModal(btn.dataset.id)));
document.querySelectorAll('.deleteBtn').forEach(btn => btn.addEventListener('click', () => deleteLesson(btn.dataset.id)));
}
async function showRegistrations(lessonId) {
const res = await fetch(`/api/admin/registrations?lesson_id=${lessonId}`);
const registrations = await res.json();
const modal = document.getElementById('registrationsModal');
const listDiv = document.getElementById('registrationsList');
listDiv.innerHTML = registrations.length ? '<ul>' + registrations.map(r => `<li><strong>${r.parent_name}</strong> — ${r.parent_phone} (${new Date(r.created_at).toLocaleString()})</li>`).join('') + '</ul>' : '<p>Нет записей</p>';
modal.style.display = 'flex';
modal.querySelector('.close').onclick = () => modal.style.display = 'none';
}
async function deleteLesson(id) {
if (!confirm('Удалить урок? Все записи будут удалены.')) return;
await fetch(`/api/admin/lessons/${id}`, { method: 'DELETE' });
loadLessons(getCurrentFilters());
}
function openLessonModal(id = null) {
const modal = document.getElementById('lessonModal');
const form = document.getElementById('lessonForm');
form.reset();
document.getElementById('lessonId').value = '';
document.getElementById('modalTitle').innerText = id ? 'Редактирование урока' : 'Новый урок';
if (id) {
fetch(`/api/admin/lessons`).then(res => res.json()).then(lessons => {
const lesson = lessons.find(l => l.id == id);
if (lesson) {
document.getElementById('lessonId').value = lesson.id;
document.getElementById('className').value = lesson.class_name;
document.getElementById('parallel').value = lesson.parallel;
document.getElementById('subject').value = lesson.subject;
document.getElementById('teacher').value = lesson.teacher;
document.getElementById('topic').value = lesson.topic || '';
document.getElementById('maxSlots').value = lesson.max_slots;
document.getElementById('date').value = lesson.date;
document.getElementById('time').value = lesson.time;
}
});
}
modal.style.display = 'flex';
modal.querySelector('.close').onclick = () => modal.style.display = 'none';
}
document.getElementById('lessonForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('lessonId').value;
const payload = {
id: id || undefined,
class_name: document.getElementById('className').value,
parallel: parseInt(document.getElementById('parallel').value),
subject: document.getElementById('subject').value,
teacher: document.getElementById('teacher').value,
topic: document.getElementById('topic').value,
max_slots: parseInt(document.getElementById('maxSlots').value),
date: document.getElementById('date').value,
time: document.getElementById('time').value
};
await fetch('/api/admin/lessons', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
document.getElementById('lessonModal').style.display = 'none';
loadLessons(getCurrentFilters());
});
function getCurrentFilters() {
return {
class_name: document.getElementById('filterClass').value,
parallel: document.getElementById('filterParallel').value,
teacher: document.getElementById('filterTeacher').value,
topic: document.getElementById('filterTopic').value
};
}
function setupEventListeners() {
const classSelect = document.getElementById('filterClass');
const teacherSelect = document.getElementById('filterTeacher');
const topicSelect = document.getElementById('filterTopic');
function handleFilterChange() {
// Обновить зависимые списки, затем применить фильтрацию списка уроков
updateDependentFilters();
loadLessons(getCurrentFilters());
}
classSelect?.addEventListener('change', handleFilterChange);
teacherSelect?.addEventListener('change', handleFilterChange);
topicSelect?.addEventListener('change', handleFilterChange);
document.getElementById('applyFilters')?.addEventListener('click', () => {
loadLessons(getCurrentFilters());
});
document.getElementById('resetFilters')?.addEventListener('click', () => {
document.getElementById('filterClass').value = '';
document.getElementById('filterParallel').value = '';
document.getElementById('filterTeacher').value = '';
document.getElementById('filterTopic').value = '';
updateDependentFilters();
loadLessons({});
});
document.getElementById('addLessonBtn')?.addEventListener('click', () => openLessonModal());
document.getElementById('logoutBtn')?.addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/';
});
// Импорт JSON (без изменений)
document.getElementById('previewImportBtn')?.addEventListener('click', async () => {
const file = document.getElementById('jsonFileInput').files[0];
if (!file) return alert('Выберите JSON-файл');
const importMaxSlots = document.getElementById('importMaxSlots').value;
if (!importMaxSlots) return alert('Укажите максимальное количество мест');
const text = await file.text();
try {
const response = await fetch('/api/admin/import/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonData: JSON.parse(text) })
});
const data = await response.json();
if (data.success) {
currentPreviewLessons = data.preview;
const tbody = document.querySelector('#previewTable tbody');
tbody.innerHTML = '';
data.preview.slice(0,20).forEach(lesson => {
const row = tbody.insertRow();
row.insertCell(0).innerText = lesson.className;
row.insertCell(1).innerText = lesson.subject;
row.insertCell(2).innerText = lesson.teacher;
row.insertCell(3).innerText = lesson.topic || '';
});
if (data.preview.length > 20) {
const row = tbody.insertRow();
const cell = row.insertCell(0);
cell.colSpan = 4;
cell.innerText = `... и ещё ${data.preview.length - 20} записей`;
}
document.getElementById('importPreview').style.display = 'block';
} else {
alert('Ошибка предпросмотра: ' + (data.error || ''));
}
} catch (err) {
alert('Ошибка при разборе JSON: ' + err.message);
}
});
document.getElementById('confirmImportBtn')?.addEventListener('click', async () => {
if (!confirm(`Импортировать ${currentPreviewLessons.length} уроков?`)) return;
const importMaxSlots = document.getElementById('importMaxSlots').value;
const response = await fetch('/api/admin/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lessons: currentPreviewLessons,
defaultMaxSlots: importMaxSlots
})
});
const result = await response.json();
if (result.success) {
const created = result.results.filter(r => r.status === 'created').length;
const skipped = result.results.filter(r => r.status === 'skipped').length;
document.getElementById('importResult').innerHTML = `<span style="color:green">Импорт завершён: создано ${created}, пропущено (дубликаты) ${skipped}</span>`;
loadLessons(getCurrentFilters());
document.getElementById('importPreview').style.display = 'none';
} else {
alert('Ошибка импорта: ' + (result.error || ''));
}
});
}

111
public/help.html Normal file
View File

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

55
public/index.html Normal file
View File

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

134
public/info.html Normal file
View File

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

151
public/info.js Normal file
View File

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

53
public/login.html Normal file
View File

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

246
public/main.js Normal file
View File

@@ -0,0 +1,246 @@
// public/main.js страница записи родителей
let allLessons = [];
// Загрузка опций для выпадающих списков
async function loadFilterOptions() {
try {
const [classes, teachers, topics] = await Promise.all([
fetch('/api/filter-options/class-names').then(r => r.json()),
fetch('/api/filter-options/teachers').then(r => r.json()),
fetch('/api/filter-options/topics').then(r => r.json())
]);
// Сохраняем полные списки для дальнейшей фильтрации
window.allClassNames = classes;
window.allTeachers = teachers;
window.allTopics = topics;
populateSelect('filterClass', window.allClassNames, 'Все классы');
populateSelect('filterTeacher', window.allTeachers, 'Все учителя');
populateSelect('filterTopic', window.allTopics, 'Все темы');
} catch (err) {
console.error('Ошибка загрузки опций фильтров', err);
}
}
function populateSelect(selectId, options, defaultLabel) {
const select = document.getElementById(selectId);
if (!select) return;
const currentValue = select.value;
select.innerHTML = `<option value="">${defaultLabel}</option>`;
options.forEach(opt => {
const option = document.createElement('option');
option.value = opt;
option.textContent = opt;
select.appendChild(option);
});
// Восстанавливаем выбранное значение, если оно ещё допустимо
if (currentValue && options.includes(currentValue)) {
select.value = currentValue;
} else {
select.value = '';
}
}
// Загрузка всех уроков с сервера
async function loadLessons() {
try {
const res = await fetch('/api/lessons');
allLessons = await res.json();
updateDependentFilters(); // первоначальное построение зависимых списков
applyFilters();
} catch (err) {
console.error('Ошибка загрузки уроков', err);
document.getElementById('lessonsContainer').innerHTML = '<p>Ошибка загрузки данных</p>';
}
}
// Обновление зависимых выпадающих списков на основе текущих фильтров
function updateDependentFilters() {
const selectedClass = document.getElementById('filterClass').value;
const selectedTeacher = document.getElementById('filterTeacher').value;
const selectedTopic = document.getElementById('filterTopic').value;
// Фильтруем уроки по выбранным значениям (если они не пустые)
let filteredLessons = allLessons;
if (selectedClass) {
filteredLessons = filteredLessons.filter(l => l.class_name === selectedClass);
}
if (selectedTeacher) {
filteredLessons = filteredLessons.filter(l => l.teacher === selectedTeacher);
}
if (selectedTopic) {
filteredLessons = filteredLessons.filter(l => l.topic === selectedTopic);
}
// Извлекаем уникальные значения для каждого поля
const availableClasses = [...new Set(filteredLessons.map(l => l.class_name))].sort();
const availableTeachers = [...new Set(filteredLessons.map(l => l.teacher))].sort();
const availableTopics = [...new Set(filteredLessons.map(l => l.topic).filter(t => t))].sort();
// Обновляем select, сохраняя текущие значения, если они допустимы
const classSelect = document.getElementById('filterClass');
const teacherSelect = document.getElementById('filterTeacher');
const topicSelect = document.getElementById('filterTopic');
const oldClass = classSelect.value;
const oldTeacher = teacherSelect.value;
const oldTopic = topicSelect.value;
populateSelect('filterClass', availableClasses, 'Все классы');
populateSelect('filterTeacher', availableTeachers, 'Все учителя');
populateSelect('filterTopic', availableTopics, 'Все темы');
// Если старое значение не было сброшено populateSelect, восстанавливаем
if (oldClass && availableClasses.includes(oldClass)) classSelect.value = oldClass;
else classSelect.value = '';
if (oldTeacher && availableTeachers.includes(oldTeacher)) teacherSelect.value = oldTeacher;
else teacherSelect.value = '';
if (oldTopic && availableTopics.includes(oldTopic)) topicSelect.value = oldTopic;
else topicSelect.value = '';
}
// Применение фильтров + скрытие уроков без свободных мест
function applyFilters() {
const classFilter = document.getElementById('filterClass').value;
const teacherFilter = document.getElementById('filterTeacher').value;
const topicFilter = document.getElementById('filterTopic').value;
// Сначала отбираем только доступные уроки (есть свободные места)
let filtered = allLessons.filter(lesson => lesson.available === true);
// Точное совпадение для select (не частичное)
if (classFilter) filtered = filtered.filter(lesson => lesson.class_name === classFilter);
if (teacherFilter) filtered = filtered.filter(lesson => lesson.teacher === teacherFilter);
if (topicFilter) filtered = filtered.filter(lesson => lesson.topic === topicFilter);
renderLessons(filtered);
}
// Отрисовка карточек уроков + обновление счётчика
function renderLessons(lessons) {
const container = document.getElementById('lessonsContainer');
const counterSpan = document.getElementById('availableCount');
if (!container) return;
if (lessons.length === 0) {
container.innerHTML = '<p style="text-align:center; grid-column:1/-1;">Нет доступных уроков</p>';
if (counterSpan) counterSpan.textContent = '0';
return;
}
container.innerHTML = lessons.map(lesson => `
<div class="lesson-card" data-id="${lesson.id}">
<h3>${escapeHtml(lesson.class_name)} | ${escapeHtml(lesson.subject)}</h3>
<p><strong>Учитель:</strong> ${escapeHtml(lesson.teacher)}</p>
<p><strong>Тема:</strong> ${escapeHtml(lesson.topic || '—')}</p>
<div class="slots">
Свободных мест: ${lesson.max_slots - lesson.current_slots} из ${lesson.max_slots}
</div>
</div>
`).join('');
if (counterSpan) counterSpan.textContent = `${lessons.length}`;
document.querySelectorAll('.lesson-card').forEach(card => {
card.addEventListener('click', () => openModal(card.dataset.id));
});
}
// Модальное окно записи (без изменений)
function setupModal() {
const modal = document.getElementById('modal');
const closeSpan = modal.querySelector('.close');
const form = document.getElementById('registrationForm');
closeSpan.onclick = () => modal.style.display = 'none';
window.onclick = (e) => { if (e.target === modal) modal.style.display = 'none'; };
form.addEventListener('submit', async (e) => {
e.preventDefault();
const lessonId = document.getElementById('lessonId').value;
const parentName = document.getElementById('parentName').value.trim();
const parentPhone = document.getElementById('parentPhone').value.trim();
const messageDiv = document.getElementById('modalMessage');
if (!parentName || !parentPhone) {
messageDiv.innerHTML = '<span style="color:red">Заполните все поля</span>';
return;
}
try {
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lesson_id: lessonId, parent_name: parentName, parent_phone: parentPhone })
});
const data = await res.json();
if (res.ok) {
messageDiv.innerHTML = '<span style="color:green">✅ Вы успешно записаны!</span>';
setTimeout(() => {
modal.style.display = 'none';
loadLessons();
}, 1500);
} else {
messageDiv.innerHTML = `<span style="color:red">${data.error || 'Ошибка'}</span>`;
}
} catch (err) {
messageDiv.innerHTML = '<span style="color:red">Ошибка сервера</span>';
}
});
}
function openModal(lessonId) {
const modal = document.getElementById('modal');
const lesson = allLessons.find(l => l.id == lessonId);
if (!lesson) return;
document.getElementById('lessonId').value = lessonId;
document.getElementById('modalLessonInfo').innerHTML = `
<strong>${escapeHtml(lesson.class_name)}</strong><br>
${escapeHtml(lesson.subject)}${escapeHtml(lesson.teacher)}<br>
<small>${lesson.date} ${lesson.time}</small>
`;
document.getElementById('modalMessage').innerHTML = '';
document.getElementById('registrationForm').reset();
modal.style.display = 'flex';
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
// Инициализация
document.addEventListener('DOMContentLoaded', async () => {
await loadFilterOptions();
await loadLessons();
setupModal();
// Обработчики изменений фильтров
const classSelect = document.getElementById('filterClass');
const teacherSelect = document.getElementById('filterTeacher');
const topicSelect = document.getElementById('filterTopic');
function handleFilterChange() {
updateDependentFilters();
applyFilters();
}
classSelect?.addEventListener('change', handleFilterChange);
teacherSelect?.addEventListener('change', handleFilterChange);
topicSelect?.addEventListener('change', handleFilterChange);
document.getElementById('resetFilter')?.addEventListener('click', () => {
classSelect.value = '';
teacherSelect.value = '';
topicSelect.value = '';
updateDependentFilters();
applyFilters();
});
});

247
public/style.css Normal file
View File

@@ -0,0 +1,247 @@
* {
box-sizing: border-box;
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
}
body {
margin: 0;
background: #f5f7fb;
color: #1e293b;
}
header {
background: white;
padding: 1rem 2rem;
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
h1 { margin: 0; font-size: 1.5rem; }
.lessons-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
padding: 2rem;
}
.lesson-card {
background: white;
border-radius: 1rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
padding: 1.2rem;
transition: transform 0.1s ease;
cursor: pointer;
border: 1px solid #e2e8f0;
}
.lesson-card:hover {
transform: translateY(-4px);
border-color: #cbd5e1;
}
.lesson-card.disabled {
opacity: 0.6;
cursor: not-allowed;
background: #f1f5f9;
}
.lesson-card h3 { margin-top: 0; color: #0f172a; }
.lesson-card p { margin: 0.5rem 0; color: #334155; }
.slots {
font-weight: bold;
margin-top: 0.8rem;
color: #2d6a4f;
}
.slots.full { color: #b91c1c; }
.filters {
margin: 1rem 2rem;
display: flex;
gap: 0.8rem;
flex-wrap: wrap;
}
.filters input, .filters button {
padding: 0.5rem 1rem;
border-radius: 2rem;
border: 1px solid #cbd5e1;
background: white;
}
button {
background: #1e3a8a;
color: white;
border: none;
padding: 0.5rem 1.2rem;
border-radius: 2rem;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
button:hover { background: #1e40af; }
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
justify-content: center;
align-items: center;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 1.5rem;
width: 90%;
max-width: 500px;
position: relative;
}
.modal-content.large { max-width: 800px; }
.close {
position: absolute;
right: 1.5rem;
top: 1rem;
font-size: 1.8rem;
cursor: pointer;
color: #64748b;
}
form label {
display: block;
margin: 1rem 0 0.3rem;
font-weight: 500;
}
form input, form textarea {
width: 100%;
padding: 0.6rem;
border: 1px solid #cbd5e1;
border-radius: 0.8rem;
}
.lessons-list .lesson-item {
background: white;
margin: 1rem 2rem;
padding: 1rem;
border-radius: 1rem;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
}
.lesson-actions button {
margin-left: 0.5rem;
background: #475569;
}
.lesson-actions button.danger { background: #b91c1c; }
.import-section {
background: #f9fafb;
padding: 1rem;
border-radius: 1rem;
margin: 1rem 2rem;
}
#importPreview table {
width: 100%;
border-collapse: collapse;
background: white;
}
#importPreview th, #importPreview td {
padding: 8px;
border: 1px solid #ddd;
}
@media (max-width: 640px) {
.lessons-grid { padding: 1rem; }
.filters { margin: 1rem; }
}
.filters {
display: flex;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
}
#availableCount {
margin-left: auto;
white-space: nowrap;
}
/* Группа фильтров */
.filters {
display: flex;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
background: white;
padding: 1rem 1.5rem;
border-radius: 1.5rem;
margin: 1rem 2rem;
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 150px;
max-width: 450px;
}
.filter-group label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #475569;
}
.filter-group select,
.filter-group input {
padding: 0.5rem 1rem;
border-radius: 2rem;
border: 1px solid #cbd5e1;
background-color: white;
font-size: 0.9rem;
cursor: pointer;
transition: 0.2s;
}
.filter-group select:hover,
.filter-group input:hover {
border-color: #94a3b8;
}
.filter-group select:focus,
.filter-group input:focus {
outline: none;
border-color: #1e3a8a;
box-shadow: 0 0 0 2px rgba(30,58,138,0.2);
}
.reset-btn {
background: #f1f5f9;
color: #1e293b;
border: 1px solid #cbd5e1;
}
.reset-btn:hover {
background: #e2e8f0;
border-color: #94a3b8;
}
.lessons-count {
background: #e2e8f0;
padding: 0.4rem 1rem;
border-radius: 2rem;
font-weight: bold;
font-size: 0.9rem;
margin-left: auto;
white-space: nowrap;
}
/* Адаптивность */
@media (max-width: 640px) {
.filters {
flex-direction: column;
align-items: stretch;
margin: 1rem;
}
.filter-group {
min-width: auto;
}
.lessons-count {
margin-left: 0;
text-align: center;
}
}