import { api } from '../api.js'; import { escapeHtml, showAlert, hideAlert } from '../utils.js'; export async function initDashboard() { // DOM Элементы const metricGroups = document.getElementById('metric-groups'); const metricStudents = document.getElementById('metric-students'); const metricTeachers = document.getElementById('metric-teachers'); const metricClassrooms = document.getElementById('metric-classrooms'); const currentTimeSlot = document.getElementById('current-time-slot'); const currentLessonsTbody = document.getElementById('current-lessons-tbody'); const freeClassroomsList = document.getElementById('free-classrooms-list'); const conflictsContainer = document.getElementById('conflicts-container'); const btnRecheck = document.getElementById('btn-recheck-conflicts'); let departments = []; let classrooms = []; let groups = []; let teachers = []; let todayLessons = []; let timeSlots = []; btnRecheck.addEventListener('click', async () => { btnRecheck.disabled = true; btnRecheck.textContent = '...'; await checkConflicts(); btnRecheck.textContent = 'Перепроверить'; btnRecheck.disabled = false; }); async function loadData() { try { // Загружаем базовые справочники [departments, classrooms, groups, teachers] = await Promise.all([ api.get('/api/departments'), api.get('/api/classrooms?includeArchived=false'), api.get('/api/groups?includeArchived=false'), api.get('/api/users/teachers') ]); // Выводим метрики metricGroups.textContent = groups.length; const totalStudents = groups.reduce((acc, g) => acc + (g.groupSize || 0), 0); metricStudents.textContent = totalStudents; metricTeachers.textContent = teachers.length; const todayStr = new Date().toISOString().split('T')[0]; // Загружаем временные слоты и сегодняшнее расписание const lessonsPromises = departments.map(d => api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${todayStr}&endDate=${todayStr}`).catch(() => []) ); const lessonsArrays = await Promise.all(lessonsPromises); todayLessons = lessonsArrays.flat(); try { timeSlots = await api.get(`/api/admin/time-slots/effective?date=${todayStr}`); } catch (e) { // Если эндпоинт effective не поддерживается, используем базовые слоты const baseSlots = await api.get('/api/admin/time-slots').catch(() => []); // Отфильтруем базовые слоты тенанта (scopeId = DEFAULT или аналогично) timeSlots = baseSlots.filter(s => s.scopeId === 1 || !s.scopeId); } // Сортируем слоты по времени timeSlots.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0)); // Посчитаем занятость аудиторий сегодня const occupiedClassroomIds = new Set(todayLessons.map(l => l.classroomId).filter(Boolean)); const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED'); const classPercent = activeClassrooms.length ? Math.round((occupiedClassroomIds.size / activeClassrooms.length) * 100) : 0; metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`; updateRealtimeMonitoring(); await checkConflicts(); } catch (e) { console.error("Dashboard data load error", e); conflictsContainer.innerHTML = `
Ошибка загрузки панели: ${escapeHtml(e.message)}
`; } } function updateRealtimeMonitoring() { const now = new Date(); const currentTimeStr = now.toTimeString().split(' ')[0]; // HH:MM:SS // Находим текущий временной слот let currentSlot = null; for (const slot of timeSlots) { if (currentTimeStr >= slot.startTime && currentTimeStr <= slot.endTime) { currentSlot = slot; break; } } if (currentSlot) { currentTimeSlot.textContent = `Пара №${currentSlot.orderNumber} (${currentSlot.startTime.substring(0, 5)} – ${currentSlot.endTime.substring(0, 5)})`; renderCurrentLessons(currentSlot.orderNumber); renderFreeClassrooms(currentSlot.orderNumber); } else { currentTimeSlot.textContent = 'Вне сетки пар'; currentLessonsTbody.innerHTML = 'В данный момент учебные пары не проводятся'; // Если сейчас нет пары, покажем свободные аудитории на ближайшую следующую пару (или просто все свободные) renderFreeClassrooms(null); } } function renderCurrentLessons(slotOrder) { const activeLessons = todayLessons.filter(l => l.timeSlotOrder === slotOrder); if (!activeLessons.length) { currentLessonsTbody.innerHTML = 'В текущий слот занятий не запланировано'; return; } currentLessonsTbody.innerHTML = activeLessons.map(l => ` ${escapeHtml(l.classroomName)} ${escapeHtml(l.subjectName)} ${escapeHtml(l.lessonTypeName)} ${escapeHtml(l.teacherName)} ${escapeHtml(l.groupNames.join(', '))} `).join(''); } function renderFreeClassrooms(slotOrder) { const activeClassrooms = classrooms.filter(c => c.isAvailable && c.status !== 'ARCHIVED'); let occupiedIds = new Set(); if (slotOrder !== null) { occupiedIds = new Set(todayLessons.filter(l => l.timeSlotOrder === slotOrder).map(l => l.classroomId)); } const free = activeClassrooms.filter(c => !occupiedIds.has(c.id)); if (!free.length) { freeClassroomsList.innerHTML = 'Все аудитории заняты'; return; } freeClassroomsList.innerHTML = free.map(c => ` ${escapeHtml(c.name)} (${c.capacity} м.) `).join(''); } // --- Анализ конфликтов (Red Zone) --- async function checkConflicts() { conflictsContainer.innerHTML = '
Анализ расписания и выявление конфликтов...
'; // Вычисляем даты начала и конца текущей недели const curr = new Date(); const day = curr.getDay(); const diff = curr.getDate() - day + (day === 0 ? -6 : 1); // Понедельник const monday = new Date(curr.setDate(diff)); const sunday = new Date(curr.setDate(diff + 6)); const mondayStr = monday.toISOString().split('T')[0]; const sundayStr = sunday.toISOString().split('T')[0]; try { // Загружаем расписание на всю неделю по кафедрам const weekLessonsPromises = departments.map(d => api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${mondayStr}&endDate=${sundayStr}`).catch(() => []) ); const weekLessonsArrays = await Promise.all(weekLessonsPromises); const weekLessons = weekLessonsArrays.flat(); const conflicts = []; // Мапы для быстрого поиска характеристик групп и аудиторий const groupSizeMap = new Map(groups.map(g => [g.id, g.groupSize || 0])); const classroomCapacityMap = new Map(classrooms.map(c => [c.id, c.capacity || 0])); // 1. Проверка накладок преподавателей // Ключ: date + '_' + timeSlotOrder + '_' + teacherId const teacherSlots = new Map(); weekLessons.forEach(l => { if (!l.teacherId) return; const key = `${l.date}_${l.timeSlotOrder}_${l.teacherId}`; if (!teacherSlots.has(key)) { teacherSlots.set(key, []); } teacherSlots.get(key).push(l); }); teacherSlots.forEach((lessons, key) => { if (lessons.length > 1) { // Проверим, если все занятия в одной аудитории — это потоковая лекция, а не конфликт const classroomIds = new Set(lessons.map(l => l.classroomId)); if (classroomIds.size > 1) { const first = lessons[0]; const listStr = lessons.map(l => `ауд. ${l.classroomName} (группа ${l.groupNames.join(', ')})`).join(', '); conflicts.push({ type: 'TEACHER_COLLISION', title: 'Накладка в расписании преподавателя', description: `Преподаватель ${escapeHtml(first.teacherName)} назначен одновременно в разные места: ${escapeHtml(listStr)} на ${first.date} (${first.dayName}), пара №${first.timeSlotOrder}.`, severity: 'error' }); } } }); // 2. Проверка накладок аудиторий // Ключ: date + '_' + timeSlotOrder + '_' + classroomId const classroomSlots = new Map(); weekLessons.forEach(l => { if (!l.classroomId) return; const key = `${l.date}_${l.timeSlotOrder}_${l.classroomId}`; if (!classroomSlots.has(key)) { classroomSlots.set(key, []); } classroomSlots.get(key).push(l); }); classroomSlots.forEach((lessons, key) => { if (lessons.length > 1) { // Проверим, не является ли это потоковой лекцией (один преподаватель и одна дисциплина) const teacherIds = new Set(lessons.map(l => l.teacherId)); const subjectIds = new Set(lessons.map(l => l.subjectId)); if (teacherIds.size > 1 || subjectIds.size > 1) { const first = lessons[0]; const details = lessons.map(l => `препод. ${l.teacherName} (предмет: ${l.subjectName}, группа: ${l.groupNames.join(', ')})`).join(' И '); conflicts.push({ type: 'CLASSROOM_COLLISION', title: 'Накладка в аудитории', description: `Аудитория ${escapeHtml(first.classroomName)} занята одновременно разными занятиями: ${escapeHtml(details)} на ${first.date} (${first.dayName}), пара №${first.timeSlotOrder}.`, severity: 'error' }); } } }); // 3. Проверка превышения вместимости аудитории // Считаем сумму студентов для каждого уникального занятия в аудитории classroomSlots.forEach((lessons, key) => { if (!lessons.length) return; const first = lessons[0]; const capacity = classroomCapacityMap.get(first.classroomId) || 0; // Суммируем численность всех групп, назначенных на этот слот в этой аудитории const groupIds = new Set(); lessons.forEach(l => { if (l.groupIds) { l.groupIds.forEach(id => groupIds.add(id)); } }); let totalStudents = 0; groupIds.forEach(id => { totalStudents += groupSizeMap.get(id) || 0; }); if (totalStudents > capacity) { const groupNamesList = lessons.map(l => l.groupNames.join(', ')).join(', '); conflicts.push({ type: 'CAPACITY_OVERFLOW', title: 'Превышение вместимости аудитории', description: `Вместимость аудитории ${escapeHtml(first.classroomName)} составляет ${capacity} чел., однако на занятие назначено ${totalStudents} человек (группы: ${escapeHtml(groupNamesList)}) на ${first.date} (${first.dayName}), пара №${first.timeSlotOrder}.`, severity: 'warning' }); } }); renderConflicts(conflicts); } catch (e) { console.error("Conflicts check error", e); conflictsContainer.innerHTML = `
Ошибка при анализе конфликтов: ${escapeHtml(e.message)}
`; } } function renderConflicts(conflicts) { if (!conflicts.length) { conflictsContainer.innerHTML = `
Конфликты расписания не обнаружены! Все правила расписания на текущую неделю согласованы, накладки преподавателей/аудиторий и переполнения отсутствуют.
`; return; } conflictsContainer.innerHTML = conflicts.map(c => { const isError = c.severity === 'error'; const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b'; const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)'; const badgeIcon = isError ? `` : ``; return `
${badgeIcon}

${escapeHtml(c.title)}

${c.description}

`; }).join(''); } // Загружаем данные при старте await loadData(); // Запускаем периодическое обновление (каждые 30 секунд для пар в реальном времени) const realtimeTimer = setInterval(() => { if (document.getElementById('current-time-slot')) { updateRealtimeMonitoring(); } else { clearInterval(realtimeTimer); } }, 30000); }