Files
OpenLesson/public/admin.js

445 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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('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 class_name = document.getElementById('className').value.trim();
const parallel = parseInt(document.getElementById('parallel').value);
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) || !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 () => {
const confirmed = confirm('⚠️ ВНИМАНИЕ! Вы собираетесь УДАЛИТЬ ВСЕ уроки и все записи родителей. Это действие необратимо. Продолжить?');
if (!confirmed) 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 || ''));
}
});
}