поправил просмотр расписания
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
- добавлены точечные изменения расписания: перенос, отмена, замена;
|
- добавлены точечные изменения расписания: перенос, отмена, замена;
|
||||||
- добавлен расширенный read-only поиск расписания по аудитории, преподавателю, кафедре, группе, дисциплине, типу занятия, паре и чётности;
|
- добавлен расширенный read-only поиск расписания по аудитории, преподавателю, кафедре, группе, дисциплине, типу занятия, паре и чётности;
|
||||||
- добавлены отчёты загруженности по преподавателям, аудиториям, кафедрам, парам и свободным аудиториям;
|
- добавлены отчёты загруженности по преподавателям, аудиториям, кафедрам, парам и свободным аудиториям;
|
||||||
- просмотр расписаний переведён с плоского списка на недельные матричные таблицы;
|
- просмотр расписаний переведён с плоского списка на совмещённые матричные таблицы чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям;
|
||||||
- вкладка загруженности стала общей для аудиторий, преподавателей и кафедр;
|
- вкладка загруженности стала общей для аудиторий, преподавателей и кафедр;
|
||||||
- интерфейсы кафедры и учебного отдела переведены в общий стиль админ-панели через role-based вкладки;
|
- интерфейсы кафедры и учебного отдела переведены в общий стиль админ-панели через role-based вкладки;
|
||||||
- отдельная миграция `V2` удалена, изменения схемы внесены в `V1__init.sql`;
|
- отдельная миграция `V2` удалена, изменения схемы внесены в `V1__init.sql`;
|
||||||
@@ -150,8 +150,11 @@ frontend/admin/js/views/schedule-view.js
|
|||||||
|
|
||||||
Возможности:
|
Возможности:
|
||||||
|
|
||||||
- фильтры по периоду, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности;
|
- фильтры по одной дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности;
|
||||||
- недельные матричные таблицы найденных занятий: строки — даты, столбцы — пары;
|
- двухнедельный диапазон для таблиц автоматически строится от понедельника выбранной недели;
|
||||||
|
- выбор разреза таблиц: автоматически, по группам, по преподавателям или по аудиториям;
|
||||||
|
- совмещённые матричные таблицы найденных занятий: строки — пары, столбцы — дни недели;
|
||||||
|
- нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, одинаковые занятия в обе недели схлопываются в цельную ячейку;
|
||||||
- карточки занятий внутри ячеек с дисциплиной, группами, подгруппами, преподавателем, аудиторией, типом занятия, чётностью и ID слота.
|
- карточки занятий внутри ячеек с дисциплиной, группами, подгруппами, преподавателем, аудиторией, типом занятия, чётностью и ID слота.
|
||||||
|
|
||||||
### Загруженность
|
### Загруженность
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
| **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. |
|
| **Администратор** | `ADMIN` | Полный доступ: пользователи, справочники, тенанты, роли, архивирование и восстановление. |
|
||||||
| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. |
|
| **Учебный отдел** | `EDUCATION_OFFICE` | Редактирование расписания, точечные переносы/замены/отмены, временные слоты, аудитории, загруженность. |
|
||||||
| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. |
|
| **Кафедра** | `DEPARTMENT` | Дисциплины своей кафедры, загрузка дисциплин, привязки преподавателей, комментарии, расписание и нагрузка кафедры. |
|
||||||
| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в недельных матричных таблицах. |
|
| **Просмотр расписаний** | `SCHEDULE_VIEWER` | Read-only просмотр расписаний по группам, преподавателям, аудиториям и кафедрам в совмещённых таблицах чётной/нечётной недели с разрезом по группам, преподавателям или аудиториям. |
|
||||||
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
|
| **Преподаватель** | `TEACHER` | Просмотр своего расписания. В перспективе — подача заявок на перенос. |
|
||||||
| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). |
|
| **Студент** | `STUDENT` | Только просмотр расписания (Read-only). |
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ frontend/
|
|||||||
| `classrooms` | Аудитории | `/api/classrooms` |
|
| `classrooms` | Аудитории | `/api/classrooms` |
|
||||||
| `subjects` | Дисциплины | `/api/subjects` |
|
| `subjects` | Дисциплины | `/api/subjects` |
|
||||||
| `department-workspace` | Кабинет кафедры: дисциплины, импорт, комментарии, преподаватели и нагрузка | `/api/department/*`, `/api/workload/teachers` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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`.
|
- Вкладка `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`.
|
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
|
||||||
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
|
||||||
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Из календарной системы здесь используется список семестров для выбора периода действия правила.
|
- Вкладка `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/admin/schedule-rules`;
|
||||||
- создание точечного изменения пары через `/api/edu-office/schedule/overrides` на уровне API;
|
- создание точечного изменения пары через `/api/edu-office/schedule/overrides` на уровне API;
|
||||||
- матричный просмотр загруженности аудиторий, преподавателей и кафедр через общую вкладку `Загруженность`;
|
- матричный просмотр загруженности аудиторий, преподавателей и кафедр через общую вкладку `Загруженность`;
|
||||||
@@ -264,7 +264,7 @@ export function isAuthenticatedAsAdmin() {
|
|||||||
|
|
||||||
### Просмотр расписаний (`/admin/#schedule-view`)
|
### Просмотр расписаний (`/admin/#schedule-view`)
|
||||||
|
|
||||||
Роль `SCHEDULE_VIEWER` видит только read-only вкладку просмотра расписаний. Доступны фильтры по периоду, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия и чётности. Найденные занятия отображаются недельными таблицами с датами по строкам и парами по столбцам.
|
Роль `SCHEDULE_VIEWER` видит только read-only вкладку просмотра расписаний. Доступны фильтры по дате в периоде, группе, преподавателю, аудитории, кафедре, дисциплине, типу занятия, чётности и разрезу таблиц. Найденные занятия отображаются отдельными совмещёнными таблицами чётной/нечётной недели с парами по строкам и днями недели по столбцам.
|
||||||
|
|
||||||
### Преподаватель (`/teacher/`)
|
### Преподаватель (`/teacher/`)
|
||||||
|
|
||||||
|
|||||||
@@ -592,12 +592,7 @@
|
|||||||
.schedule-view-matrix-table th,
|
.schedule-view-matrix-table th,
|
||||||
.schedule-view-matrix-table td {
|
.schedule-view-matrix-table td {
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
height: 118px;
|
height: 172px;
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-view-matrix-table .schedule-view-date-cell {
|
|
||||||
width: 150px;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-view-lesson-card .lesson-meta + .lesson-meta {
|
.schedule-view-lesson-card .lesson-meta + .lesson-meta {
|
||||||
|
|||||||
@@ -7,19 +7,45 @@ const PARITY_LABELS = {
|
|||||||
ODD: 'нечётная',
|
ODD: 'нечётная',
|
||||||
EVEN: 'чётная'
|
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() {
|
export async function initScheduleView() {
|
||||||
const role = localStorage.getItem('role');
|
const role = localStorage.getItem('role');
|
||||||
const userDepartmentId = localStorage.getItem('departmentId');
|
const userDepartmentId = localStorage.getItem('departmentId');
|
||||||
const startInput = document.getElementById('schedule-view-start');
|
const dateInput = document.getElementById('schedule-view-date');
|
||||||
const endInput = document.getElementById('schedule-view-end');
|
|
||||||
const loadButton = document.getElementById('schedule-view-load');
|
const loadButton = document.getElementById('schedule-view-load');
|
||||||
const departmentField = document.getElementById('schedule-view-department-field');
|
const departmentField = document.getElementById('schedule-view-department-field');
|
||||||
const departmentSelect = document.getElementById('schedule-view-department');
|
const departmentSelect = document.getElementById('schedule-view-department');
|
||||||
const tables = document.getElementById('schedule-view-tables');
|
const tables = document.getElementById('schedule-view-tables');
|
||||||
const countLabel = document.getElementById('schedule-view-count');
|
const countLabel = document.getElementById('schedule-view-count');
|
||||||
|
|
||||||
setDefaultDates(startInput, endInput);
|
setDefaultDate(dateInput);
|
||||||
loadButton?.addEventListener('click', loadData);
|
loadButton?.addEventListener('click', loadData);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -38,9 +64,17 @@ export async function initScheduleView() {
|
|||||||
setTableState('Загрузка...');
|
setTableState('Загрузка...');
|
||||||
countLabel.textContent = 'Загрузка...';
|
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({
|
const params = new URLSearchParams({
|
||||||
startDate: startInput.value,
|
startDate: range.startDate,
|
||||||
endDate: endInput.value
|
endDate: range.endDate
|
||||||
});
|
});
|
||||||
addOptional(params, 'groupId', valueOf('schedule-view-group'));
|
addOptional(params, 'groupId', valueOf('schedule-view-group'));
|
||||||
addOptional(params, 'teacherId', valueOf('schedule-view-teacher'));
|
addOptional(params, 'teacherId', valueOf('schedule-view-teacher'));
|
||||||
@@ -52,7 +86,7 @@ export async function initScheduleView() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const lessons = await api.get(`/api/schedule/search?${params}`);
|
const lessons = await api.get(`/api/schedule/search?${params}`);
|
||||||
renderLessons(lessons);
|
renderLessons(lessons, range);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.message || 'Ошибка загрузки расписания';
|
const message = error.message || 'Ошибка загрузки расписания';
|
||||||
showAlert('schedule-view-alert', message, 'error');
|
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);
|
countLabel.textContent = lessonCountLabel(lessons.length);
|
||||||
if (!lessons.length) {
|
if (!lessons.length) {
|
||||||
setTableState('Занятия не найдены');
|
setTableState('Занятия не найдены');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const slots = collectSlots(lessons);
|
const sections = buildScheduleSections(lessons);
|
||||||
const lessonsByDateSlot = groupLessonsByDateSlot(lessons);
|
if (!sections.length) {
|
||||||
const weeks = groupDatesByWeek(lessons.map(lesson => lesson.date));
|
setTableState('Нет данных для выбранного разреза таблиц');
|
||||||
tables.innerHTML = weeks
|
return;
|
||||||
.map(week => renderWeekTable(week, slots, lessonsByDateSlot))
|
}
|
||||||
|
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('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWeekTable(week, slots, lessonsByDateSlot) {
|
function renderScheduleSection(section, range) {
|
||||||
const rows = week.dates.map(date => `
|
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 => `
|
||||||
|
<th class="room-day-cell">
|
||||||
|
<strong>${escapeHtml(dayShort(day))}</strong>
|
||||||
|
<span>${escapeHtml(DAY_FULL_LABELS[day] || '')}</span>
|
||||||
|
<div class="room-day-parity-dates">
|
||||||
|
${CELL_PARITY_LAYOUT.map(({ parity, label }) => renderDayParityDate(block.datesByDay[day]?.[parity], label)).join('')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
`).join('');
|
||||||
|
const rows = slots.map(slot => `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="axis-cell schedule-view-date-cell">
|
<td class="room-time-cell">
|
||||||
<strong>${escapeHtml(dayTitle(date, lessonsByDateSlot))}</strong>
|
<strong>${escapeHtml(slot.orderLabel)}</strong>
|
||||||
<span>${escapeHtml(formatDate(date))}</span>
|
<span>${escapeHtml(slot.timeLabel)}</span>
|
||||||
|
${slot.id ? `<small>ID ${escapeHtml(slot.id)}</small>` : ''}
|
||||||
</td>
|
</td>
|
||||||
${slots.map(slot => renderSlotCell(date, slot, lessonsByDateSlot)).join('')}
|
${DAY_ORDER.map(day => renderCombinedSlotCell(block.datesByDay[day] || {}, slot, lessonsByDateSlot)).join('')}
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<section class="schedule-view-week-panel">
|
<section class="schedule-view-week-panel">
|
||||||
<div class="room-week-heading">
|
<div class="room-week-heading">
|
||||||
<h3>${escapeHtml(week.label)}</h3>
|
<h3>${escapeHtml(section.title)} · ${escapeHtml(block.label)}</h3>
|
||||||
<span>${escapeHtml(weekCaption(week.dates))}</span>
|
<span>${escapeHtml(`${lessonCountLabel(section.lessons.length)}; ${combinedWeekCaption(block)}`)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="workload-grid-container schedule-view-grid-container">
|
<div class="workload-grid-container schedule-view-grid-container">
|
||||||
<table class="workload-table schedule-view-matrix-table">
|
<table class="workload-table room-week-table schedule-view-matrix-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="top-left-cell">
|
<th class="room-time-heading">Время</th>
|
||||||
<span class="top-label">Время</span>
|
${dayHeaders}
|
||||||
<span class="bottom-label">Дата</span>
|
|
||||||
</th>
|
|
||||||
${slots.map(renderSlotHeader).join('')}
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>${rows}</tbody>
|
<tbody>${rows}</tbody>
|
||||||
@@ -110,23 +170,107 @@ export async function initScheduleView() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSlotHeader(slot) {
|
function renderDayParityDate(date, label) {
|
||||||
|
return `<span>${escapeHtml(label)}: ${date ? escapeHtml(formatDateShort(date)) : '-'}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<td class="room-combined-cell room-single-cell">${renderUnifiedSlot(states[0].state)}</td>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const halves = states.map(({ parity, label, state }) =>
|
||||||
|
renderSlotHalf(state, parity, label)
|
||||||
|
).join('');
|
||||||
|
return `<td class="room-combined-cell">${halves}</td>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<div class="room-week-unified room-week-unified-busy">${state.lessons.map(renderLessonCard).join('')}</div>`;
|
||||||
|
}
|
||||||
|
if (state.kind === 'empty') {
|
||||||
|
return '<div class="room-week-unified room-week-unified-empty"><span class="slot-unavailable">Нет даты</span></div>';
|
||||||
|
}
|
||||||
|
return '<div class="room-week-unified room-week-unified-free"><span class="free-slot">Нет занятий</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSlotHalf(state, parity, label) {
|
||||||
|
if (state.kind === 'busy') {
|
||||||
|
return `
|
||||||
|
<div class="room-week-half room-week-half-${parity.toLowerCase()} room-week-half-busy">
|
||||||
|
<span class="room-half-marker">${escapeHtml(label)}</span>
|
||||||
|
${state.lessons.map(renderLessonCard).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (state.kind === 'empty') {
|
||||||
|
return `
|
||||||
|
<div class="room-week-half room-week-half-${parity.toLowerCase()} room-week-half-empty">
|
||||||
|
<span class="room-half-marker">${escapeHtml(label)}</span>
|
||||||
|
<span class="slot-unavailable">Нет даты</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
return `
|
return `
|
||||||
<th>
|
<div class="room-week-half room-week-half-${parity.toLowerCase()} room-week-half-free">
|
||||||
<strong>${escapeHtml(slot.orderLabel)}</strong>
|
<span class="room-half-marker">${escapeHtml(label)}</span>
|
||||||
<span>${escapeHtml(slot.timeLabel)}</span>
|
<span class="free-slot">Нет занятий</span>
|
||||||
${slot.id ? `<small>ID ${escapeHtml(slot.id)}</small>` : ''}
|
</div>
|
||||||
</th>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSlotCell(date, slot, lessonsByDateSlot) {
|
function slotStatesEqual(first, second) {
|
||||||
const lessons = lessonsByDateSlot.get(dateSlotKey(date, slot.key)) || [];
|
if (first.kind !== second.kind) return false;
|
||||||
return `
|
if (first.kind !== 'busy') return true;
|
||||||
<td class="${lessons.length ? 'workload-busy-cell' : 'workload-free-cell'}">
|
return lessonSetSignature(first.lessons) === lessonSetSignature(second.lessons);
|
||||||
${lessons.length ? lessons.map(renderLessonCard).join('') : '<span class="free-slot">Свободно</span>'}
|
}
|
||||||
</td>
|
|
||||||
`;
|
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) {
|
function renderLessonCard(lesson) {
|
||||||
@@ -178,12 +322,9 @@ async function loadDictionaries(role, userDepartmentId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDefaultDates(startInput, endInput) {
|
function setDefaultDate(dateInput) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const nextWeek = new Date();
|
dateInput.value = toIsoDate(today);
|
||||||
nextWeek.setDate(today.getDate() + 7);
|
|
||||||
startInput.value = toIsoDate(today);
|
|
||||||
endInput.value = toIsoDate(nextWeek);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillSelect(id, items, valueFn, labelFn, emptyLabel) {
|
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(' · ');
|
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) {
|
function collectSlots(lessons) {
|
||||||
const slots = new Map();
|
const slots = new Map();
|
||||||
lessons.forEach(lesson => {
|
lessons.forEach(lesson => {
|
||||||
@@ -252,35 +385,159 @@ function groupLessonsByDateSlot(lessons) {
|
|||||||
return grouped;
|
return grouped;
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupDatesByWeek(dates) {
|
function buildScheduleSections(lessons) {
|
||||||
const groups = new Map();
|
const splitType = resolveSplitType();
|
||||||
Array.from(new Set(dates.filter(Boolean)))
|
const sections = new Map();
|
||||||
.sort((a, b) => naturalCompare(a, b))
|
|
||||||
.forEach(date => {
|
lessons.forEach(lesson => {
|
||||||
const key = weekStart(date);
|
splitEntitiesForLesson(lesson, splitType).forEach(entity => {
|
||||||
if (!groups.has(key)) groups.set(key, []);
|
if (!sections.has(entity.key)) {
|
||||||
groups.get(key).push(date);
|
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,
|
return Array.from(sections.values())
|
||||||
label: `Неделя ${formatDate(startDate)} - ${formatDate(addDaysToIso(startDate, 6))}`
|
.map(section => ({
|
||||||
}));
|
...section,
|
||||||
|
lessons: sortLessons(section.lessons)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => naturalCompare(a.title, b.title));
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayTitle(date, lessonsByDateSlot) {
|
function resolveSplitType() {
|
||||||
for (const [key, lessons] of lessonsByDateSlot.entries()) {
|
const requested = valueOf('schedule-view-split');
|
||||||
if (key.startsWith(`${date}:`) && lessons[0]?.dayName) {
|
if ([SPLIT_GROUPS, SPLIT_TEACHERS, SPLIT_CLASSROOMS].includes(requested)) {
|
||||||
return lessons[0].dayName;
|
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) {
|
function splitEntitiesForLesson(lesson, splitType) {
|
||||||
const first = dates[0];
|
if (splitType === SPLIT_TEACHERS) {
|
||||||
const last = dates[dates.length - 1];
|
return [singleEntity('teacher', lesson.teacherId, lesson.teacherName, 'Преподаватель не указан')];
|
||||||
return `${formatDate(first)} - ${formatDate(last)}`;
|
}
|
||||||
|
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) {
|
function lessonSlotKey(lesson) {
|
||||||
@@ -299,10 +556,20 @@ function weekStart(value) {
|
|||||||
return toIsoDate(date);
|
return toIsoDate(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addDaysToIso(value, days) {
|
function twoWeekRange(value) {
|
||||||
const date = parseIsoDate(value);
|
const selected = parseIsoDate(value);
|
||||||
date.setDate(date.getDate() + days);
|
const start = addDays(selected, 1 - isoDay(value));
|
||||||
return toIsoDate(date);
|
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) {
|
function parseIsoDate(value) {
|
||||||
@@ -310,8 +577,21 @@ function parseIsoDate(value) {
|
|||||||
return new Date(year, month - 1, day);
|
return new Date(year, month - 1, day);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayName(value) {
|
function datesBetween(startDate, endDate) {
|
||||||
return parseIsoDate(value).toLocaleDateString('ru-RU', { weekday: 'long' });
|
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) {
|
function formatDate(value) {
|
||||||
@@ -320,6 +600,11 @@ function formatDate(value) {
|
|||||||
return `${day}.${month}.${year}`;
|
return `${day}.${month}.${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateShort(value) {
|
||||||
|
const [, month, day] = String(value).split('-');
|
||||||
|
return `${day}.${month}`;
|
||||||
|
}
|
||||||
|
|
||||||
function trimTime(value) {
|
function trimTime(value) {
|
||||||
return String(value || '').slice(0, 5);
|
return String(value || '').slice(0, 5);
|
||||||
}
|
}
|
||||||
@@ -340,6 +625,14 @@ function lessonCountLabel(count) {
|
|||||||
return `${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) {
|
function compareSlots(a, b) {
|
||||||
const first = Number(a.orderNumber || a.key);
|
const first = Number(a.orderNumber || a.key);
|
||||||
const second = Number(b.orderNumber || b.key);
|
const second = Number(b.orderNumber || b.key);
|
||||||
|
|||||||
@@ -6,12 +6,8 @@
|
|||||||
|
|
||||||
<div class="schedule-view-filter-grid">
|
<div class="schedule-view-filter-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="schedule-view-start">Начало</label>
|
<label for="schedule-view-date">Дата в периоде</label>
|
||||||
<input type="date" id="schedule-view-start">
|
<input type="date" id="schedule-view-date">
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="schedule-view-end">Окончание</label>
|
|
||||||
<input type="date" id="schedule-view-end">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="schedule-view-group">Группа</label>
|
<label for="schedule-view-group">Группа</label>
|
||||||
@@ -58,6 +54,15 @@
|
|||||||
<option value="EVEN">Чётная</option>
|
<option value="EVEN">Чётная</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="schedule-view-split">Таблицы по</label>
|
||||||
|
<select id="schedule-view-split" data-native-select="true">
|
||||||
|
<option value="auto">Автоматически</option>
|
||||||
|
<option value="groups">Группам</option>
|
||||||
|
<option value="teachers">Преподавателям</option>
|
||||||
|
<option value="classrooms">Аудиториям</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-alert" id="schedule-view-alert" role="alert"></div>
|
<div class="form-alert" id="schedule-view-alert" role="alert"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user