#2 - тест, #5 - готово. Разовое редактирование занятия без изменений правила

This commit is contained in:
Zuev
2026-07-22 00:15:35 +03:00
parent ee876f1acd
commit 92ff87a917
33 changed files with 3324 additions and 251 deletions

View File

@@ -0,0 +1,611 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
const EDIT_ROLES = new Set(['ADMIN', 'EDUCATION_OFFICE']);
const ACTION_LABELS = {
MOVE: 'Перенос',
REPLACE: 'Замена',
CANCEL: 'Отмена'
};
const DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const MONTH_LABELS = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
export function canEditScheduleOverrides(role) {
return EDIT_ROLES.has(role);
}
export function overrideActionForChanges(changes) {
if (changes.dateChanged || changes.timeChanged) return 'MOVE';
if (changes.teacherChanged || changes.classroomChanged || changes.formatChanged) return 'REPLACE';
return null;
}
export function weekDates(value) {
const selected = parseIsoDate(value);
const day = selected.getDay() || 7;
const monday = addDays(selected, 1 - day);
return Array.from({ length: 7 }, (_, index) => toIsoDate(addDays(monday, index)));
}
export function pickMatchingTimeSlot(slots, preferredId, startTime, endTime) {
const safeSlots = Array.isArray(slots) ? slots : [];
const byId = preferredId == null
? null
: safeSlots.find(slot => String(slot.id) === String(preferredId));
if (byId) return byId;
const normalizedStart = trimTime(startTime);
const normalizedEnd = trimTime(endTime);
if (!normalizedStart || !normalizedEnd) return null;
return safeSlots.find(slot => trimTime(slot.startTime) === normalizedStart
&& trimTime(slot.endTime) === normalizedEnd) || null;
}
export function createScheduleOverridePanel({ role, dictionaries, getRange, onChanged }) {
if (!canEditScheduleOverrides(role)) {
return {
setRegistry() {},
openLesson() {},
openRegistry() {}
};
}
const drawer = document.getElementById('schedule-override-drawer');
const form = document.getElementById('schedule-override-form');
const empty = document.getElementById('schedule-override-empty');
const registryButton = document.getElementById('schedule-view-overrides-open');
const registryCount = document.getElementById('schedule-view-overrides-count');
const registry = document.getElementById('schedule-override-registry');
const registryPeriod = document.getElementById('schedule-override-registry-period');
const calendar = document.getElementById('schedule-override-calendar');
const calendarToggle = document.getElementById('schedule-override-calendar-toggle');
const week = document.getElementById('schedule-override-week');
const timeSelect = document.getElementById('schedule-override-time');
const teacherSelect = document.getElementById('schedule-override-teacher');
const classroomSelect = document.getElementById('schedule-override-classroom');
const formatSelect = document.getElementById('schedule-override-format');
const commentInput = document.getElementById('schedule-override-comment');
const restoreButton = document.getElementById('schedule-override-restore');
const cancelButton = document.getElementById('schedule-override-cancel');
const saveButton = document.getElementById('schedule-override-save');
const state = {
overrides: [],
selectedLesson: null,
selectedOverride: null,
source: null,
availability: null,
targetDate: null,
timeSlots: [],
busy: false
};
registryButton.hidden = false;
fillReferenceSelects();
bindEvents();
renderRegistry();
return { setRegistry, openLesson, openRegistry };
function bindEvents() {
drawer.querySelectorAll('[data-override-close]').forEach(button => {
button.addEventListener('click', close);
});
drawer.querySelectorAll('[data-override-tab]').forEach(button => {
button.addEventListener('click', () => activateTab(button.dataset.overrideTab));
});
registryButton.addEventListener('click', openRegistry);
calendarToggle.addEventListener('click', () => {
calendar.hidden = !calendar.hidden;
calendarToggle.textContent = calendar.hidden ? 'Выбрать другую дату' : 'Скрыть календарь семестра';
});
week.addEventListener('click', selectDateFromButton);
calendar.addEventListener('click', selectDateFromButton);
registry.addEventListener('click', event => {
const button = event.target.closest('[data-override-registry-id]');
if (!button) return;
const item = state.overrides.find(override => String(override.id) === button.dataset.overrideRegistryId);
if (item) openOverride(item);
});
[timeSelect, teacherSelect, classroomSelect, formatSelect].forEach(select => {
select.addEventListener('change', updateComparison);
});
form.addEventListener('submit', save);
cancelButton.addEventListener('click', cancelLesson);
restoreButton.addEventListener('click', restoreRule);
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !drawer.hidden) close();
});
}
function setRegistry(overrides, range) {
state.overrides = Array.isArray(overrides) ? overrides : [];
registryCount.textContent = String(state.overrides.length);
const activeRange = range || getRange?.();
registryPeriod.textContent = activeRange
? `${formatDate(activeRange.startDate)}${formatDate(activeRange.endDate)}`
: 'Период не выбран';
renderRegistry();
}
async function openLesson(lesson) {
if (!lesson?.scheduleRuleSlotId) return;
const originalDate = lesson.originalLessonDate || lesson.date;
const existing = state.overrides.find(override =>
String(override.baseRuleSlotId) === String(lesson.scheduleRuleSlotId)
&& override.lessonDate === originalDate
) || null;
await prepareEditor(lesson, existing);
}
async function openOverride(override) {
const syntheticLesson = {
scheduleRuleSlotId: override.baseRuleSlotId,
date: override.targetLessonDate || override.lessonDate,
originalLessonDate: override.lessonDate,
scheduleOverrideId: override.id,
subjectName: override.subjectName,
lessonTypeName: override.lessonTypeName,
groupNames: override.groupNames,
timeSlotId: override.newTimeSlotId || override.sourceTimeSlotId,
timeSlotOrder: override.sourceTimeSlotOrder,
startTime: override.sourceStartTime,
endTime: override.sourceEndTime,
teacherId: override.newTeacherId || override.sourceTeacherId,
teacherName: teacherName(override.newTeacherId) || override.sourceTeacherName,
classroomId: override.newClassroomId || override.sourceClassroomId,
classroomName: classroomName(override.newClassroomId) || override.sourceClassroomName,
lessonFormat: override.newLessonFormat || override.sourceLessonFormat
};
await prepareEditor(syntheticLesson, override);
}
function openRegistry() {
open();
activateTab('registry');
renderRegistry();
}
async function prepareEditor(lesson, existing) {
open();
activateTab('edit');
form.hidden = true;
empty.hidden = false;
empty.textContent = 'Загрузка доступных дат и времени...';
hideAlert('schedule-override-alert');
state.selectedLesson = lesson;
state.selectedOverride = existing;
state.source = sourceFrom(lesson, existing);
state.targetDate = existing?.targetLessonDate || state.source.lessonDate;
try {
state.availability = await api.get(
`/api/edu-office/schedule/overrides/availability?baseRuleSlotId=${encodeURIComponent(state.source.baseRuleSlotId)}`
+ `&lessonDate=${encodeURIComponent(state.source.lessonDate)}`
);
renderDateChoices();
await loadTimeSlots(initialTimePreference(existing));
setEditorValues(existing);
renderSource();
updateComparison();
restoreButton.hidden = !existing;
form.hidden = false;
empty.hidden = true;
} catch (error) {
empty.textContent = error.message || 'Не удалось загрузить данные занятия';
}
}
function sourceFrom(lesson, existing) {
return {
baseRuleSlotId: lesson.scheduleRuleSlotId,
lessonDate: lesson.originalLessonDate || existing?.lessonDate || lesson.date,
subjectName: existing?.subjectName || lesson.subjectName || 'Без дисциплины',
lessonTypeName: existing?.lessonTypeName || lesson.lessonTypeName || 'Тип не указан',
groupNames: existing?.groupNames?.length ? existing.groupNames : (lesson.groupNames || []),
timeSlotId: existing?.sourceTimeSlotId || lesson.timeSlotId,
timeSlotOrder: existing?.sourceTimeSlotOrder || lesson.timeSlotOrder,
startTime: existing?.sourceStartTime || lesson.startTime,
endTime: existing?.sourceEndTime || lesson.endTime,
teacherId: existing?.sourceTeacherId || lesson.teacherId,
teacherName: existing?.sourceTeacherName || lesson.teacherName,
classroomId: existing?.sourceClassroomId || lesson.classroomId,
classroomName: existing?.sourceClassroomName || lesson.classroomName,
lessonFormat: existing?.sourceLessonFormat || lesson.lessonFormat || 'Очно'
};
}
function initialTimePreference(existing) {
return {
id: existing?.newTimeSlotId || state.source.timeSlotId,
startTime: existing?.newTimeSlotId ? null : state.source.startTime,
endTime: existing?.newTimeSlotId ? null : state.source.endTime
};
}
function fillReferenceSelects() {
teacherSelect.innerHTML = (dictionaries.teachers || []).map(teacher =>
`<option value="${escapeHtml(teacher.id)}">${escapeHtml(teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`)}</option>`
).join('');
classroomSelect.innerHTML = (dictionaries.classrooms || []).map(classroom =>
`<option value="${escapeHtml(classroom.id)}">${escapeHtml(classroomOptionLabel(classroom))}</option>`
).join('');
}
function setEditorValues(existing) {
const effectiveTeacherId = existing?.newTeacherId || state.source.teacherId || '';
const effectiveClassroomId = existing?.newClassroomId || state.source.classroomId || '';
ensureSelectOption(
teacherSelect,
effectiveTeacherId,
teacherName(effectiveTeacherId) || state.source.teacherName || `Преподаватель ${effectiveTeacherId}`
);
ensureSelectOption(
classroomSelect,
effectiveClassroomId,
classroomName(effectiveClassroomId) || state.source.classroomName || `Аудитория ${effectiveClassroomId}`
);
teacherSelect.value = String(effectiveTeacherId);
classroomSelect.value = String(effectiveClassroomId);
formatSelect.value = existing?.newLessonFormat || state.source.lessonFormat || 'Очно';
commentInput.value = existing?.comment || '';
}
async function selectDateFromButton(event) {
const button = event.target.closest('[data-override-date]');
if (!button || button.disabled || button.dataset.overrideDate === state.targetDate) return;
state.targetDate = button.dataset.overrideDate;
renderDateChoices();
await loadTimeSlots({
id: state.source.timeSlotId,
startTime: state.source.startTime,
endTime: state.source.endTime
});
updateComparison();
}
function renderDateChoices() {
const available = new Set(state.availability?.availableDates || []);
week.innerHTML = weekDates(state.source.lessonDate).map((date, index) => dateButton(date, DAY_LABELS[index], available)).join('');
calendar.innerHTML = renderSemesterCalendar(
state.availability.semesterStartDate,
state.availability.semesterEndDate,
available,
state.targetDate
);
}
function dateButton(date, label, available) {
const selected = date === state.targetDate ? ' active' : '';
const disabled = available.has(date) ? '' : ' disabled';
return `
<button type="button" class="schedule-override-date${selected}" data-override-date="${escapeHtml(date)}"${disabled}>
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(formatDateShort(date))}</strong>
</button>
`;
}
async function loadTimeSlots(preference) {
timeSelect.disabled = true;
timeSelect.innerHTML = '<option value="">Загрузка...</option>';
state.timeSlots = await api.get(
`/api/admin/time-slots/effective?date=${encodeURIComponent(state.targetDate)}`
);
const selected = pickMatchingTimeSlot(
state.timeSlots,
preference?.id,
preference?.startTime,
preference?.endTime
);
timeSelect.innerHTML = '<option value="">Выберите время</option>' + state.timeSlots.map(slot =>
`<option value="${escapeHtml(slot.id)}">${escapeHtml(timeSlotLabel(slot))}</option>`
).join('');
timeSelect.value = selected ? String(selected.id) : '';
timeSelect.disabled = false;
}
function renderSource() {
document.getElementById('schedule-override-before-title').textContent =
`${state.source.subjectName} · ${state.source.lessonTypeName}`;
document.getElementById('schedule-override-before-meta').textContent = [
formatDate(state.source.lessonDate),
intervalLabel(state.source.startTime, state.source.endTime),
state.source.teacherName,
state.source.classroomName,
state.source.lessonFormat,
state.source.groupNames.join(', ')
].filter(Boolean).join(' · ');
}
function updateComparison() {
if (!state.source) return;
const selectedTime = state.timeSlots.find(slot => String(slot.id) === timeSelect.value);
document.getElementById('schedule-override-after-title').textContent =
`${state.source.subjectName} · ${state.source.lessonTypeName}`;
document.getElementById('schedule-override-after-meta').textContent = [
formatDate(state.targetDate),
selectedTime ? intervalLabel(selectedTime.startTime, selectedTime.endTime) : 'Время не выбрано',
teacherName(teacherSelect.value),
classroomName(classroomSelect.value),
formatSelect.value,
state.source.groupNames.join(', ')
].filter(Boolean).join(' · ');
}
async function save(event) {
event.preventDefault();
if (state.busy) return;
hideAlert('schedule-override-alert');
const payload = buildPayload();
if (!payload) return;
await mutate(async () => {
if (state.selectedOverride) {
await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload);
} else {
await api.post('/api/edu-office/schedule/overrides', payload);
}
});
}
function buildPayload() {
if (!timeSelect.value) {
showAlert('schedule-override-alert', 'Выберите время занятия для выбранной даты', 'error');
return null;
}
const dateChanged = state.targetDate !== state.source.lessonDate;
const timeChanged = String(timeSelect.value) !== String(state.source.timeSlotId);
const teacherChanged = String(teacherSelect.value) !== String(state.source.teacherId);
const classroomChanged = String(classroomSelect.value) !== String(state.source.classroomId);
const formatChanged = formatSelect.value !== state.source.lessonFormat;
const action = overrideActionForChanges({
dateChanged,
timeChanged,
teacherChanged,
classroomChanged,
formatChanged
});
if (!action) {
showAlert(
'schedule-override-alert',
state.selectedOverride
? 'Параметры совпадают с правилом. Используйте «Вернуть по правилу»'
: 'Измените дату, время, преподавателя, аудиторию или формат',
'error'
);
return null;
}
return {
baseRuleSlotId: state.source.baseRuleSlotId,
lessonDate: state.source.lessonDate,
targetLessonDate: dateChanged ? state.targetDate : null,
action,
newTimeSlotId: (dateChanged || timeChanged) ? Number(timeSelect.value) : null,
newTeacherId: teacherChanged ? Number(teacherSelect.value) : null,
newClassroomId: classroomChanged ? Number(classroomSelect.value) : null,
newLessonFormat: formatChanged ? formatSelect.value : null,
comment: commentInput.value.trim() || null
};
}
async function cancelLesson() {
if (state.busy || !state.source) return;
const payload = {
baseRuleSlotId: state.source.baseRuleSlotId,
lessonDate: state.source.lessonDate,
targetLessonDate: null,
action: 'CANCEL',
newTimeSlotId: null,
newTeacherId: null,
newClassroomId: null,
newLessonFormat: null,
comment: commentInput.value.trim() || null
};
await mutate(async () => {
if (state.selectedOverride) {
await api.put(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`, payload);
} else {
await api.post('/api/edu-office/schedule/overrides', payload);
}
});
}
async function restoreRule() {
if (state.busy || !state.selectedOverride) return;
await mutate(() => api.delete(`/api/edu-office/schedule/overrides/${state.selectedOverride.id}`));
}
async function mutate(operation) {
setBusy(true);
hideAlert('schedule-override-alert');
try {
await operation();
await onChanged?.();
state.selectedLesson = null;
state.selectedOverride = null;
state.source = null;
form.hidden = true;
empty.hidden = false;
empty.textContent = 'Выберите занятие в расписании или откройте его из реестра изменений.';
openRegistry();
} catch (error) {
showAlert('schedule-override-alert', error.message || 'Не удалось сохранить разовое изменение', 'error');
} finally {
setBusy(false);
}
}
function setBusy(busy) {
state.busy = busy;
saveButton.disabled = busy;
cancelButton.disabled = busy;
restoreButton.disabled = busy;
saveButton.textContent = busy ? 'Сохранение...' : 'Сохранить';
}
function renderRegistry() {
if (!registry) return;
if (!state.overrides.length) {
registry.innerHTML = '<div class="schedule-override-empty">За выбранный период разовых изменений нет.</div>';
return;
}
registry.innerHTML = state.overrides.map(override => {
const target = override.targetLessonDate
? `${formatDate(override.targetLessonDate)}`
: '';
const result = override.action === 'CANCEL'
? 'Занятие отменено'
: [
override.newTimeSlotId ? `слот ${override.newTimeSlotId}` : null,
teacherName(override.newTeacherId),
classroomName(override.newClassroomId),
override.newLessonFormat
].filter(Boolean).join(' · ');
return `
<article class="schedule-override-registry-item">
<div class="schedule-override-registry-topline">
<span class="schedule-override-action action-${escapeHtml(override.action.toLowerCase())}">${escapeHtml(ACTION_LABELS[override.action] || override.action)}</span>
<time>${escapeHtml(formatDate(override.lessonDate))}${escapeHtml(target)}</time>
</div>
<h4>${escapeHtml(override.subjectName || 'Занятие')}</h4>
<p>${escapeHtml([
override.lessonTypeName,
(override.groupNames || []).join(', '),
result || 'Параметры изменены'
].filter(Boolean).join(' · '))}</p>
${override.comment ? `<blockquote>${escapeHtml(override.comment)}</blockquote>` : ''}
<button type="button" class="btn btn-md btn-secondary" data-override-registry-id="${escapeHtml(override.id)}">Открыть</button>
</article>
`;
}).join('');
}
function activateTab(tab) {
drawer.querySelectorAll('[data-override-tab]').forEach(button => {
button.classList.toggle('active', button.dataset.overrideTab === tab);
});
drawer.querySelectorAll('[data-override-section]').forEach(section => {
section.hidden = section.dataset.overrideSection !== tab;
});
}
function open() {
drawer.hidden = false;
drawer.setAttribute('aria-hidden', 'false');
document.body.classList.add('schedule-override-open');
}
function close() {
drawer.hidden = true;
drawer.setAttribute('aria-hidden', 'true');
document.body.classList.remove('schedule-override-open');
}
function teacherName(id) {
if (id == null || id === '') return '';
const teacher = (dictionaries.teachers || []).find(item => String(item.id) === String(id));
return teacher?.fullName || teacher?.username || '';
}
function classroomName(id) {
if (id == null || id === '') return '';
const classroom = (dictionaries.classrooms || []).find(item => String(item.id) === String(id));
return classroom ? classroomOptionLabel(classroom) : '';
}
}
function renderSemesterCalendar(startDate, endDate, available, selectedDate) {
if (!startDate || !endDate) return '';
const start = parseIsoDate(startDate);
const end = parseIsoDate(endDate);
const months = [];
for (let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
cursor <= end;
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) {
months.push(renderMonth(cursor, startDate, endDate, available, selectedDate));
}
return months.join('');
}
function renderMonth(month, semesterStart, semesterEnd, available, selectedDate) {
const year = month.getFullYear();
const monthIndex = month.getMonth();
const firstDay = new Date(year, monthIndex, 1).getDay() || 7;
const dayCount = new Date(year, monthIndex + 1, 0).getDate();
const blanks = Array.from({ length: firstDay - 1 }, () => '<span class="schedule-override-calendar-blank"></span>').join('');
const days = Array.from({ length: dayCount }, (_, index) => {
const date = toIsoDate(new Date(year, monthIndex, index + 1));
const enabled = date >= semesterStart && date <= semesterEnd && available.has(date);
const active = date === selectedDate ? ' active' : '';
return `<button type="button" class="schedule-override-calendar-day${active}" data-override-date="${date}"${enabled ? '' : ' disabled'}>${index + 1}</button>`;
}).join('');
return `
<section class="schedule-override-month">
<h4>${MONTH_LABELS[monthIndex]} ${year}</h4>
<div class="schedule-override-calendar-grid">
${DAY_LABELS.map(label => `<strong>${label}</strong>`).join('')}
${blanks}${days}
</div>
</section>
`;
}
function timeSlotLabel(slot) {
const order = slot.orderNumber ? `${slot.orderNumber} пара · ` : '';
return `${order}${intervalLabel(slot.startTime, slot.endTime)}`;
}
function intervalLabel(start, end) {
return `${trimTime(start)}${trimTime(end)}`;
}
export function classroomOptionLabel(room) {
return room?.name || '';
}
function ensureSelectOption(select, value, label) {
if (value == null || value === '') return;
const exists = Array.from(select.options).some(option => String(option.value) === String(value));
if (exists) return;
const option = document.createElement('option');
option.value = String(value);
option.textContent = label;
select.append(option);
}
function formatDate(value) {
if (!value) return '—';
const [year, month, day] = String(value).split('-');
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);
}
function parseIsoDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
return new Date(year, month - 1, day);
}
function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function toIsoDate(date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-');
}

View File

@@ -1,5 +1,6 @@
import { api, getSession } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
const TARGET_GROUPS = 'groups';
@@ -59,6 +60,7 @@ export async function initScheduleView() {
const tables = document.getElementById('schedule-view-tables');
const entityFields = Array.from(document.querySelectorAll('[data-schedule-view-target-field]'));
const departmentSelect = document.getElementById('schedule-view-department');
const semesterSelect = document.getElementById('schedule-view-semester');
const state = {
dictionaries: {
@@ -71,8 +73,11 @@ export async function initScheduleView() {
activeSectionIndex: 0,
mobileDay: 1,
range: null,
totalLessons: 0
totalLessons: 0,
lessons: [],
overrides: []
};
let overridePanel = createEmptyOverridePanel();
const mobileMedia = window.matchMedia(MOBILE_QUERY);
setDefaultDate(dateInput);
@@ -97,8 +102,22 @@ export async function initScheduleView() {
updatePeriodLabel();
resetResults('Нажмите «Показать», чтобы обновить расписание');
});
semesterSelect?.addEventListener('change', () => {
synchronizeDateWithSemester();
updatePeriodLabel();
resetResults('Нажмите «Показать», чтобы обновить расписание');
});
resultPreviousButton?.addEventListener('click', () => moveResult(-1));
resultNextButton?.addEventListener('click', () => moveResult(1));
tables?.addEventListener('click', event => {
const button = event.target.closest('[data-schedule-override-edit]');
if (!button) return;
const lesson = state.lessons.find(item =>
String(item.scheduleRuleSlotId) === button.dataset.baseRuleSlotId
&& (item.originalLessonDate || item.date) === button.dataset.lessonDate
);
if (lesson) overridePanel.openLesson(lesson);
});
if (typeof mobileMedia.addEventListener === 'function') {
mobileMedia.addEventListener('change', renderActiveSection);
} else if (typeof mobileMedia.addListener === 'function') {
@@ -107,6 +126,13 @@ export async function initScheduleView() {
try {
state.dictionaries = await loadDictionaries(role, userDepartmentId);
selectCurrentSemester();
overridePanel = createScheduleOverridePanel({
role,
dictionaries: state.dictionaries,
getRange: () => state.range,
onChanged: loadData
});
configureDepartmentAccess();
updateEntityFieldVisibility();
await loadData();
@@ -192,7 +218,7 @@ export async function initScheduleView() {
}
const target = activeTarget();
const range = twoWeekRange(dateInput.value);
const range = scheduleRangeForSemester(dateInput.value, selectedSemester());
const previousKey = state.sections[state.activeSectionIndex]?.key;
state.range = range;
updatePeriodLabel(range);
@@ -213,7 +239,14 @@ export async function initScheduleView() {
addOptional(params, 'parity', valueOf('schedule-view-parity'));
try {
const lessons = await api.get(`/api/schedule/search?${params}`);
const [lessons, overrides] = await Promise.all([
api.get(`/api/schedule/search?${params}`),
canEditScheduleOverrides(role)
? api.get(`/api/edu-office/schedule/overrides?startDate=${range.startDate}&endDate=${range.endDate}`)
: Promise.resolve([])
]);
state.overrides = overrides;
overridePanel.setRegistry(overrides, range);
renderLessons(lessons, range, target, previousKey);
} catch (error) {
const message = error.message || 'Ошибка загрузки расписания';
@@ -245,6 +278,7 @@ export async function initScheduleView() {
}
function renderLessons(lessons, range, target, previousKey) {
state.lessons = lessons;
state.totalLessons = lessons.length;
if (!lessons.length) {
state.sections = [];
@@ -581,6 +615,9 @@ export async function initScheduleView() {
lesson.classroomId || lesson.classroomName || '',
lesson.lessonTypeId || lesson.lessonTypeName || '',
lesson.lessonFormat || '',
lesson.scheduleOverrideId || '',
lesson.overrideAction || '',
lesson.originalLessonDate || '',
valueListSignature(lesson.groupIds, lesson.groupNames),
valueListSignature(lesson.subgroupIds, lesson.subgroupNames)
].join(':');
@@ -672,19 +709,36 @@ export async function initScheduleView() {
const weeksText = getLessonWeeksText(lesson);
const weeksBadge = weeksText ? `<span class="lesson-weeks-badge">${escapeHtml(weeksText)}</span>` : '';
const sourceDate = lesson.originalLessonDate || lesson.date;
const overrideBadge = lesson.scheduleOverrideId
? '<span class="schedule-override-badge">Разовая правка</span>'
: '';
const editButton = canEditScheduleOverrides(role) && lesson.scheduleRuleSlotId && sourceDate
? `
<button type="button"
class="schedule-override-edit"
data-schedule-override-edit
data-base-rule-slot-id="${escapeHtml(lesson.scheduleRuleSlotId)}"
data-lesson-date="${escapeHtml(sourceDate)}">
Изменить
</button>
`
: '';
return `
<div class="lesson-card schedule-view-lesson-card">
<div class="lesson-subject" style="display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem;">
<div class="lesson-subject schedule-view-lesson-title" style="display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem;">
<span>${escapeHtml(lesson.subjectName || 'Без дисциплины')}</span>
${weeksBadge}
</div>
${overrideBadge}
<div class="lesson-group">${escapeHtml((lesson.groupNames || []).join(', ') || 'Группа не указана')}</div>
${subgroupText ? `<div class="lesson-subgroup">${escapeHtml(subgroupText)}</div>` : ''}
<div class="lesson-teacher">${escapeHtml(lesson.teacherName || 'Преподаватель не указан')}</div>
<div class="lesson-meta">${escapeHtml(lesson.classroomName || 'Аудитория не указана')}</div>
${meta ? `<div class="lesson-meta">${escapeHtml(meta)}</div>` : ''}
${lesson.scheduleRuleSlotId ? `<div class="lesson-meta">ID слота: ${escapeHtml(lesson.scheduleRuleSlotId)}</div>` : ''}
${editButton}
</div>
`;
}
@@ -699,6 +753,7 @@ export async function initScheduleView() {
state.activeSectionIndex = 0;
state.mobileDay = 1;
state.totalLessons = 0;
state.lessons = [];
renderResultNavigation();
tableTitle.textContent = 'Расписание';
tableCaption.textContent = message;
@@ -714,7 +769,36 @@ export async function initScheduleView() {
periodLabel.textContent = 'Период не выбран';
return;
}
periodLabel.textContent = periodCaption(range || twoWeekRange(dateInput.value));
periodLabel.textContent = periodCaption(
range || scheduleRangeForSemester(dateInput.value, selectedSemester()),
selectedSemester()
);
}
function selectedSemester() {
const semesterId = semesterSelect?.value;
if (!semesterId) return null;
return (state.dictionaries.semesters || [])
.find(semester => String(semester.id) === String(semesterId)) || null;
}
function selectCurrentSemester() {
if (!semesterSelect) return;
const currentSemester = findSemesterForDate(state.dictionaries.semesters, dateInput.value);
semesterSelect.value = currentSemester ? String(currentSemester.id) : '';
synchronizeDateWithSemester();
updatePeriodLabel();
}
function synchronizeDateWithSemester() {
const semester = selectedSemester();
if (!semester) {
dateInput.value = toIsoDate(new Date());
return;
}
if (!dateWithinSemester(dateInput.value, semester)) {
dateInput.value = semester.startDate;
}
}
function groupNameById(id) {
@@ -727,15 +811,23 @@ export async function initScheduleView() {
}
}
function createEmptyOverridePanel() {
return {
setRegistry() {},
openLesson() {},
openRegistry() {}
};
}
async function loadDictionaries(role, userDepartmentId) {
const [groups, teachers, classrooms, departments, subjects, lessonTypes, academicYears] = await Promise.all([
const [groups, teachers, classrooms, departments, subjects, lessonTypes, semesters] = await Promise.all([
api.get('/api/groups'),
api.get('/api/users/teachers'),
api.get('/api/classrooms'),
api.get('/api/departments'),
api.get('/api/subjects'),
api.get('/api/lesson-types'),
api.get('/api/admin/calendar/years').catch(() => [])
api.get('/api/schedule/semesters')
]);
const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId
@@ -748,13 +840,14 @@ async function loadDictionaries(role, userDepartmentId) {
fillSelect('schedule-view-department', departments, item => item.id, item => item.departmentName, 'Все кафедры');
fillSelect('schedule-view-subject', subjects, item => item.id, item => item.name, 'Все дисциплины');
fillSelect('schedule-view-lesson-type', lessonTypes, item => item.id, item => item.name, 'Все типы');
fillSelect('schedule-view-semester', semesters, item => item.id, semesterOptionLabel, 'По текущей дате');
return {
groups,
teachers,
classrooms,
departments,
semesters: (academicYears || []).flatMap(year => year.semesters || [])
semesters: semesters || []
};
}
@@ -975,8 +1068,43 @@ function mobileDayCaption(datesByParity) {
return 'Нечётная / Чётная';
}
function periodCaption(range) {
return 'Учебное расписание';
function periodCaption(range, semester = null) {
if (!range) return 'Учебное расписание';
const rangeLabel = `${formatDate(range.startDate)}${formatDate(range.endDate)}`;
return semester ? `${semesterOptionLabel(semester)} · ${rangeLabel}` : rangeLabel;
}
export function semesterOptionLabel(semester) {
const type = String(semester?.semesterType || '').toLowerCase();
const typeLabel = type === 'autumn'
? 'Осенний семестр'
: (type === 'spring' ? 'Весенний семестр' : 'Семестр');
const academicYear = semester?.academicYearTitle || 'Учебный год';
const dates = semester?.startDate && semester?.endDate
? ` · ${formatDate(semester.startDate)}${formatDate(semester.endDate)}`
: '';
return `${academicYear} · ${typeLabel}${dates}`;
}
export function findSemesterForDate(semesters, date) {
return (semesters || []).find(semester => dateWithinSemester(date, semester)) || null;
}
export function scheduleRangeForSemester(selectedDate, semester) {
const anchorDate = semester && !dateWithinSemester(selectedDate, semester)
? semester.startDate
: selectedDate;
return twoWeekRange(anchorDate);
}
function dateWithinSemester(date, semester) {
return Boolean(
date
&& semester?.startDate
&& semester?.endDate
&& date >= semester.startDate
&& date <= semester.endDate
);
}
function lessonSlotKey(lesson) {