обновил дизайн, добавил дашборд

This commit is contained in:
Zuev
2026-05-31 16:18:27 +03:00
parent e2a3553a09
commit 7926038bdc
36 changed files with 2364 additions and 1184 deletions

View File

@@ -0,0 +1,322 @@
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);
}