323 lines
18 KiB
JavaScript
323 lines
18 KiB
JavaScript
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 = `<div class="form-alert error" style="display:block">Ошибка загрузки панели: ${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
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 = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В данный момент учебные пары не проводятся</td></tr>';
|
||
|
||
// Если сейчас нет пары, покажем свободные аудитории на ближайшую следующую пару (или просто все свободные)
|
||
renderFreeClassrooms(null);
|
||
}
|
||
}
|
||
|
||
function renderCurrentLessons(slotOrder) {
|
||
const activeLessons = todayLessons.filter(l => l.timeSlotOrder === slotOrder);
|
||
if (!activeLessons.length) {
|
||
currentLessonsTbody.innerHTML = '<tr><td colspan="4" class="loading-row" style="padding: 1.5rem; text-align: center; color: var(--text-secondary);">В текущий слот занятий не запланировано</td></tr>';
|
||
return;
|
||
}
|
||
|
||
currentLessonsTbody.innerHTML = activeLessons.map(l => `
|
||
<tr>
|
||
<td style="padding: 0.75rem;"><strong>${escapeHtml(l.classroomName)}</strong></td>
|
||
<td style="padding: 0.75rem;">${escapeHtml(l.subjectName)} <span class="badge badge-ef" style="font-size:0.75rem;">${escapeHtml(l.lessonTypeName)}</span></td>
|
||
<td style="padding: 0.75rem;">${escapeHtml(l.teacherName)}</td>
|
||
<td style="padding: 0.75rem;"><span class="badge badge-available">${escapeHtml(l.groupNames.join(', '))}</span></td>
|
||
</tr>
|
||
`).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 = '<span style="color: var(--error); font-size: 0.9rem; font-weight: 500;">Все аудитории заняты</span>';
|
||
return;
|
||
}
|
||
|
||
freeClassroomsList.innerHTML = free.map(c => `
|
||
<span class="badge badge-available" style="padding: 0.4rem 0.6rem; font-size: 0.85rem; background: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2);">
|
||
${escapeHtml(c.name)} (${c.capacity} м.)
|
||
</span>
|
||
`).join('');
|
||
}
|
||
|
||
// --- Анализ конфликтов (Red Zone) ---
|
||
async function checkConflicts() {
|
||
conflictsContainer.innerHTML = '<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>';
|
||
|
||
// Вычисляем даты начала и конца текущей недели
|
||
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: `Преподаватель <strong>${escapeHtml(first.teacherName)}</strong> назначен одновременно в разные места: ${escapeHtml(listStr)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
|
||
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: `Аудитория <strong>${escapeHtml(first.classroomName)}</strong> занята одновременно разными занятиями: ${escapeHtml(details)} на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
|
||
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: `Вместимость аудитории <strong>${escapeHtml(first.classroomName)}</strong> составляет <strong>${capacity} чел.</strong>, однако на занятие назначено <strong>${totalStudents} человек</strong> (группы: ${escapeHtml(groupNamesList)}) на <strong>${first.date} (${first.dayName}), пара №${first.timeSlotOrder}</strong>.`,
|
||
severity: 'warning'
|
||
});
|
||
}
|
||
});
|
||
|
||
renderConflicts(conflicts);
|
||
} catch (e) {
|
||
console.error("Conflicts check error", e);
|
||
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка при анализе конфликтов: ${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderConflicts(conflicts) {
|
||
if (!conflicts.length) {
|
||
conflictsContainer.innerHTML = `
|
||
<div style="background: rgba(16, 185, 129, 0.08); border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 8px; padding: 1.25rem; display: flex; align-items: center; gap: 1rem; color: #10b981;">
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||
<polyline points="22 4 12 14.01 9 11.01" />
|
||
</svg>
|
||
<div>
|
||
<strong style="display: block; font-size: 0.95rem; margin-bottom: 0.15rem;">Конфликты расписания не обнаружены!</strong>
|
||
<span style="font-size: 0.85rem; opacity: 0.9;">Все правила расписания на текущую неделю согласованы, накладки преподавателей/аудиторий и переполнения отсутствуют.</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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
|
||
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`
|
||
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
|
||
|
||
return `
|
||
<div style="background: ${bgCol}; border: 1px solid rgba(229, 231, 235, 0.1); border-left: 4px solid ${borderCol}; border-radius: 8px; padding: 1rem; display: flex; gap: 0.75rem; align-items: start;">
|
||
<div style="margin-top: 0.1rem;">${badgeIcon}</div>
|
||
<div style="flex: 1;">
|
||
<h4 style="margin: 0 0 0.25rem 0; font-size: 0.95rem; font-weight: 600; color: ${isError ? 'var(--error, #ef4444)' : '#d97706'};">${escapeHtml(c.title)}</h4>
|
||
<p style="margin: 0; font-size: 0.85rem; line-height: 1.4; color: var(--text-main);">${c.description}</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// Загружаем данные при старте
|
||
await loadData();
|
||
|
||
// Запускаем периодическое обновление (каждые 30 секунд для пар в реальном времени)
|
||
const realtimeTimer = setInterval(() => {
|
||
if (document.getElementById('current-time-slot')) {
|
||
updateRealtimeMonitoring();
|
||
} else {
|
||
clearInterval(realtimeTimer);
|
||
}
|
||
}, 30000);
|
||
}
|