449 lines
21 KiB
JavaScript
449 lines
21 KiB
JavaScript
// public/admin.js – панель администратора (локальная XLSX)
|
||
|
||
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 = '';
|
||
}
|
||
}
|
||
|
||
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();
|
||
|
||
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 => {
|
||
let dateTimeStr = '';
|
||
if (lesson.topic === 'Консультация' && lesson.date && lesson.time) {
|
||
dateTimeStr = `${lesson.date} ${lesson.time}`;
|
||
} else {
|
||
dateTimeStr = 'Согласно расписанию';
|
||
}
|
||
const div = document.createElement('div');
|
||
div.className = 'lesson-item';
|
||
div.innerHTML = `
|
||
<div>
|
||
<strong>${lesson.subject}</strong> — ${lesson.teacher}<br>
|
||
<em>Тема: ${lesson.topic || '—'}</em><br>
|
||
${dateTimeStr} | Мест свободно: ${lesson.max_slots - lesson.current_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('parallel').value = lesson.parallel;
|
||
// Извлекаем букву из class_name (например, "10А" → "А")
|
||
const match = lesson.class_name.match(/[А-Яа-я]+$/);
|
||
const classLetter = match ? match[0] : '';
|
||
document.getElementById('classLetter').value = classLetter;
|
||
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 parallel = parseInt(document.getElementById('parallel').value);
|
||
const classLetter = document.getElementById('classLetter').value.trim();
|
||
const class_name = parallel + classLetter; // формируем полное имя класса
|
||
const subject = document.getElementById('subject').value.trim();
|
||
const teacher = document.getElementById('teacher').value.trim();
|
||
const topic = document.getElementById('topic').value;
|
||
const max_slots = parseInt(document.getElementById('maxSlots').value);
|
||
const date = document.getElementById('date').value;
|
||
const time = document.getElementById('time').value;
|
||
|
||
if (!class_name || isNaN(parallel) || !classLetter || !subject || !teacher || isNaN(max_slots) || !date || !time) {
|
||
alert('Пожалуйста, заполните все поля: параллель, букву класса, предмет, учителя, макс. мест, дату и время.');
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
id: id || undefined,
|
||
class_name,
|
||
parallel,
|
||
subject,
|
||
teacher,
|
||
topic,
|
||
max_slots,
|
||
date,
|
||
time
|
||
};
|
||
|
||
try {
|
||
const response = await fetch('/api/admin/lessons', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
alert('Ошибка сохранения: ' + (data.error || 'Неизвестная ошибка'));
|
||
return;
|
||
}
|
||
document.getElementById('lessonModal').style.display = 'none';
|
||
resetFilters();
|
||
loadLessons(getCurrentFilters());
|
||
} catch (err) {
|
||
alert('Ошибка соединения: ' + err.message);
|
||
}
|
||
});
|
||
|
||
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 resetFilters() {
|
||
document.getElementById('filterClass').value = '';
|
||
document.getElementById('filterParallel').value = '';
|
||
document.getElementById('filterTeacher').value = '';
|
||
document.getElementById('filterTopic').value = '';
|
||
updateDependentFilters();
|
||
}
|
||
|
||
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', () => {
|
||
resetFilters();
|
||
loadLessons({});
|
||
});
|
||
|
||
document.getElementById('addLessonBtn')?.addEventListener('click', () => openLessonModal());
|
||
document.getElementById('logoutBtn')?.addEventListener('click', async () => {
|
||
await fetch('/api/logout', { method: 'POST' });
|
||
window.location.href = '/';
|
||
});
|
||
|
||
// Кнопка очистки всей базы с запросом пароля
|
||
document.getElementById('clearDbBtn')?.addEventListener('click', async () => {
|
||
// Запрашиваем пароль. Пароль: "delete", подсказка: "удалить"
|
||
const password = prompt('⚠️ ВНИМАНИЕ! Вы собираетесь УДАЛИТЬ ВСЕ уроки и все записи родителей.\nЭто действие необратимо.\n\nВведите пароль для подтверждения (пароль: удалить):');
|
||
if (password !== 'delete') {
|
||
alert('Неверный пароль. Операция отменена.');
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch('/api/admin/clear-db', { method: 'POST' });
|
||
const data = await res.json();
|
||
if (res.ok && data.success) {
|
||
alert('База данных успешно очищена.');
|
||
loadLessons(getCurrentFilters());
|
||
} else {
|
||
alert('Ошибка при очистке: ' + (data.error || 'неизвестная ошибка'));
|
||
}
|
||
} catch (err) {
|
||
alert('Ошибка соединения: ' + err.message);
|
||
}
|
||
});
|
||
|
||
// Импорт (XLSX)
|
||
function parseExcelToRecords(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
const data = new Uint8Array(e.target.result);
|
||
let workbook;
|
||
try {
|
||
workbook = XLSX.read(data, { type: 'array' });
|
||
} catch (err) {
|
||
reject(new Error('Ошибка чтения Excel: ' + err.message));
|
||
return;
|
||
}
|
||
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
|
||
const rows = XLSX.utils.sheet_to_json(firstSheet, { header: 1, defval: "" });
|
||
if (!rows || rows.length < 2) {
|
||
reject(new Error("Файл не содержит данных"));
|
||
return;
|
||
}
|
||
const headers = rows[0].map(h => String(h || "").trim());
|
||
const expectedHeaders = [
|
||
"Фамилия Учителя", "Имя Учителя", "Отчество Учителя",
|
||
"Предмет", "Тема Урока", "Класс (параллель)", "Класс (буква)"
|
||
];
|
||
const missing = expectedHeaders.filter(h => !headers.includes(h));
|
||
if (missing.length) {
|
||
reject(new Error(`В файле отсутствуют столбцы: ${missing.join(", ")}`));
|
||
return;
|
||
}
|
||
const idx = {};
|
||
expectedHeaders.forEach(h => { idx[h] = headers.indexOf(h); });
|
||
const records = [];
|
||
for (let i = 1; i < rows.length; i++) {
|
||
const row = rows[i];
|
||
if (!row || row.length === 0) continue;
|
||
const teacherLast = row[idx["Фамилия Учителя"]] || "";
|
||
const teacherFirst = row[idx["Имя Учителя"]] || "";
|
||
const teacherPatr = row[idx["Отчество Учителя"]] || "";
|
||
const subject = row[idx["Предмет"]] || "";
|
||
const topic = row[idx["Тема Урока"]] || "";
|
||
const parallelRaw = row[idx["Класс (параллель)"]]?.toString() || "";
|
||
const classLetter = row[idx["Класс (буква)"]]?.toString() || "";
|
||
if (!teacherLast && !teacherFirst && !subject) continue;
|
||
records.push({
|
||
"Фамилия Учителя": teacherLast,
|
||
"Имя Учителя": teacherFirst,
|
||
"Отчество Учителя": teacherPatr,
|
||
"Предмет": subject,
|
||
"Тема Урока": topic,
|
||
"Класс (параллель)": parallelRaw,
|
||
"Класс (буква)": classLetter
|
||
});
|
||
}
|
||
resolve(records);
|
||
};
|
||
reader.onerror = reject;
|
||
reader.readAsArrayBuffer(file);
|
||
});
|
||
}
|
||
|
||
document.getElementById('previewImportBtn')?.addEventListener('click', async () => {
|
||
const file = document.getElementById('jsonFileInput').files[0];
|
||
if (!file) return alert('Выберите JSON или Excel файл');
|
||
const importMaxSlots = document.getElementById('importMaxSlots').value;
|
||
if (!importMaxSlots) return alert('Укажите максимальное количество мест');
|
||
|
||
let records = null;
|
||
const fileExt = file.name.split('.').pop().toLowerCase();
|
||
try {
|
||
if (fileExt === 'json') {
|
||
const text = await file.text();
|
||
records = JSON.parse(text);
|
||
} else if (fileExt === 'xlsx' || fileExt === 'xls') {
|
||
records = await parseExcelToRecords(file);
|
||
} else {
|
||
alert('Неподдерживаемый формат. Используйте .json, .xlsx или .xls');
|
||
return;
|
||
}
|
||
|
||
const response = await fetch('/api/admin/import/preview', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ jsonData: records })
|
||
});
|
||
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('Ошибка при разборе файла: ' + 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 || ''));
|
||
}
|
||
});
|
||
} |