Добавил визуальную матрицу расписания групп

This commit is contained in:
dipatrik10
2026-05-27 21:18:57 +03:00
parent 467f78c3d0
commit c19a9b34c7
4 changed files with 949 additions and 24 deletions

View File

@@ -138,7 +138,7 @@ frontend/
| `subjects` | Дисциплины | `/api/subjects` |
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` |
| `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` |
| `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` |
| `database` | Тенанты | `/api/database` |
@@ -150,7 +150,7 @@ frontend/
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку.
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.

View File

@@ -312,6 +312,389 @@
margin-top: 1rem;
}
.schedule-visual-handle {
position: fixed;
top: 50%;
right: 0;
z-index: 905;
width: 42px;
height: 74px;
border: 1px solid var(--bg-card-border);
border-right: 0;
border-radius: 10px 0 0 10px;
background: color-mix(in srgb, var(--bg-card) 92%, var(--accent));
color: var(--text-primary);
font: inherit;
font-size: 1.8rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.2);
transform: translateY(-50%);
transition: right var(--transition), background var(--transition), border-color var(--transition), color var(--transition);
}
.schedule-visual-handle:hover,
.schedule-visual-handle.open {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 20%, var(--bg-card));
color: var(--accent-hover);
}
.schedule-visual-handle.open {
right: min(92vw, 1180px);
}
.schedule-visual-backdrop {
position: fixed;
inset: 0;
z-index: 880;
background: rgba(0, 0, 0, 0.24);
backdrop-filter: blur(2px);
}
.schedule-visual-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 900;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: min(92vw, 1180px);
height: 100vh;
padding: 1rem;
background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-card));
border-left: 1px solid var(--bg-card-border);
box-shadow: -22px 0 55px rgba(0, 0, 0, 0.34);
transform: translateX(100%);
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.schedule-visual-drawer.open {
transform: translateX(0);
}
.schedule-visual-header {
display: grid;
gap: 1rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid var(--bg-card-border);
}
.schedule-visual-header-main,
.schedule-visual-filter-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.schedule-visual-header h2 {
margin: 0 0 0.25rem;
color: var(--text-primary);
font-size: 1rem;
text-transform: none;
letter-spacing: 0;
}
.schedule-visual-toolbar {
display: grid;
gap: 0.55rem;
}
.schedule-visual-filter-head {
align-items: center;
}
.schedule-visual-filter-head > span {
color: var(--text-secondary);
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.schedule-visual-filter-head div {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.4rem;
}
.schedule-visual-group-filter {
display: flex;
gap: 0.4rem;
overflow-x: auto;
padding-bottom: 0.15rem;
}
.schedule-visual-group-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 30px;
padding: 0.25rem 0.58rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition);
}
.schedule-visual-group-chip:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.schedule-visual-group-chip input {
width: 0.9rem;
height: 0.9rem;
margin: 0;
accent-color: var(--accent);
}
.schedule-visual-group-chip:has(input:checked) {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 14%, var(--bg-input));
color: var(--text-primary);
}
.schedule-visual-stage {
min-height: 0;
padding-top: 0.85rem;
overflow: hidden;
}
.schedule-visual-state {
height: 100%;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.schedule-visual-table-wrap {
width: 100%;
height: 100%;
overflow: auto;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-primary);
}
.schedule-visual-table {
min-width: 760px;
border-collapse: collapse;
table-layout: fixed;
}
.schedule-visual-table th,
.schedule-visual-table td {
border: 1px solid var(--bg-card-border);
}
.schedule-visual-table th {
position: sticky;
top: 0;
z-index: 6;
min-width: 168px;
padding: 0.52rem;
background: var(--bg-input);
color: var(--text-primary);
text-align: center;
font-size: 0.78rem;
font-weight: 800;
text-transform: none;
letter-spacing: 0;
}
.schedule-visual-table .schedule-visual-day-head,
.schedule-visual-table .schedule-visual-time-head {
z-index: 8;
min-width: 96px;
width: 96px;
}
.schedule-visual-table .schedule-visual-day-head {
left: 0;
}
.schedule-visual-table .schedule-visual-time-head {
left: 96px;
}
.schedule-visual-day-cell,
.schedule-visual-time-cell {
position: sticky;
background: var(--bg-input);
color: var(--text-primary);
box-shadow: 1px 0 0 var(--bg-card-border);
}
.schedule-visual-day-cell {
left: 0;
z-index: 4;
width: 96px;
min-width: 96px;
padding: 0.52rem;
vertical-align: top;
font-weight: 800;
font-size: 0.74rem;
line-height: 1.15;
overflow-wrap: anywhere;
word-break: break-word;
hyphens: auto;
}
.schedule-visual-time-cell {
left: 96px;
z-index: 4;
width: 96px;
min-width: 96px;
padding: 0.48rem;
vertical-align: middle;
}
.schedule-visual-time-cell strong,
.schedule-visual-time-cell span {
display: block;
line-height: 1.15;
}
.schedule-visual-time-cell span {
margin-top: 0.2rem;
color: var(--text-secondary);
font-size: 0.66rem;
}
.schedule-visual-lesson-cell {
height: 1px;
min-width: 168px;
padding: 0 !important;
vertical-align: stretch;
background: var(--bg-primary);
}
.schedule-visual-lesson-cell-empty {
background: color-mix(in srgb, var(--bg-input) 42%, var(--bg-primary));
}
.schedule-visual-unified {
min-height: 96px;
height: 100%;
display: flex;
flex-direction: column;
gap: 0.22rem;
padding: 0.34rem;
}
.schedule-visual-unified {
justify-content: flex-start;
background: color-mix(in srgb, var(--accent) 5%, var(--bg-primary));
}
.schedule-visual-half {
position: relative;
min-height: 56px;
display: flex;
flex-direction: column;
gap: 0.22rem;
padding: 1.28rem 0.34rem 0.34rem;
background: color-mix(in srgb, var(--accent) 13%, var(--bg-primary));
}
.schedule-visual-half + .schedule-visual-half {
border-top: 1px solid var(--bg-card-border);
}
.schedule-visual-half-even {
background: color-mix(in srgb, var(--success) 8%, var(--bg-primary));
}
.schedule-visual-half-label {
position: absolute;
top: 0.35rem;
left: 0.45rem;
max-width: calc(100% - 0.9rem);
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: var(--bg-input);
color: var(--text-secondary);
font-size: 0.58rem;
font-weight: 800;
line-height: 1.1;
white-space: nowrap;
}
.schedule-visual-half-odd .schedule-visual-half-label {
color: var(--accent-hover);
}
.schedule-visual-half-even .schedule-visual-half-label {
color: var(--success);
}
.schedule-visual-lesson {
display: grid;
gap: 0.12rem;
padding: 0.34rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-card);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.schedule-visual-lesson strong {
color: var(--text-primary);
font-size: 0.7rem;
line-height: 1.18;
}
.schedule-visual-lesson strong span {
color: var(--accent-hover);
font-weight: 800;
}
.schedule-visual-lesson small {
color: var(--text-secondary);
font-size: 0.6rem;
line-height: 1.14;
}
.schedule-visual-free {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
color: var(--text-secondary);
font-size: 0.78rem;
}
@media (max-width: 640px) {
.schedule-visual-drawer {
width: 100vw;
padding: 0.75rem;
}
.schedule-visual-handle.open {
right: calc(100vw - 42px);
}
.schedule-visual-header {
display: grid;
grid-template-columns: 1fr;
}
.schedule-visual-header-main,
.schedule-visual-filter-head {
display: grid;
grid-template-columns: 1fr;
}
}
.calendar-fill-panel {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr)) auto;
@@ -391,10 +774,9 @@
}
.calendar-semesters-grid {
--calendar-semester-block-height: 194px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
grid-auto-rows: auto;
align-items: stretch;
gap: 0.65rem;
padding: 0.65rem;
@@ -403,8 +785,8 @@
.calendar-semester-block {
display: flex;
flex-direction: column;
height: var(--calendar-semester-block-height);
min-height: 100%;
height: auto;
min-height: 0;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-card);
@@ -433,11 +815,11 @@
}
.calendar-semester-table-wrap {
flex: 1;
flex: 1 1 auto;
display: flex;
min-height: 0;
min-height: 151px;
overflow-x: auto;
overflow-y: hidden;
overflow-y: visible;
background: var(--bg-primary);
scrollbar-width: none;
}
@@ -450,7 +832,7 @@
.calendar-semester-table {
width: max-content;
min-width: 100%;
height: 100%;
min-height: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 0.62rem;
@@ -607,19 +989,29 @@
width: 34px;
min-width: 34px;
height: 34px;
box-sizing: border-box;
display: inline-grid;
place-items: center;
border-radius: 8px;
background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, transparent);
border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 56%, transparent);
color: var(--text-primary);
font-size: 0.86rem;
font-weight: 800;
line-height: 1;
padding-top: 2px;
padding: 0;
text-align: center;
}
.calendar-day-tooltip-code span {
display: block;
transform: translateY(2px);
}
.calendar-day-tooltip-body {
display: grid;
gap: 0.45rem;

View File

@@ -17,12 +17,18 @@ const PARITY_LABELS = {
EVEN: 'чётная'
};
const CELL_PARITY_LAYOUT = [
{ parity: 'ODD', label: 'Нечётная' },
{ parity: 'EVEN', label: 'Чётная' }
];
const SEMESTER_LABELS = {
autumn: 'осенний',
spring: 'весенний'
};
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
const ACADEMIC_HOURS_PER_SLOT = 2;
const LESSON_TYPE_LIMITS = [
{
@@ -70,6 +76,15 @@ export async function initSchedule() {
const slotAddButton = document.getElementById('schedule-slot-add');
const rulesTbody = document.getElementById('schedule-tbody');
const refreshRulesButton = document.getElementById('schedule-refresh');
const visualToggleButton = document.getElementById('schedule-visual-toggle');
const visualCloseButton = document.getElementById('schedule-visual-close');
const visualDrawer = document.getElementById('schedule-visual-drawer');
const visualBackdrop = document.getElementById('schedule-visual-backdrop');
const visualStage = document.getElementById('schedule-visual-stage');
const visualSummary = document.getElementById('schedule-visual-summary');
const visualGroupFilter = document.getElementById('schedule-visual-group-filter');
const visualUseFormButton = document.getElementById('schedule-visual-use-form');
const visualSelectAllButton = document.getElementById('schedule-visual-select-all');
let rules = [];
let subjects = [];
@@ -80,6 +95,8 @@ export async function initSchedule() {
let subgroups = [];
let timeSlots = [];
let semesters = [];
let selectedVisualGroupIds = new Set();
let visualGroupFilterTouched = false;
bindEvents();
@@ -96,6 +113,23 @@ export async function initSchedule() {
refreshRulesButton?.addEventListener('click', loadRules);
ruleResetButton?.addEventListener('click', resetRuleForm);
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
visualCloseButton?.addEventListener('click', closeVisualDrawer);
visualBackdrop?.addEventListener('click', closeVisualDrawer);
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
visualUseFormButton?.addEventListener('click', () => {
visualGroupFilterTouched = false;
selectedVisualGroupIds = new Set();
renderVisualSchedule();
});
visualSelectAllButton?.addEventListener('click', () => {
selectedVisualGroupIds = new Set(collectVisualGroups().map(group => String(group.id)));
visualGroupFilterTouched = true;
renderVisualSchedule();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeVisualDrawer();
});
ruleSlotsContainer.addEventListener('click', (event) => {
const removeButton = event.target.closest('.schedule-slot-remove');
@@ -166,14 +200,17 @@ export async function initSchedule() {
try {
rules = await api.get('/api/admin/schedule-rules');
renderRules();
renderVisualSchedule();
} catch (error) {
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
setVisualState('Ошибка загрузки правил расписания');
}
}
function renderRules() {
if (!rules.length) {
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>';
renderVisualSchedule();
return;
}
rulesTbody.innerHTML = rules.map(rule => `
@@ -192,6 +229,326 @@ export async function initSchedule() {
`).join('');
}
function toggleVisualDrawer() {
if (!visualDrawer) return;
if (visualDrawer.classList.contains('open')) {
closeVisualDrawer();
} else {
openVisualDrawer();
}
}
function openVisualDrawer() {
if (!visualDrawer) return;
visualDrawer.classList.add('open');
visualDrawer.setAttribute('aria-hidden', 'false');
visualToggleButton?.classList.add('open');
visualToggleButton?.setAttribute('aria-expanded', 'true');
visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
if (visualToggleButton) visualToggleButton.textContent = '';
if (visualBackdrop) visualBackdrop.hidden = false;
}
function closeVisualDrawer() {
if (!visualDrawer) return;
visualDrawer.classList.remove('open');
visualDrawer.setAttribute('aria-hidden', 'true');
visualToggleButton?.classList.remove('open');
visualToggleButton?.setAttribute('aria-expanded', 'false');
visualToggleButton?.setAttribute('aria-label', 'Показать визуальное расписание');
if (visualToggleButton) visualToggleButton.textContent = '';
if (visualBackdrop) visualBackdrop.hidden = true;
}
function renderVisualSchedule() {
if (!visualStage || !visualSummary) return;
if (!rules.length) {
visualSummary.textContent = 'Нет сохранённых правил';
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
return;
}
const allGroups = collectVisualGroups();
syncVisualGroupFilter(allGroups);
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules();
if (!allGroups.length || !slots.length) {
visualSummary.textContent = 'Недостаточно данных для построения';
setVisualState('У правил должны быть группы и слоты занятий');
return;
}
if (!columns.length) {
visualSummary.textContent = 'Выберите группы для просмотра';
setVisualState('Отметьте одну или несколько групп в фильтре');
return;
}
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds);
const rows = buildVisualRows(columns, slots, lessonsByCell);
if (!rows.length) {
visualSummary.textContent = `${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
setVisualState('Для выбранных групп нет активных пар в сохранённых правилах');
return;
}
visualSummary.textContent = `${rules.length} ${ruleCountLabel(rules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
visualStage.innerHTML = `
<div class="schedule-visual-table-wrap">
<table class="schedule-visual-table">
<thead>
<tr>
<th class="schedule-visual-day-head">День</th>
<th class="schedule-visual-time-head">Время</th>
${columns.map(group => `<th>${escapeHtml(group.name)}</th>`).join('')}
</tr>
</thead>
<tbody>${rows.map(renderVisualRow).join('')}</tbody>
</table>
</div>
`;
}
function buildVisualRows(columns, slots, lessonsByCell) {
const rows = [];
DAY_OPTIONS.forEach(day => {
const dayRows = slots
.map(slot => ({
day,
slot,
cells: columns.map(group => ({
group,
lessons: lessonsByCell.get(visualCellKey(group.id, day.value, slot.key)) || []
}))
}))
.filter(row => row.cells.some(cell => cell.lessons.length));
dayRows.forEach((row, index) => {
rows.push({
...row,
dayRowspan: index === 0 ? dayRows.length : 0
});
});
});
return rows;
}
function renderVisualRow(row) {
return `
<tr>
${row.dayRowspan ? `<td class="schedule-visual-day-cell" rowspan="${row.dayRowspan}">${escapeHtml(row.day.label)}</td>` : ''}
<td class="schedule-visual-time-cell">
<strong>${escapeHtml(row.slot.orderLabel)}</strong>
<span>${escapeHtml(row.slot.timeLabel)}</span>
</td>
${row.cells.map(cell => {
const emptyClass = cell.lessons.length ? '' : ' schedule-visual-lesson-cell-empty';
return `<td class="schedule-visual-lesson-cell${emptyClass}">${renderVisualCellContent(cell.lessons)}</td>`;
}).join('')}
</tr>
`;
}
function buildVisualLessonsByCell(allowedGroupIds) {
const grouped = new Map();
const activeWeeks = buildActiveWeeksByRuleSlot();
rules.forEach(rule => {
(rule.slots || []).forEach((slot, index) => {
const slotKey = visualRuleSlotKey(slot, index);
(rule.groupIds || []).forEach((groupId, groupIndex) => {
if (!allowedGroupIds.has(String(groupId))) return;
const weeks = activeWeeks.get(activeWeekKey(rule.id, groupId, slotKey)) || [];
if (!weeks.length) return;
const lesson = {
id: `${rule.id}:${slotKey}:${groupId}`,
parity: slot.parity || 'BOTH',
subjectName: rule.subjectName || 'Без дисциплины',
teacherName: slot.teacherName || '',
classroomName: slot.classroomName || '',
lessonTypeName: slot.lessonTypeName || '',
lessonFormat: slot.lessonFormat || '',
subgroupNames: slotSubgroupNames(slot),
weekText: compactWeekRanges(weeks),
groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId)
};
const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot));
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
});
});
});
grouped.forEach(items => items.sort(compareVisualLessons));
return grouped;
}
function buildActiveWeeksByRuleSlot() {
const activeWeeks = new Map();
rules.forEach(rule => {
const maxWeeks = maxWeeksForRule(rule);
(rule.groupIds || []).forEach(groupId => {
LESSON_TYPE_LIMITS.forEach(config => {
const totalHours = Number(rule[config.hoursField] || 0);
if (totalHours <= 0) return;
const categorySlots = (rule.slots || [])
.map((slot, index) => ({ slot, index }))
.filter(item => lessonTypeKeyById(item.slot.lessonTypeId) === config.key)
.sort(compareSlotItems);
if (!categorySlots.length) return;
let consumedHours = 0;
const startWeek = Math.max(1, Number(rule[config.startWeekField] || 1));
for (let week = startWeek; week <= maxWeeks && consumedHours < totalHours; week += 1) {
for (const item of categorySlots) {
if (consumedHours >= totalHours) break;
if (!slotAppliesToWeek(item.slot, week)) continue;
const key = activeWeekKey(rule.id, groupId, visualRuleSlotKey(item.slot, item.index));
if (!activeWeeks.has(key)) activeWeeks.set(key, []);
activeWeeks.get(key).push(week);
consumedHours += ACADEMIC_HOURS_PER_SLOT;
}
}
});
});
});
return activeWeeks;
}
function renderVisualCellContent(lessons) {
if (!lessons.length) return '';
const oddLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'ODD');
const evenLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'EVEN');
const shouldSplit = oddLessons.length > 0
&& evenLessons.length > 0
&& visualLessonSignature(oddLessons) !== visualLessonSignature(evenLessons);
if (!shouldSplit) {
return `<div class="schedule-visual-unified">${uniqueVisualLessons(lessons).map(renderVisualLesson).join('')}</div>`;
}
return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
const parityLessons = parity === 'ODD' ? oddLessons : evenLessons;
return `
<div class="schedule-visual-half schedule-visual-half-${parity.toLowerCase()}">
<span class="schedule-visual-half-label">${escapeHtml(label)}</span>
${parityLessons.length ? parityLessons.map(renderVisualLesson).join('') : ''}
</div>
`;
}).join('');
}
function uniqueVisualLessons(lessons) {
const unique = new Map();
lessons.forEach(lesson => {
unique.set(visualLessonSignature([lesson]), lesson);
});
return Array.from(unique.values()).sort(compareVisualLessons);
}
function renderVisualLesson(lesson) {
const meta = [
lesson.lessonTypeName,
lesson.lessonFormat,
lesson.teacherName,
lesson.classroomName
].filter(Boolean).join(' · ');
const subgroupText = lesson.subgroupNames.join(', ');
return `
<article class="schedule-visual-lesson">
<strong>${escapeHtml(lesson.subjectName)}${lesson.weekText ? ` <span>${escapeHtml(lesson.weekText)}</span>` : ''}</strong>
${subgroupText ? `<small>${escapeHtml(subgroupText)}</small>` : ''}
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
</article>
`;
}
function collectVisualGroups() {
const byId = new Map();
rules.forEach(rule => {
(rule.groupIds || []).forEach((id, index) => {
if (!byId.has(String(id))) {
byId.set(String(id), {
id,
name: (rule.groupNames || [])[index] || groupNameById(id)
});
}
});
});
return Array.from(byId.values()).sort((a, b) => naturalCompare(a.name, b.name));
}
function collectVisualSlotsFromRules() {
const byKey = new Map();
rules.flatMap(rule => rule.slots || []).forEach(slot => {
const key = visualSlotKey(slot);
if (!byKey.has(key)) {
byKey.set(key, {
key,
orderNumber: slot.timeSlotOrder,
orderLabel: slot.timeSlotOrder ? `${slot.timeSlotOrder} пара` : 'Пара',
timeLabel: slot.timeSlotLabel || 'Время не указано'
});
}
});
return Array.from(byKey.values()).sort(compareVisualSlots);
}
function visualSlotFromTimeSlot(slot) {
return {
key: String(slot.id),
orderNumber: slot.orderNumber,
orderLabel: slot.orderNumber ? `${slot.orderNumber} пара` : 'Пара',
timeLabel: slot.startTime || slot.endTime ? `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}` : 'Время не указано'
};
}
function setVisualState(message) {
if (!visualStage) return;
visualStage.innerHTML = `<div class="loading-row schedule-visual-state">${escapeHtml(message)}</div>`;
}
function handleVisualGroupFilterChange(event) {
if (!event.target.matches('input[type="checkbox"]')) return;
selectedVisualGroupIds = new Set(Array.from(visualGroupFilter.querySelectorAll('input[type="checkbox"]:checked'))
.map(input => String(input.value)));
visualGroupFilterTouched = true;
renderVisualSchedule();
}
function syncVisualGroupFilter(allGroups) {
ensureVisualGroupSelection(allGroups);
if (!visualGroupFilter) return;
if (!allGroups.length) {
visualGroupFilter.innerHTML = '<span class="muted">Нет групп в правилах</span>';
return;
}
visualGroupFilter.innerHTML = allGroups.map(group => {
const id = String(group.id);
return `
<label class="schedule-visual-group-chip">
<input type="checkbox" value="${escapeHtml(id)}" ${selectedVisualGroupIds.has(id) ? 'checked' : ''}>
<span>${escapeHtml(group.name)}</span>
</label>
`;
}).join('');
}
function ensureVisualGroupSelection(allGroups) {
const availableIds = new Set(allGroups.map(group => String(group.id)));
selectedVisualGroupIds = new Set(Array.from(selectedVisualGroupIds).filter(id => availableIds.has(id)));
if (visualGroupFilterTouched) return;
const formIds = selectedRuleGroupIds()
.map(String)
.filter(id => availableIds.has(id));
if (!visualGroupFilterTouched && formIds.length) {
selectedVisualGroupIds = new Set(formIds);
return;
}
if (!selectedVisualGroupIds.size && allGroups.length) {
selectedVisualGroupIds = new Set([String(allGroups[0].id)]);
}
}
function renderRuleSlotsSummary(slots) {
if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
return slots.map(slot => {
@@ -712,4 +1069,145 @@ export async function initSchedule() {
select.dispatchEvent(new Event('change', { bubbles: true }));
});
}
function visualCellKey(groupId, dayOfWeek, slotKey) {
return `${groupId}:${dayOfWeek}:${slotKey}`;
}
function visualSlotKey(slot) {
return String(slot.timeSlotId ?? slot.timeSlotOrder ?? slot.timeSlotLabel ?? 'unknown');
}
function visualRuleSlotKey(slot, index) {
return String(slot.id ?? `${visualSlotKey(slot)}:${slot.dayOfWeek}:${slot.parity}:${index}`);
}
function activeWeekKey(ruleId, groupId, slotKey) {
return `${ruleId}:${groupId}:${slotKey}`;
}
function slotAppliesToWeek(slot, week) {
if (!slot.parity || slot.parity === 'BOTH') return true;
const weekParity = week % 2 === 0 ? 'EVEN' : 'ODD';
return slot.parity === weekParity;
}
function maxWeeksForRule(rule) {
const semester = semesters.find(item => String(item.id) === String(rule.semesterId));
if (semester?.startDate && semester?.endDate) {
const start = parseIsoDate(semester.startDate);
const end = parseIsoDate(semester.endDate);
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
return Math.max(1, Math.ceil(days / 7));
}
return 18;
}
function parseIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
return new Date(year, month - 1, day);
}
function compactWeekRanges(weeks) {
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
if (!uniqueWeeks.length) return '';
const ranges = [];
let start = uniqueWeeks[0];
let previous = uniqueWeeks[0];
for (let index = 1; index < uniqueWeeks.length; index += 1) {
const week = uniqueWeeks[index];
if (week === previous + 1) {
previous = week;
continue;
}
ranges.push(weekRangeText(start, previous));
start = week;
previous = week;
}
ranges.push(weekRangeText(start, previous));
return `(${ranges.join(', ')})`;
}
function weekRangeText(start, end) {
return start === end
? `${start} нед.`
: `с ${start} по ${end} нед.`;
}
function compareSlotItems(first, second) {
return Number(first.slot.dayOfWeek || 0) - Number(second.slot.dayOfWeek || 0)
|| compareVisualSlots(slotDescriptor(first.slot), slotDescriptor(second.slot))
|| Number(first.index || 0) - Number(second.index || 0);
}
function slotDescriptor(slot) {
return {
key: visualSlotKey(slot),
orderNumber: slot.timeSlotOrder,
timeLabel: slot.timeSlotLabel
};
}
function compareVisualSlots(first, second) {
const firstOrder = Number(first.orderNumber || first.key);
const secondOrder = Number(second.orderNumber || second.key);
if (!Number.isNaN(firstOrder) && !Number.isNaN(secondOrder) && firstOrder !== secondOrder) {
return firstOrder - secondOrder;
}
return naturalCompare(first.timeLabel, second.timeLabel) || naturalCompare(first.key, second.key);
}
function compareVisualLessons(first, second) {
return naturalCompare(first.subjectName, second.subjectName)
|| naturalCompare(first.weekText, second.weekText)
|| naturalCompare(first.lessonTypeName, second.lessonTypeName)
|| naturalCompare(first.teacherName, second.teacherName);
}
function visualLessonSignature(lessons) {
return lessons
.map(lesson => [
lesson.id,
lesson.subjectName,
lesson.weekText,
lesson.lessonTypeName,
lesson.teacherName,
lesson.classroomName,
lesson.subgroupNames.join(',')
].join(':'))
.sort((a, b) => naturalCompare(a, b))
.join('|');
}
function groupNameById(id) {
return groups.find(group => String(group.id) === String(id))?.name || `Группа ${id}`;
}
function naturalCompare(a, b) {
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
}
function ruleCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return 'правил';
if (count % 10 === 1) return 'правило';
if ([2, 3, 4].includes(count % 10)) return 'правила';
return 'правил';
}
function groupCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return 'групп';
if (count % 10 === 1) return 'группа';
if ([2, 3, 4].includes(count % 10)) return 'группы';
return 'групп';
}
function rowCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return 'строк';
if (count % 10 === 1) return 'строка';
if ([2, 3, 4].includes(count % 10)) return 'строки';
return 'строк';
}
}

View File

@@ -117,3 +117,38 @@
</table>
</div>
</div>
<button type="button"
class="schedule-visual-handle"
id="schedule-visual-toggle"
aria-label="Показать визуальное расписание"
aria-expanded="false"></button>
<div class="schedule-visual-backdrop" id="schedule-visual-backdrop" hidden></div>
<aside class="schedule-visual-drawer" id="schedule-visual-drawer" aria-hidden="true">
<div class="schedule-visual-header">
<div class="schedule-visual-header-main">
<div>
<h2>Визуальное расписание групп</h2>
<span class="muted" id="schedule-visual-summary">Правила ещё не загружены</span>
</div>
<button type="button" class="btn-edit-classroom" id="schedule-visual-close">Свернуть</button>
</div>
<div class="schedule-visual-toolbar">
<div class="schedule-visual-filter-head">
<span>Группы</span>
<div>
<button type="button" class="btn-edit-classroom" id="schedule-visual-use-form">Из формы</button>
<button type="button" class="btn-edit-classroom" id="schedule-visual-select-all">Все</button>
</div>
</div>
<div class="schedule-visual-group-filter" id="schedule-visual-group-filter">
<span class="muted">Группы появятся после загрузки правил</span>
</div>
</div>
</div>
<div class="schedule-visual-stage" id="schedule-visual-stage">
<div class="loading-row schedule-visual-state">Загрузка...</div>
</div>
</aside>