diff --git a/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md b/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md index ed632d6..68dfc2d 100644 --- a/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md +++ b/SUPERVISOR_TASKS_IMPLEMENTATION_PLAN.md @@ -14,7 +14,7 @@ - добавлены точечные изменения расписания: перенос, отмена, замена; - добавлен расширенный read-only поиск расписания по аудитории, преподавателю, кафедре, группе, дисциплине, типу занятия, паре и чётности; - добавлены отчёты загруженности по преподавателям, аудиториям, кафедрам, парам и свободным аудиториям; -- просмотр расписаний переведён с плоского списка на недельные матричные таблицы; +- просмотр расписаний переведён с плоского списка на совмещённые матричные таблицы чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям; - вкладка загруженности стала общей для аудиторий, преподавателей и кафедр; - интерфейсы кафедры и учебного отдела переведены в общий стиль админ-панели через role-based вкладки; - отдельная миграция `V2` удалена, изменения схемы внесены в `V1__init.sql`; @@ -150,8 +150,11 @@ frontend/admin/js/views/schedule-view.js Возможности: -- фильтры по периоду, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности; -- недельные матричные таблицы найденных занятий: строки — даты, столбцы — пары; +- фильтры по одной дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности; +- двухнедельный диапазон для таблиц автоматически строится от понедельника выбранной недели; +- выбор разреза таблиц: автоматически, по группам, по преподавателям или по аудиториям; +- совмещённые матричные таблицы найденных занятий: строки — пары, столбцы — дни недели; +- нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, одинаковые занятия в обе недели схлопываются в цельную ячейку; - карточки занятий внутри ячеек с дисциплиной, группами, подгруппами, преподавателем, аудиторией, типом занятия, чётностью и ID слота. ### Загруженность diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 8d729ea..e78a6be 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -9,7 +9,7 @@ | **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. | | **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. | | **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. | -| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в недельных матричных таблицах. | +| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в совмещённых таблицах чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям. | | **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. | | **Студент** | `STUDENT` | Только просмотр расписания (Read-only). | diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 4e3c0b2..8ee0d29 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -137,7 +137,7 @@ frontend/ | `classrooms` | Аудитории | `/api/classrooms` | | `subjects` | Дисциплины | `/api/subjects` | | `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` | -| `schedule-view` | Read-only просмотр расписаний: результат поиска выводится недельными матричными таблицами с датами по строкам и парами по столбцам | `/api/schedule/search` | +| `schedule-view` | Read-only просмотр расписаний: по одной выбранной дате строится двухнедельный диапазон и результат разбивается на отдельные совмещённые таблицы чётной/нечётной недели по группам, преподавателям или аудиториям | `/api/schedule/search` | | `schedule` | Конструктор правил динамического расписания | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types`, `/api/subgroups` | | `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` | | `auditorium-workload` | Динамическая загруженность аудиторий, преподавателей и кафедр: сводная матрица по дате или совмещённая таблица выбранной сущности по чётной/нечётной неделе | `/api/classrooms`, `/api/users/teachers`, `/api/departments`, `/api/admin/time-slots`, `/api/equipments`, `/api/groups`, `/api/schedule`, `/api/admin/calendar/years` | @@ -147,7 +147,7 @@ frontend/ ### Особенности админских вкладок - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. -- Вкладка `schedule-view` показывает найденные занятия только в табличной матрице: период разбивается по учебным неделям, строки — даты, столбцы — пары, а внутри ячеек используются те же карточки занятий, что и в таблицах загруженности. +- Вкладка `schedule-view` показывает найденные занятия только в табличной матрице. Пользователь выбирает одну дату в периоде; frontend сам запрашивает двухнедельный диапазон от понедельника выбранной недели, чтобы обе половины совмещённой таблицы были заполнены одним периодом. Чтобы ячейка вроде «понедельник, 1 пара» не смешивала занятия разных групп и аудиторий, результат разбивается на отдельные таблицы. Поле «Таблицы по» поддерживает авто-режим, группы, преподавателей и аудитории; авто-режим выбирает преподавателей при фильтре преподавателя, аудитории при фильтре аудитории, иначе группы. Каждая таблица строится двухнедельными блоками: строки — пары, столбцы — дни недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Внутри ячеек используются те же карточки занятий, что и в таблицах загруженности. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. @@ -246,7 +246,7 @@ export function isAuthenticatedAsAdmin() { Учебный отдел работает в общей админ-панели с ограниченным набором вкладок: -- расширенный поиск `/api/schedule/search` по периоду, преподавателю, аудитории и кафедре; +- расширенный поиск `/api/schedule/search` по дате в периоде, преподавателю, аудитории и кафедре; - конструктор расписания через `/api/admin/schedule-rules`; - создание точечного изменения пары через `/api/edu-office/schedule/overrides` на уровне API; - матричный просмотр загруженности аудиторий, преподавателей и кафедр через общую вкладку `Загруженность`; @@ -264,7 +264,7 @@ export function isAuthenticatedAsAdmin() { ### Просмотр расписаний (`/admin/#schedule-view`) -Роль `SCHEDULE_VIEWER` видит только read-only вкладку просмотра расписаний. Доступны фильтры по периоду, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности. Найденные занятия отображаются недельными таблицами с датами по строкам и парами по столбцам. +Роль `SCHEDULE_VIEWER` видит только read-only вкладку просмотра расписаний. Доступны фильтры по дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. Найденные занятия отображаются отдельными совмещёнными таблицами чётной/нечётной недели с парами по строкам и днями недели по столбцам. ### Преподаватель (`/teacher/`) diff --git a/frontend/admin/css/auditorium-workload.css b/frontend/admin/css/auditorium-workload.css index 31cfdd4..ed5fad2 100644 --- a/frontend/admin/css/auditorium-workload.css +++ b/frontend/admin/css/auditorium-workload.css @@ -592,12 +592,7 @@ .schedule-view-matrix-table th, .schedule-view-matrix-table td { min-width: 150px; - height: 118px; -} - -.schedule-view-matrix-table .schedule-view-date-cell { - width: 150px; - min-width: 150px; + height: 172px; } .schedule-view-lesson-card .lesson-meta + .lesson-meta { diff --git a/frontend/admin/js/views/schedule-view.js b/frontend/admin/js/views/schedule-view.js index 85d61ba..787075a 100644 --- a/frontend/admin/js/views/schedule-view.js +++ b/frontend/admin/js/views/schedule-view.js @@ -7,19 +7,45 @@ const PARITY_LABELS = { ODD: 'нечётная', EVEN: 'чётная' }; +const DAY_SHORT_LABELS = { + 1: 'Пн', + 2: 'Вт', + 3: 'Ср', + 4: 'Чт', + 5: 'Пт', + 6: 'Сб', + 7: 'Вс' +}; +const DAY_FULL_LABELS = { + 1: 'Понедельник', + 2: 'Вторник', + 3: 'Среда', + 4: 'Четверг', + 5: 'Пятница', + 6: 'Суббота', + 7: 'Воскресенье' +}; +const DAY_ORDER = [1, 2, 3, 4, 5, 6, 7]; +const CELL_PARITY_LAYOUT = [ + { parity: 'ODD', label: 'Нечётная' }, + { parity: 'EVEN', label: 'Чётная' } +]; +const SPLIT_AUTO = 'auto'; +const SPLIT_GROUPS = 'groups'; +const SPLIT_TEACHERS = 'teachers'; +const SPLIT_CLASSROOMS = 'classrooms'; export async function initScheduleView() { const role = localStorage.getItem('role'); const userDepartmentId = localStorage.getItem('departmentId'); - const startInput = document.getElementById('schedule-view-start'); - const endInput = document.getElementById('schedule-view-end'); + const dateInput = document.getElementById('schedule-view-date'); const loadButton = document.getElementById('schedule-view-load'); const departmentField = document.getElementById('schedule-view-department-field'); const departmentSelect = document.getElementById('schedule-view-department'); const tables = document.getElementById('schedule-view-tables'); const countLabel = document.getElementById('schedule-view-count'); - setDefaultDates(startInput, endInput); + setDefaultDate(dateInput); loadButton?.addEventListener('click', loadData); try { @@ -38,9 +64,17 @@ export async function initScheduleView() { setTableState('Загрузка...'); countLabel.textContent = 'Загрузка...'; + if (!dateInput.value) { + const message = 'Выберите дату в периоде'; + showAlert('schedule-view-alert', message, 'error'); + setTableState(message); + countLabel.textContent = '0 занятий'; + return; + } + const range = twoWeekRange(dateInput.value); const params = new URLSearchParams({ - startDate: startInput.value, - endDate: endInput.value + startDate: range.startDate, + endDate: range.endDate }); addOptional(params, 'groupId', valueOf('schedule-view-group')); addOptional(params, 'teacherId', valueOf('schedule-view-teacher')); @@ -52,7 +86,7 @@ export async function initScheduleView() { try { const lessons = await api.get(`/api/schedule/search?${params}`); - renderLessons(lessons); + renderLessons(lessons, range); } catch (error) { const message = error.message || 'Ошибка загрузки расписания'; showAlert('schedule-view-alert', message, 'error'); @@ -61,46 +95,72 @@ export async function initScheduleView() { } } - function renderLessons(lessons) { + function renderLessons(lessons, range) { countLabel.textContent = lessonCountLabel(lessons.length); if (!lessons.length) { setTableState('Занятия не найдены'); return; } - const slots = collectSlots(lessons); - const lessonsByDateSlot = groupLessonsByDateSlot(lessons); - const weeks = groupDatesByWeek(lessons.map(lesson => lesson.date)); - tables.innerHTML = weeks - .map(week => renderWeekTable(week, slots, lessonsByDateSlot)) + const sections = buildScheduleSections(lessons); + if (!sections.length) { + setTableState('Нет данных для выбранного разреза таблиц'); + return; + } + const tableCount = sections.reduce((count, section) => + count + buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons).length, 0 + ); + if (!tableCount) { + setTableState('Выберите корректный период'); + return; + } + countLabel.textContent = `${lessonCountLabel(lessons.length)}, ${tableCountLabel(tableCount)}`; + tables.innerHTML = sections + .map(section => renderScheduleSection(section, range)) .join(''); } - function renderWeekTable(week, slots, lessonsByDateSlot) { - const rows = week.dates.map(date => ` + function renderScheduleSection(section, range) { + const slots = collectSlots(section.lessons); + const lessonsByDateSlot = groupLessonsByDateSlot(section.lessons); + const blocks = buildCombinedWeekBlocks(range.startDate, range.endDate, section.lessons); + return blocks + .map(block => renderCombinedWeekTable(block, slots, lessonsByDateSlot, section)) + .join(''); + } + + function renderCombinedWeekTable(block, slots, lessonsByDateSlot, section) { + const dayHeaders = DAY_ORDER.map(day => ` + + ${escapeHtml(dayShort(day))} + ${escapeHtml(DAY_FULL_LABELS[day] || '')} +
+ ${CELL_PARITY_LAYOUT.map(({ parity, label }) => renderDayParityDate(block.datesByDay[day]?.[parity], label)).join('')} +
+ + `).join(''); + const rows = slots.map(slot => ` - - ${escapeHtml(dayTitle(date, lessonsByDateSlot))} - ${escapeHtml(formatDate(date))} + + ${escapeHtml(slot.orderLabel)} + ${escapeHtml(slot.timeLabel)} + ${slot.id ? `ID ${escapeHtml(slot.id)}` : ''} - ${slots.map(slot => renderSlotCell(date, slot, lessonsByDateSlot)).join('')} + ${DAY_ORDER.map(day => renderCombinedSlotCell(block.datesByDay[day] || {}, slot, lessonsByDateSlot)).join('')} `).join(''); return `
-

${escapeHtml(week.label)}

- ${escapeHtml(weekCaption(week.dates))} +

${escapeHtml(section.title)} · ${escapeHtml(block.label)}

+ ${escapeHtml(`${lessonCountLabel(section.lessons.length)}; ${combinedWeekCaption(block)}`)}
- +
- - ${slots.map(renderSlotHeader).join('')} + + ${dayHeaders} ${rows} @@ -110,23 +170,107 @@ export async function initScheduleView() { `; } - function renderSlotHeader(slot) { + function renderDayParityDate(date, label) { + return `${escapeHtml(label)}: ${date ? escapeHtml(formatDateShort(date)) : '-'}`; + } + + function renderCombinedSlotCell(datesByParity, slot, lessonsByDateSlot) { + const states = CELL_PARITY_LAYOUT.map(({ parity, label }) => ({ + parity, + label, + state: scheduleSlotState(datesByParity[parity], slot, lessonsByDateSlot) + })); + + if (slotStatesEqual(states[0].state, states[1].state)) { + return ``; + } + + const halves = states.map(({ parity, label, state }) => + renderSlotHalf(state, parity, label) + ).join(''); + return ``; + } + + function scheduleSlotState(date, slot, lessonsByDateSlot) { + if (!date) { + return { kind: 'empty', lessons: [] }; + } + const lessons = lessonsByDateSlot.get(dateSlotKey(date, slot.key)) || []; + if (lessons.length) { + return { kind: 'busy', lessons }; + } + return { kind: 'free', lessons: [] }; + } + + function renderUnifiedSlot(state) { + if (state.kind === 'busy') { + return `
${state.lessons.map(renderLessonCard).join('')}
`; + } + if (state.kind === 'empty') { + return '
Нет даты
'; + } + return '
Нет занятий
'; + } + + function renderSlotHalf(state, parity, label) { + if (state.kind === 'busy') { + return ` +
+ ${escapeHtml(label)} + ${state.lessons.map(renderLessonCard).join('')} +
+ `; + } + if (state.kind === 'empty') { + return ` +
+ ${escapeHtml(label)} + Нет даты +
+ `; + } return ` - +
+ ${escapeHtml(label)} + Нет занятий +
`; } - function renderSlotCell(date, slot, lessonsByDateSlot) { - const lessons = lessonsByDateSlot.get(dateSlotKey(date, slot.key)) || []; - return ` - - `; + function slotStatesEqual(first, second) { + if (first.kind !== second.kind) return false; + if (first.kind !== 'busy') return true; + return lessonSetSignature(first.lessons) === lessonSetSignature(second.lessons); + } + + function lessonSetSignature(lessons) { + return lessons + .map(lessonContentSignature) + .sort((a, b) => naturalCompare(a, b)) + .join('|'); + } + + function lessonContentSignature(lesson) { + return [ + lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '', + lesson.subjectId || lesson.subjectName || '', + lesson.teacherId || lesson.teacherName || '', + lesson.classroomId || lesson.classroomName || '', + lesson.lessonTypeId || lesson.lessonTypeName || '', + lesson.lessonFormat || '', + valueListSignature(lesson.groupIds, lesson.groupNames), + valueListSignature(lesson.subgroupIds, lesson.subgroupNames) + ].join(':'); + } + + function valueListSignature(primary = [], fallback = []) { + const primaryList = Array.isArray(primary) ? primary : []; + const fallbackList = Array.isArray(fallback) ? fallback : []; + const source = primaryList.length ? primaryList : fallbackList; + return [...source] + .map(value => String(value)) + .sort((a, b) => naturalCompare(a, b)) + .join(','); } function renderLessonCard(lesson) { @@ -178,12 +322,9 @@ async function loadDictionaries(role, userDepartmentId) { } } -function setDefaultDates(startInput, endInput) { +function setDefaultDate(dateInput) { const today = new Date(); - const nextWeek = new Date(); - nextWeek.setDate(today.getDate() + 7); - startInput.value = toIsoDate(today); - endInput.value = toIsoDate(nextWeek); + dateInput.value = toIsoDate(today); } function fillSelect(id, items, valueFn, labelFn, emptyLabel) { @@ -205,14 +346,6 @@ function roomLabel(room) { return [room.name, room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(' · '); } -function formatSlot(lesson) { - const order = lesson.timeSlotOrder ? `${lesson.timeSlotOrder} пара` : 'Пара'; - const time = lesson.startTime && lesson.endTime - ? ` (${trimTime(lesson.startTime)}-${trimTime(lesson.endTime)})` - : ''; - return `${order}${time}`; -} - function collectSlots(lessons) { const slots = new Map(); lessons.forEach(lesson => { @@ -252,35 +385,159 @@ function groupLessonsByDateSlot(lessons) { return grouped; } -function groupDatesByWeek(dates) { - const groups = new Map(); - Array.from(new Set(dates.filter(Boolean))) - .sort((a, b) => naturalCompare(a, b)) - .forEach(date => { - const key = weekStart(date); - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(date); +function buildScheduleSections(lessons) { + const splitType = resolveSplitType(); + const sections = new Map(); + + lessons.forEach(lesson => { + splitEntitiesForLesson(lesson, splitType).forEach(entity => { + if (!sections.has(entity.key)) { + sections.set(entity.key, { + key: entity.key, + title: entity.title, + lessons: [] + }); + } + sections.get(entity.key).lessons.push(lesson); }); - return Array.from(groups.entries()).map(([startDate, weekDates]) => ({ - startDate, - dates: weekDates, - label: `Неделя ${formatDate(startDate)} - ${formatDate(addDaysToIso(startDate, 6))}` - })); + }); + + return Array.from(sections.values()) + .map(section => ({ + ...section, + lessons: sortLessons(section.lessons) + })) + .sort((a, b) => naturalCompare(a.title, b.title)); } -function dayTitle(date, lessonsByDateSlot) { - for (const [key, lessons] of lessonsByDateSlot.entries()) { - if (key.startsWith(`${date}:`) && lessons[0]?.dayName) { - return lessons[0].dayName; - } +function resolveSplitType() { + const requested = valueOf('schedule-view-split'); + if ([SPLIT_GROUPS, SPLIT_TEACHERS, SPLIT_CLASSROOMS].includes(requested)) { + return requested; } - return dayName(date); + if (valueOf('schedule-view-teacher')) return SPLIT_TEACHERS; + if (valueOf('schedule-view-classroom')) return SPLIT_CLASSROOMS; + return SPLIT_GROUPS; } -function weekCaption(dates) { - const first = dates[0]; - const last = dates[dates.length - 1]; - return `${formatDate(first)} - ${formatDate(last)}`; +function splitEntitiesForLesson(lesson, splitType) { + if (splitType === SPLIT_TEACHERS) { + return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')]; + } + if (splitType === SPLIT_CLASSROOMS) { + return [singleEntity('classroom', lesson.classroomId, lesson.classroomName, 'Аудитория не указана')]; + } + return listEntities('group', lesson.groupIds, lesson.groupNames, 'Группа не указана'); +} + +function singleEntity(prefix, id, name, fallback) { + const title = name || fallback; + return { + key: `${prefix}:${id ?? title}`, + title + }; +} + +function listEntities(prefix, ids = [], names = [], fallback) { + const safeIds = Array.isArray(ids) ? ids : []; + const safeNames = Array.isArray(names) ? names : []; + if (!safeIds.length && !safeNames.length) { + return [singleEntity(prefix, null, fallback, fallback)]; + } + const maxLength = Math.max(safeIds.length, safeNames.length); + const entities = []; + for (let index = 0; index < maxLength; index += 1) { + const id = safeIds[index] ?? null; + const name = safeNames[index] || (id == null ? fallback : `Группа ${id}`); + entities.push(singleEntity(prefix, id, name, fallback)); + } + return entities; +} + +function sortLessons(lessons) { + return [...lessons].sort((a, b) => + naturalCompare(a.date, b.date) + || compareSlots(lessonSlotDescriptor(a), lessonSlotDescriptor(b)) + || naturalCompare(a.subjectName, b.subjectName) + || naturalCompare(a.teacherName, b.teacherName) + || naturalCompare(a.classroomName, b.classroomName) + ); +} + +function lessonSlotDescriptor(lesson) { + return { + key: lessonSlotKey(lesson), + orderNumber: lesson.timeSlotOrder, + startTime: lesson.startTime + }; +} + +function buildCombinedWeekBlocks(startDate, endDate, lessons) { + const dates = datesBetween(startDate, endDate); + const weekGroups = new Map(); + dates.forEach(date => { + const key = weekStart(date); + if (!weekGroups.has(key)) weekGroups.set(key, []); + weekGroups.get(key).push(date); + }); + + const weekStarts = Array.from(weekGroups.keys()).sort((a, b) => naturalCompare(a, b)); + const blocks = []; + for (let index = 0; index < weekStarts.length; index += 2) { + const firstWeek = weekGroups.get(weekStarts[index]) || []; + const secondWeek = weekGroups.get(weekStarts[index + 1]) || []; + const blockDates = [...firstWeek, ...secondWeek]; + const firstParity = weekParity(firstWeek, lessons) || 'ODD'; + const secondParity = weekParity(secondWeek, lessons) || (firstParity === 'ODD' ? 'EVEN' : 'ODD'); + const datesWithFallback = [ + ...firstWeek.map(date => ({ date, fallbackParity: firstParity })), + ...secondWeek.map(date => ({ date, fallbackParity: secondParity })) + ]; + blocks.push({ + startDate: blockDates[0], + endDate: blockDates[blockDates.length - 1], + datesByDay: buildDatesByDay(datesWithFallback, lessons), + label: `Период ${formatDate(blockDates[0])} - ${formatDate(blockDates[blockDates.length - 1])}` + }); + } + return blocks; +} + +function weekParity(dates, lessons) { + for (const date of dates) { + const parity = parityForDate(date, lessons); + if (parity) return parity; + } + return null; +} + +function buildDatesByDay(datesWithFallback, lessons) { + const datesByDay = {}; + datesWithFallback.forEach(({ date, fallbackParity }) => { + const day = isoDay(date); + const parity = parityForDate(date, lessons) || fallbackParity; + if (!datesByDay[day]) datesByDay[day] = {}; + datesByDay[day][parity] = date; + }); + return datesByDay; +} + +function parityForDate(date, lessons) { + const lesson = lessons.find(item => item.date === date && ['ODD', 'EVEN'].includes(item.parity)); + if (lesson?.parity) return lesson.parity; + const withWeek = lessons.find(item => item.date === date && item.weekNumber != null); + if (withWeek?.weekNumber != null) return Number(withWeek.weekNumber) % 2 === 0 ? 'EVEN' : 'ODD'; + return null; +} + +function combinedWeekCaption(block) { + return CELL_PARITY_LAYOUT + .map(({ parity, label }) => { + const dates = DAY_ORDER.map(day => block.datesByDay[day]?.[parity]).filter(Boolean); + if (!dates.length) return `${label}: дат нет`; + return `${label}: ${formatDate(dates[0])} - ${formatDate(dates[dates.length - 1])}`; + }) + .join('; '); } function lessonSlotKey(lesson) { @@ -299,10 +556,20 @@ function weekStart(value) { return toIsoDate(date); } -function addDaysToIso(value, days) { - const date = parseIsoDate(value); - date.setDate(date.getDate() + days); - return toIsoDate(date); +function twoWeekRange(value) { + const selected = parseIsoDate(value); + const start = addDays(selected, 1 - isoDay(value)); + const end = addDays(start, 13); + return { + startDate: toIsoDate(start), + endDate: toIsoDate(end) + }; +} + +function addDays(date, days) { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; } function parseIsoDate(value) { @@ -310,8 +577,21 @@ function parseIsoDate(value) { return new Date(year, month - 1, day); } -function dayName(value) { - return parseIsoDate(value).toLocaleDateString('ru-RU', { weekday: 'long' }); +function datesBetween(startDate, endDate) { + const dates = []; + for (let date = parseIsoDate(startDate); date <= parseIsoDate(endDate); date.setDate(date.getDate() + 1)) { + dates.push(toIsoDate(date)); + } + return dates; +} + +function isoDay(value) { + const day = parseIsoDate(value).getDay(); + return day === 0 ? 7 : day; +} + +function dayShort(dayOfWeek) { + return DAY_SHORT_LABELS[Number(dayOfWeek)] || '-'; } function formatDate(value) { @@ -320,6 +600,11 @@ function formatDate(value) { return `${day}.${month}.${year}`; } +function formatDateShort(value) { + const [, month, day] = String(value).split('-'); + return `${day}.${month}`; +} + function trimTime(value) { return String(value || '').slice(0, 5); } @@ -340,6 +625,14 @@ function lessonCountLabel(count) { return `${count} занятий`; } +function tableCountLabel(count) { + const tail = count % 100; + if (tail >= 11 && tail <= 14) return `${count} таблиц`; + if (count % 10 === 1) return `${count} таблица`; + if ([2, 3, 4].includes(count % 10)) return `${count} таблицы`; + return `${count} таблиц`; +} + function compareSlots(a, b) { const first = Number(a.orderNumber || a.key); const second = Number(b.orderNumber || b.key); diff --git a/frontend/admin/views/schedule-view.html b/frontend/admin/views/schedule-view.html index 2c93be6..4cec711 100644 --- a/frontend/admin/views/schedule-view.html +++ b/frontend/admin/views/schedule-view.html @@ -6,12 +6,8 @@
- - -
-
- - + +
@@ -58,6 +54,15 @@
+
+ + +
- Время - Дата - Время
${renderUnifiedSlot(states[0].state)}${halves} - ${escapeHtml(slot.orderLabel)} - ${escapeHtml(slot.timeLabel)} - ${slot.id ? `ID ${escapeHtml(slot.id)}` : ''} - - ${lessons.length ? lessons.map(renderLessonCard).join('') : 'Свободно'} -