import { api } from '../api.js'; import { escapeHtml } from '../utils.js'; import { CONFLICT_CHECK_STATE, conflictCheckPresentation, currentWeekDateRange, formatLocalDate, loadDepartmentSchedules } from '../dashboard-conflicts.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 = []; let dashboardDataReady = false; btnRecheck.addEventListener('click', async () => { btnRecheck.disabled = true; btnRecheck.setAttribute('aria-busy', 'true'); btnRecheck.textContent = 'Проверка…'; try { if (dashboardDataReady) { await checkConflicts(); } else { await loadData(); } } finally { btnRecheck.textContent = 'Перепроверить'; btnRecheck.disabled = false; btnRecheck.removeAttribute('aria-busy'); } }); async function loadData() { dashboardDataReady = false; 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 = formatLocalDate(new Date()); // Загружаем временные слоты и сегодняшнее расписание 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(); dashboardDataReady = true; await checkConflicts(); } catch (e) { dashboardDataReady = false; console.error('Не удалось загрузить данные панели', { errorType: e?.name || 'Error' }); conflictsContainer.innerHTML = renderCheckStateCard({ tone: 'error', title: 'Проверка не выполнена', description: 'Не удалось загрузить данные панели. Нажмите «Перепроверить», чтобы повторить попытку.' }); } } 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.setAttribute('aria-busy', 'true'); conflictsContainer.innerHTML = '
Анализ расписания и выявление конфликтов...
'; try { const { startDate, endDate } = currentWeekDateRange(new Date()); const scheduleLoad = await loadDepartmentSchedules( departments, department => api.get( `/api/schedule/search?departmentId=${department.id}` + `&startDate=${startDate}&endDate=${endDate}` ) ); if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) { renderConflicts([], scheduleLoad, { startDate, endDate }); return; } const weekLessons = scheduleLoad.lessons; 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, scheduleLoad, { startDate, endDate }); } catch (e) { console.error('Не удалось выполнить проверку конфликтов', { errorType: e?.name || 'Error' }); conflictsContainer.innerHTML = renderCheckStateCard({ tone: 'error', title: 'Проверка не выполнена', description: 'Во время анализа произошла ошибка. Повторите проверку позже.' }); } finally { conflictsContainer.removeAttribute('aria-busy'); } } function renderConflicts(conflicts, scheduleLoad, range) { const stateCards = []; const presentation = conflictCheckPresentation(scheduleLoad, conflicts.length, range); if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) { conflictsContainer.innerHTML = renderCheckStateCard(presentation); return; } if (presentation) { stateCards.push(renderCheckStateCard(presentation)); } if (!conflicts.length) { conflictsContainer.innerHTML = stateCards.join(''); return; } const conflictCards = conflicts.map(c => { const isError = c.severity === 'error'; const badgeIcon = isError ? `` : ``; return `
${badgeIcon}

${escapeHtml(c.title)}

${escapeHtml(c.description)}

`; }).join(''); conflictsContainer.innerHTML = stateCards.join('') + conflictCards; } function renderCheckStateCard({ tone, title, description, meta = '' }) { const icon = tone === 'success' ? '' : tone === 'warning' ? '' : ''; return `
${escapeHtml(title)} ${escapeHtml(description)} ${meta ? `${escapeHtml(meta)}` : ''}
`; } // Загружаем данные при старте await loadData(); // Запускаем периодическое обновление (каждые 30 секунд для пар в реальном времени) const realtimeTimer = setInterval(() => { if (document.getElementById('current-time-slot')) { updateRealtimeMonitoring(); } else { clearInterval(realtimeTimer); } }, 30000); }