377 lines
19 KiB
JavaScript
377 lines
19 KiB
JavaScript
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 = '<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.setAttribute('aria-busy', 'true');
|
||
conflictsContainer.innerHTML = '<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>';
|
||
|
||
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: `Преподаватель <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, 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
|
||
? `<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 class="dashboard-conflict-card dashboard-conflict-card--${isError ? 'error' : 'warning'}">
|
||
<div class="dashboard-conflict-card__icon">${badgeIcon}</div>
|
||
<div class="dashboard-conflict-card__body">
|
||
<h4>${escapeHtml(c.title)}</h4>
|
||
<p>${escapeHtml(c.description)}</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
conflictsContainer.innerHTML = stateCards.join('') + conflictCards;
|
||
}
|
||
|
||
function renderCheckStateCard({ tone, title, description, meta = '' }) {
|
||
const icon = tone === 'success'
|
||
? '<path d="M20 6 9 17l-5-5" />'
|
||
: tone === 'warning'
|
||
? '<path d="M12 9v4m0 4h.01" /><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />'
|
||
: '<circle cx="12" cy="12" r="9" /><path d="M12 8v5m0 3h.01" />';
|
||
return `
|
||
<section class="dashboard-check-state dashboard-check-state--${tone}">
|
||
<span class="dashboard-check-state__icon" aria-hidden="true">
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">${icon}</svg>
|
||
</span>
|
||
<span class="dashboard-check-state__body">
|
||
<strong>${escapeHtml(title)}</strong>
|
||
<span>${escapeHtml(description)}</span>
|
||
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
|
||
</span>
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
// Загружаем данные при старте
|
||
await loadData();
|
||
|
||
// Запускаем периодическое обновление (каждые 30 секунд для пар в реальном времени)
|
||
const realtimeTimer = setInterval(() => {
|
||
if (document.getElementById('current-time-slot')) {
|
||
updateRealtimeMonitoring();
|
||
} else {
|
||
clearInterval(realtimeTimer);
|
||
}
|
||
}, 30000);
|
||
}
|