Много разных изменений

This commit is contained in:
Zuev
2026-08-20 00:55:27 +03:00
parent 3599d850f6
commit a1c4ee4298
78 changed files with 4698 additions and 428 deletions

View File

@@ -1,7 +1,15 @@
import { api, getSession } from '../api.js';
import {
academicYearsFromSemesters,
resolveAcademicPeriod,
semestersForAcademicYear
} from '../academic-period.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { preferredScheduleSemester } from '../schedule-period.js';
import { canEditScheduleOverrides, createScheduleOverridePanel } from './schedule-override-panel.js';
import { AsyncCombobox } from '../async-combobox.js';
import { readUrlState, writeUrlState } from '../url-state.js';
import { trackUxEvent } from '../../../telemetry.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
const TARGET_GROUPS = 'groups';
@@ -62,7 +70,18 @@ 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 academicYearSelect = document.getElementById('schedule-view-year');
const semesterSelect = document.getElementById('schedule-view-semester');
const restored = readUrlState({
scheduleTarget: TARGET_GROUPS,
scheduleEntity: '',
scheduleDate: '',
scheduleYear: '',
scheduleSemester: '',
scheduleSubject: '',
scheduleLessonType: '',
scheduleParity: ''
});
const state = {
dictionaries: {
@@ -83,6 +102,10 @@ export async function initScheduleView() {
const mobileMedia = window.matchMedia(MOBILE_QUERY);
setDefaultDate(dateInput);
if (restored.scheduleDate) dateInput.value = restored.scheduleDate;
if (targetSelect && role !== ROLE_DEPARTMENT) targetSelect.value = restored.scheduleTarget;
restoreEntitySelect(restored.scheduleTarget, restored.scheduleEntity);
const entityComboboxes = setupEntityComboboxes(state.dictionaries, role, userDepartmentId);
configureDepartmentTarget();
updatePeriodLabel();
updateEntityFieldVisibility();
@@ -92,6 +115,7 @@ export async function initScheduleView() {
clearInactiveEntityValues();
updateEntityFieldVisibility();
resetResults(SCHEDULE_REFRESH_PROMPT);
persistFilters();
});
loadButton?.addEventListener('click', loadData);
previousPeriodButton?.addEventListener('click', () => movePeriod(-1));
@@ -103,11 +127,22 @@ export async function initScheduleView() {
dateInput?.addEventListener('change', () => {
updatePeriodLabel();
resetResults(SCHEDULE_REFRESH_PROMPT);
persistFilters();
});
academicYearSelect?.addEventListener('change', () => {
const yearSemesters = semestersForAcademicYear(state.dictionaries.semesters, academicYearSelect.value);
const selected = resolveAcademicPeriod(yearSemesters, { date: dateInput.value });
renderPeriodSelects(academicYearSelect.value, selected.semesterId);
synchronizeDateWithSemester();
updatePeriodLabel();
resetResults(SCHEDULE_REFRESH_PROMPT);
persistFilters();
});
semesterSelect?.addEventListener('change', () => {
synchronizeDateWithSemester();
updatePeriodLabel();
resetResults(SCHEDULE_REFRESH_PROMPT);
persistFilters();
});
resultPreviousButton?.addEventListener('click', () => moveResult(-1));
resultNextButton?.addEventListener('click', () => moveResult(1));
@@ -132,8 +167,10 @@ export async function initScheduleView() {
}
try {
state.dictionaries = await loadDictionaries(role, userDepartmentId);
selectCurrentSemester();
Object.assign(state.dictionaries, await loadDictionaries(role, userDepartmentId));
mergeLoadedDictionaries(state.dictionaries, entityComboboxes);
restoreSimpleFilters(restored);
selectCurrentSemester(restored.scheduleYear, restored.scheduleSemester);
overridePanel = createScheduleOverridePanel({
role,
dictionaries: state.dictionaries,
@@ -149,6 +186,11 @@ export async function initScheduleView() {
resetResults(message);
}
['schedule-view-group', 'schedule-view-teacher', 'schedule-view-classroom', 'schedule-view-department',
'schedule-view-subject', 'schedule-view-lesson-type', 'schedule-view-parity'].forEach(id => {
document.getElementById(id)?.addEventListener('change', persistFilters);
});
function configureDepartmentTarget() {
if (role !== ROLE_DEPARTMENT) return;
targetSelect.value = TARGET_DEPARTMENTS;
@@ -187,8 +229,12 @@ export async function initScheduleView() {
].forEach(([fieldTarget, id]) => {
if (fieldTarget === target) return;
if (role === ROLE_DEPARTMENT && id === 'schedule-view-department') return;
const select = document.getElementById(id);
if (select) select.value = '';
if (entityComboboxes[fieldTarget]) {
entityComboboxes[fieldTarget].clear();
} else {
const select = document.getElementById(id);
if (select) select.value = '';
}
});
}
@@ -255,6 +301,8 @@ export async function initScheduleView() {
state.overrides = overrides;
overridePanel.setRegistry(overrides, range);
renderLessons(lessons, range, target, previousKey);
persistFilters();
trackUxEvent('расписание_найдено', { target, lessons: lessons.length });
} catch (error) {
const message = error.message || 'Ошибка загрузки расписания';
showAlert('schedule-view-alert', message, 'error');
@@ -894,17 +942,38 @@ export async function initScheduleView() {
.find(semester => String(semester.id) === String(semesterId)) || null;
}
function selectCurrentSemester() {
if (!semesterSelect) return;
const preferredSemester = preferredScheduleSemester(
state.dictionaries.semesters,
dateInput.value
);
semesterSelect.value = preferredSemester ? String(preferredSemester.id) : '';
function selectCurrentSemester(academicYearId = '', semesterId = '') {
if (!academicYearSelect || !semesterSelect) return;
const selected = resolveAcademicPeriod(state.dictionaries.semesters, {
academicYearId,
semesterId,
date: dateInput.value
});
renderPeriodSelects(selected.academicYearId, selected.semesterId);
synchronizeDateWithSemester();
updatePeriodLabel();
}
function renderPeriodSelects(academicYearId, semesterId) {
const years = academicYearsFromSemesters(state.dictionaries.semesters);
const selectedYearId = String(academicYearId || years[0]?.id || '');
academicYearSelect.innerHTML = years.length
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
: '<option value="">Учебные годы не настроены</option>';
academicYearSelect.value = selectedYearId;
const yearSemesters = semestersForAcademicYear(state.dictionaries.semesters, selectedYearId);
semesterSelect.innerHTML = yearSemesters.length
? yearSemesters.map(semester => `
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(semesterOptionLabel(semester))}</option>
`).join('')
: '<option value="">Семестры не настроены</option>';
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|| preferredScheduleSemester(yearSemesters, dateInput.value)
|| yearSemesters[0];
semesterSelect.value = String(selectedSemester?.id || '');
}
function synchronizeDateWithSemester() {
const semester = selectedSemester();
if (!semester) {
@@ -924,6 +993,26 @@ export async function initScheduleView() {
if (!range) return 1;
return isoDay(dateInput.value || range.startDate);
}
function persistFilters() {
const target = activeTarget();
const entitySelectId = {
groups: 'schedule-view-group',
teachers: 'schedule-view-teacher',
classrooms: 'schedule-view-classroom',
departments: 'schedule-view-department'
}[target];
writeUrlState({
scheduleTarget: role === ROLE_DEPARTMENT ? '' : target,
scheduleEntity: entitySelectId ? valueOf(entitySelectId) : '',
scheduleDate: dateInput.value,
scheduleYear: academicYearSelect?.value || '',
scheduleSemester: semesterSelect?.value || '',
scheduleSubject: valueOf('schedule-view-subject'),
scheduleLessonType: valueOf('schedule-view-lesson-type'),
scheduleParity: valueOf('schedule-view-parity')
});
}
}
function createEmptyOverridePanel() {
@@ -935,37 +1024,111 @@ function createEmptyOverridePanel() {
}
async function loadDictionaries(role, userDepartmentId) {
const [groups, teachers, classrooms, departments, subjects, lessonTypes, semesters] = await Promise.all([
api.get('/api/groups'),
api.get('/api/users/teachers'),
api.get('/api/classrooms'),
const [departments, subjects, lessonTypes, semesters] = await Promise.all([
api.get('/api/departments'),
api.get('/api/subjects'),
api.get('/api/lesson-types'),
api.get('/api/schedule/semesters')
]);
const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId
? groups.filter(group => String(group.departmentId) === String(userDepartmentId))
: groups;
fillSelect('schedule-view-group', visibleGroups, item => item.id, item => item.name, 'Все группы');
fillSelect('schedule-view-teacher', teachers, item => item.id, item => item.fullName || item.username, 'Все преподаватели');
fillSelect('schedule-view-classroom', classrooms, item => item.id, item => roomLabel(item), 'Все аудитории');
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,
groups: [],
teachers: [],
classrooms: [],
departments,
semesters: semesters || []
};
}
function setupEntityComboboxes(dictionaries, role, userDepartmentId) {
const definitions = {
groups: {
select: document.getElementById('schedule-view-group'),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
if (role === ROLE_DEPARTMENT && userDepartmentId) params.set('departmentId', userDepartmentId);
return api.get(`/api/groups/options?${params}`);
},
label: item => item.name,
description: item => [item.specialtyCode, item.specialtyProfileName, item.educationFormName].filter(Boolean).join(' · ')
},
teachers: {
select: document.getElementById('schedule-view-teacher'),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return api.get(`/api/users/teachers/options?${params}`);
},
label: item => item.fullName || item.username,
description: item => [item.jobTitle, item.departmentName].filter(Boolean).join(' · ')
},
classrooms: {
select: document.getElementById('schedule-view-classroom'),
load: async (query, selectedId) => {
const params = new URLSearchParams({ query, limit: '30' });
if (selectedId) params.set('selectedId', selectedId);
return api.get(`/api/classrooms/options?${params}`);
},
label: roomLabel,
description: item => [item.capacity ? `${item.capacity} мест` : null, item.building].filter(Boolean).join(' · ')
}
};
return Object.fromEntries(Object.entries(definitions).map(([key, definition]) => {
const combo = new AsyncCombobox(definition.select, {
placeholder: `Найдите ${key === 'groups' ? 'группу' : key === 'teachers' ? 'преподавателя' : 'аудиторию'}`,
optionLabel: definition.label,
optionDescription: definition.description,
load: async (query, selectedId) => {
const items = await definition.load(query, selectedId);
mergeItems(dictionaries, key, items);
return items;
}
});
combo.preload('');
return [key, combo];
}));
}
function mergeLoadedDictionaries(dictionaries, combos) {
Object.keys(combos).forEach(key => mergeItems(dictionaries, key, combos[key].items || []));
}
function mergeItems(dictionaries, key, items) {
const merged = new Map((dictionaries[key] || []).map(item => [String(item.id), item]));
(items || []).forEach(item => merged.set(String(item.id), item));
dictionaries[key] = [...merged.values()];
}
function restoreEntitySelect(target, value) {
if (!value) return;
const id = {
groups: 'schedule-view-group',
teachers: 'schedule-view-teacher',
classrooms: 'schedule-view-classroom',
departments: 'schedule-view-department'
}[target];
const select = document.getElementById(id);
if (select) select.innerHTML = `<option value="${escapeHtml(value)}" selected>Загрузка...</option>`;
}
function restoreSimpleFilters(restored) {
[
['schedule-view-subject', restored.scheduleSubject],
['schedule-view-lesson-type', restored.scheduleLessonType],
['schedule-view-parity', restored.scheduleParity]
].forEach(([id, value]) => {
const select = document.getElementById(id);
if (select && value && Array.from(select.options).some(option => option.value === String(value))) {
select.value = String(value);
}
});
}
function setDefaultDate(dateInput) {
const today = new Date();
dateInput.value = toIsoDate(today);