Задача Егора 2ч.

This commit is contained in:
2026-07-17 01:27:02 +03:00
parent 3f685d3e22
commit 3d798c13e3
20 changed files with 2003 additions and 275 deletions

View File

@@ -0,0 +1,214 @@
export const CONFLICT_CHECK_STATE = Object.freeze({
COMPLETE: 'COMPLETE',
PARTIAL: 'PARTIAL',
NOT_RUN: 'NOT_RUN'
});
export const CONFLICT_CHECK_REASON = Object.freeze({
NO_DEPARTMENTS: 'NO_DEPARTMENTS',
ALL_REQUESTS_FAILED: 'ALL_REQUESTS_FAILED'
});
/** Формирует date-only строку из локальных компонентов без UTC-конвертации. */
export function formatLocalDate(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new TypeError('Для форматирования требуется корректная дата');
}
return [
String(date.getFullYear()).padStart(4, '0'),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}
/** Возвращает локальные границы недели с понедельника по воскресенье включительно. */
export function currentWeekDateRange(referenceDate = new Date()) {
if (!(referenceDate instanceof Date) || Number.isNaN(referenceDate.getTime())) {
throw new TypeError('Для расчёта недели требуется корректная дата');
}
const localDate = new Date(
referenceDate.getFullYear(),
referenceDate.getMonth(),
referenceDate.getDate()
);
const dayOfWeek = localDate.getDay();
const daysFromMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = addLocalDays(localDate, daysFromMonday);
const sunday = addLocalDays(monday, 6);
return {
startDate: formatLocalDate(monday),
endDate: formatLocalDate(sunday)
};
}
/**
* Загружает расписания независимо и сохраняет факт каждого отказа.
* Технические причины намеренно не возвращаются в UI-модель.
*/
export async function loadDepartmentSchedules(departments, loadSchedule) {
if (!Array.isArray(departments)) {
throw new TypeError('Список кафедр должен быть массивом');
}
if (typeof loadSchedule !== 'function') {
throw new TypeError('Функция загрузки расписания обязательна');
}
if (departments.length === 0) {
return {
state: CONFLICT_CHECK_STATE.NOT_RUN,
reason: CONFLICT_CHECK_REASON.NO_DEPARTMENTS,
lessons: [],
totalDepartmentCount: 0,
checkedDepartmentCount: 0,
failedDepartments: []
};
}
const results = await Promise.allSettled(departments.map(async department => {
const lessons = await loadSchedule(department);
if (!Array.isArray(lessons)) {
throw new TypeError('API расписания вернул некорректный формат данных');
}
return lessons;
}));
const lessons = [];
const failedDepartments = [];
let checkedDepartmentCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
checkedDepartmentCount += 1;
lessons.push(...result.value);
return;
}
failedDepartments.push(publicDepartment(departments[index], index));
});
const state = checkedDepartmentCount === 0
? CONFLICT_CHECK_STATE.NOT_RUN
: failedDepartments.length > 0
? CONFLICT_CHECK_STATE.PARTIAL
: CONFLICT_CHECK_STATE.COMPLETE;
return {
state,
reason: state === CONFLICT_CHECK_STATE.NOT_RUN
? CONFLICT_CHECK_REASON.ALL_REQUESTS_FAILED
: null,
lessons,
totalDepartmentCount: departments.length,
checkedDepartmentCount,
failedDepartments
};
}
/** Возвращает безопасную UI-модель итогового состояния проверки. */
export function conflictCheckPresentation(scheduleLoad, conflictCount, range) {
assertPresentationInput(scheduleLoad, conflictCount, range);
const count = conflictCount;
const startDate = range.startDate;
const endDate = range.endDate;
const rangeLabel = `${startDate}${endDate}`;
if (scheduleLoad?.state === CONFLICT_CHECK_STATE.NOT_RUN) {
const noDepartments = scheduleLoad.reason === CONFLICT_CHECK_REASON.NO_DEPARTMENTS;
return {
tone: 'error',
title: 'Проверка не выполнена',
description: noDepartments
? 'Нет кафедр, расписание которых можно проверить.'
: 'Не удалось получить расписание ни одной кафедры. Подтвердить отсутствие конфликтов нельзя.',
meta: `Период: ${rangeLabel}`
};
}
if (scheduleLoad?.state === CONFLICT_CHECK_STATE.PARTIAL) {
return {
tone: 'warning',
title: 'Проверка выполнена частично',
description: count > 0
? 'Показаны найденные конфликты, но подтвердить полноту результата нельзя.'
: 'В доступной части расписания конфликтов не найдено, но подтвердить их отсутствие во всём расписании нельзя.',
meta: `Проверено кафедр: ${scheduleLoad.checkedDepartmentCount} из `
+ `${scheduleLoad.totalDepartmentCount} · Период: ${rangeLabel}`
};
}
if (scheduleLoad?.state === CONFLICT_CHECK_STATE.COMPLETE && count === 0) {
return {
tone: 'success',
title: 'Конфликты расписания не обнаружены',
description: 'Проверены накладки преподавателей и аудиторий, а также вместимость помещений.',
meta: `Проверено кафедр: ${scheduleLoad.checkedDepartmentCount} из `
+ `${scheduleLoad.totalDepartmentCount} · Период: ${rangeLabel}`
};
}
return null;
}
function addLocalDays(date, days) {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
function assertPresentationInput(scheduleLoad, conflictCount, range) {
if (!Number.isInteger(conflictCount) || conflictCount < 0) {
throw new TypeError('Количество конфликтов должно быть целым неотрицательным числом');
}
if (!isValidDateOnly(range?.startDate) || !isValidDateOnly(range?.endDate)
|| range.startDate > range.endDate) {
throw new TypeError('Для результата проверки требуется корректный локальный диапазон дат');
}
const total = scheduleLoad?.totalDepartmentCount;
const checked = scheduleLoad?.checkedDepartmentCount;
if (!Number.isInteger(total) || total < 0
|| !Number.isInteger(checked) || checked < 0 || checked > total) {
throw new TypeError('Количество проверенных кафедр не соответствует результату проверки');
}
if (scheduleLoad.state === CONFLICT_CHECK_STATE.COMPLETE) {
if (total === 0 || checked !== total) {
throw new TypeError('Полная проверка должна охватывать все кафедры');
}
return;
}
if (scheduleLoad.state === CONFLICT_CHECK_STATE.PARTIAL) {
if (checked === 0 || checked === total) {
throw new TypeError('Частичная проверка должна охватывать только часть кафедр');
}
return;
}
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN && checked === 0) {
return;
}
throw new TypeError('Неизвестное состояние проверки конфликтов');
}
function isValidDateOnly(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || '');
if (!match) return false;
const [, year, month, day] = match.map(Number);
const date = new Date(year, month - 1, day);
return date.getFullYear() === year
&& date.getMonth() === month - 1
&& date.getDate() === day;
}
function publicDepartment(department, index) {
return {
id: department?.id ?? null,
name: department?.departmentName || department?.name || `Кафедра ${index + 1}`
};
}

View File

@@ -1,5 +1,12 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { escapeHtml } from '../utils.js';
import {
CONFLICT_CHECK_STATE,
conflictCheckPresentation,
currentWeekDateRange,
formatLocalDate,
loadDepartmentSchedules
} from '../dashboard-conflicts.js';
export async function initDashboard() {
// DOM Элементы
@@ -20,16 +27,27 @@ export async function initDashboard() {
let teachers = [];
let todayLessons = [];
let timeSlots = [];
let dashboardDataReady = false;
btnRecheck.addEventListener('click', async () => {
btnRecheck.disabled = true;
btnRecheck.textContent = '...';
await checkConflicts();
btnRecheck.textContent = 'Перепроверить';
btnRecheck.disabled = false;
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([
@@ -45,7 +63,7 @@ export async function initDashboard() {
metricStudents.textContent = totalStudents;
metricTeachers.textContent = teachers.length;
const todayStr = new Date().toISOString().split('T')[0];
const todayStr = formatLocalDate(new Date());
// Загружаем временные слоты и сегодняшнее расписание
const lessonsPromises = departments.map(d =>
@@ -75,10 +93,18 @@ export async function initDashboard() {
metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`;
updateRealtimeMonitoring();
dashboardDataReady = true;
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>`;
dashboardDataReady = false;
console.error('Не удалось загрузить данные панели', {
errorType: e?.name || 'Error'
});
conflictsContainer.innerHTML = renderCheckStateCard({
tone: 'error',
title: 'Проверка не выполнена',
description: 'Не удалось загрузить данные панели. Нажмите «Перепроверить», чтобы повторить попытку.'
});
}
}
@@ -147,25 +173,25 @@ export async function initDashboard() {
// --- Анализ конфликтов (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>';
// Вычисляем даты начала и конца текущей недели
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 { startDate, endDate } = currentWeekDateRange(new Date());
const scheduleLoad = await loadDepartmentSchedules(
departments,
department => api.get(
`/api/schedule/search?departmentId=${department.id}`
+ `&startDate=${startDate}&endDate=${endDate}`
)
);
const weekLessonsArrays = await Promise.all(weekLessonsPromises);
const weekLessons = weekLessonsArrays.flat();
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) {
renderConflicts([], scheduleLoad, { startDate, endDate });
return;
}
const weekLessons = scheduleLoad.lessons;
const conflicts = [];
@@ -264,31 +290,40 @@ export async function initDashboard() {
}
});
renderConflicts(conflicts);
renderConflicts(conflicts, scheduleLoad, { startDate, endDate });
} catch (e) {
console.error("Conflicts check error", e);
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка при анализе конфликтов: ${escapeHtml(e.message)}</div>`;
console.error('Не удалось выполнить проверку конфликтов', {
errorType: e?.name || 'Error'
});
conflictsContainer.innerHTML = renderCheckStateCard({
tone: 'error',
title: 'Проверка не выполнена',
description: 'Во время анализа произошла ошибка. Повторите проверку позже.'
});
} finally {
conflictsContainer.removeAttribute('aria-busy');
}
}
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>
`;
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;
}
conflictsContainer.innerHTML = conflicts.map(c => {
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 borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b';
const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)';
@@ -306,6 +341,27 @@ export async function initDashboard() {
</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>
`;
}
// Загружаем данные при старте