Много разных изменений
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
academicYearsFromSemesters,
|
||||
resolveAcademicPeriod,
|
||||
semestersForAcademicYear
|
||||
} from '../academic-period.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { confirmAction } from '../dialog.js';
|
||||
import { confirmAbandonDirtyChanges, createDirtyScope } from '../dirty-state.js';
|
||||
import { trackUxEvent } from '../../../telemetry.js';
|
||||
import { AsyncCombobox } from '../async-combobox.js';
|
||||
import { debounce } from '../url-state.js';
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: 1, label: 'Понедельник' },
|
||||
@@ -61,6 +71,10 @@ export function scheduleSlotActionLabel(slotCount) {
|
||||
return slotCount === 1 ? 'Очистить' : 'Удалить';
|
||||
}
|
||||
|
||||
export function shouldMarkRuleFormDirty({ programmatic = false, type = '', fromAsyncCombobox = false } = {}) {
|
||||
return !programmatic && !(type === 'input' && fromAsyncCombobox);
|
||||
}
|
||||
|
||||
export function chooseScheduleVersion(versions, semesterId = '', requestedVersionId = '') {
|
||||
const available = (versions || []).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||||
return available.find(version => String(version.id) === String(requestedVersionId))
|
||||
@@ -100,11 +114,16 @@ const LESSON_TYPE_LIMITS = [
|
||||
];
|
||||
|
||||
export async function initSchedule() {
|
||||
const dirtyScope = createDirtyScope('schedule-constructor', 'В конструкторе есть несохранённые изменения правила. Покинуть раздел?');
|
||||
const initialState = document.getElementById('schedule-initial-state');
|
||||
const initialContent = document.getElementById('schedule-initial-content');
|
||||
const ruleForm = document.getElementById('schedule-rule-form');
|
||||
const ruleFormTitle = document.getElementById('schedule-rule-form-title');
|
||||
const ruleIdInput = document.getElementById('schedule-rule-id');
|
||||
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
|
||||
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
|
||||
const scheduleVersionYearSelect = document.getElementById('schedule-version-year');
|
||||
const scheduleVersionSemesterSelect = document.getElementById('schedule-version-semester');
|
||||
const scheduleVersionSelect = document.getElementById('schedule-version-select');
|
||||
const scheduleVersionContext = document.getElementById('schedule-version-context');
|
||||
const scheduleVersionEyebrow = document.getElementById('schedule-version-eyebrow');
|
||||
@@ -128,6 +147,7 @@ export async function initSchedule() {
|
||||
const ruleGroupsMenu = document.getElementById('schedule-rule-groups-menu');
|
||||
const ruleGroupsText = document.getElementById('schedule-rule-groups-text');
|
||||
const ruleGroupCheckboxes = document.getElementById('schedule-rule-group-checkboxes');
|
||||
const ruleGroupSearch = document.getElementById('schedule-rule-group-search');
|
||||
const ruleSlotsContainer = document.getElementById('schedule-rule-slots');
|
||||
const ruleResetButton = document.getElementById('schedule-rule-reset');
|
||||
const slotAddButton = document.getElementById('schedule-slot-add');
|
||||
@@ -170,8 +190,6 @@ export async function initSchedule() {
|
||||
let rules = [];
|
||||
let subjects = [];
|
||||
let groups = [];
|
||||
let teachers = [];
|
||||
let classrooms = [];
|
||||
let lessonTypes = [];
|
||||
let subgroups = [];
|
||||
let timeSlots = [];
|
||||
@@ -190,6 +208,14 @@ export async function initSchedule() {
|
||||
let visualContextMenuPositionAnimation = null;
|
||||
let pendingSlotMove = null;
|
||||
let creatingDraft = false;
|
||||
let lastVersionYearValue = '';
|
||||
let lastVersionSemesterValue = '';
|
||||
let lastVersionValue = '';
|
||||
let slotComboboxes = [];
|
||||
let conflictComboboxes = [];
|
||||
let groupSearchRequest = 0;
|
||||
let subgroupRequest = 0;
|
||||
let syncingRuleForm = false;
|
||||
|
||||
bindEvents();
|
||||
|
||||
@@ -201,13 +227,26 @@ export async function initSchedule() {
|
||||
syncRuleSemesterWithVersion();
|
||||
setRuleEditorEnabled(Boolean(selectedScheduleVersion()));
|
||||
await loadRules();
|
||||
dirtyScope.clear();
|
||||
} catch (error) {
|
||||
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
|
||||
} finally {
|
||||
initialContent?.classList.remove('is-loading');
|
||||
initialContent?.setAttribute('aria-busy', 'false');
|
||||
if (initialState) initialState.hidden = true;
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
refreshRulesButton?.addEventListener('click', loadRules);
|
||||
scheduleVersionYearSelect?.addEventListener('change', handleVersionYearChange);
|
||||
scheduleVersionSemesterSelect?.addEventListener('change', handleVersionSemesterChange);
|
||||
scheduleVersionSelect?.addEventListener('change', async () => {
|
||||
const requestedValue = scheduleVersionSelect.value;
|
||||
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
|
||||
scheduleVersionSelect.value = lastVersionValue;
|
||||
return;
|
||||
}
|
||||
lastVersionValue = requestedValue;
|
||||
if (scheduleVersionSelect.value) {
|
||||
localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value);
|
||||
}
|
||||
@@ -227,7 +266,10 @@ export async function initSchedule() {
|
||||
});
|
||||
draftForm?.addEventListener('submit', createDraftFromSelectedVersion);
|
||||
ruleResetButton?.addEventListener('click', resetRuleForm);
|
||||
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
|
||||
slotAddButton?.addEventListener('click', () => {
|
||||
renderRuleSlots([...readRuleSlots(false), {}]);
|
||||
dirtyScope.mark();
|
||||
});
|
||||
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
|
||||
visualCloseButton?.addEventListener('click', closeVisualDrawer);
|
||||
visualBackdrop?.addEventListener('click', closeVisualDrawer);
|
||||
@@ -305,6 +347,7 @@ export async function initSchedule() {
|
||||
const slots = readRuleSlots(false);
|
||||
slots.splice(Number(removeButton.dataset.index), 1);
|
||||
renderRuleSlots(slots.length ? slots : [{}]);
|
||||
dirtyScope.mark();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -321,9 +364,11 @@ export async function initSchedule() {
|
||||
ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu);
|
||||
ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown);
|
||||
ruleGroupsMenu.addEventListener('click', event => event.stopPropagation());
|
||||
ruleGroupCheckboxes.addEventListener('change', () => {
|
||||
ruleGroupSearch?.addEventListener('input', debounce(() => searchRuleGroups(ruleGroupSearch.value), 320));
|
||||
ruleGroupCheckboxes.addEventListener('change', async () => {
|
||||
const slots = readRuleSlots(false);
|
||||
updateRuleGroupsText();
|
||||
renderRuleSlots(readRuleSlots(false));
|
||||
if (await loadSubgroupsForGroups(selectedRuleGroupIds())) renderRuleSlots(slots);
|
||||
});
|
||||
document.addEventListener('click', () => {
|
||||
closeRuleGroupsMenu();
|
||||
@@ -340,25 +385,39 @@ export async function initSchedule() {
|
||||
updatePreferenceHints();
|
||||
});
|
||||
ruleForm.addEventListener('submit', saveRule);
|
||||
ruleForm.addEventListener('input', event => {
|
||||
if (shouldMarkRuleFormDirty({
|
||||
programmatic: syncingRuleForm,
|
||||
type: event.type,
|
||||
fromAsyncCombobox: Boolean(event.target.closest('.async-combobox'))
|
||||
})) dirtyScope.mark();
|
||||
});
|
||||
ruleForm.addEventListener('change', event => {
|
||||
if (shouldMarkRuleFormDirty({ programmatic: syncingRuleForm, type: event.type })) dirtyScope.mark();
|
||||
});
|
||||
rulesTbody.addEventListener('click', handleRuleTableClick);
|
||||
}
|
||||
|
||||
async function loadBaseLists() {
|
||||
[subjects, groups, teachers, classrooms, lessonTypes, subgroups] = await Promise.all([
|
||||
[subjects, lessonTypes] = await Promise.all([
|
||||
api.get('/api/subjects'),
|
||||
api.get('/api/groups'),
|
||||
api.get('/api/users/teachers'),
|
||||
api.get('/api/classrooms'),
|
||||
api.get('/api/lesson-types'),
|
||||
api.get('/api/subgroups')
|
||||
api.get('/api/lesson-types')
|
||||
]);
|
||||
subgroups = [];
|
||||
populateSubjectSelects();
|
||||
await searchRuleGroups('');
|
||||
}
|
||||
|
||||
async function loadYears() {
|
||||
academicYears = await api.get('/api/admin/calendar/years');
|
||||
semesters = academicYears.flatMap(year => year.semesters || []);
|
||||
populateSemesterSelects();
|
||||
const selected = resolveAcademicPeriod(semesters, {
|
||||
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
|
||||
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
|
||||
date: localIsoDate(new Date())
|
||||
});
|
||||
renderVersionPeriodSelects(selected.academicYearId, selected.semesterId);
|
||||
selectDefaultVisualPeriod();
|
||||
}
|
||||
|
||||
@@ -368,15 +427,27 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
async function loadScheduleVersions(preferredVersionId = null) {
|
||||
const responses = await Promise.all(semesters.map(semester =>
|
||||
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semester.id)}`)
|
||||
));
|
||||
scheduleVersions = responses.flat().filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||||
const requestedVersionId = preferredVersionId || localStorage.getItem('magistr.schedule.openVersionId') || '';
|
||||
const semesterId = scheduleVersionSemesterSelect?.value || defaultScheduleSemesterId();
|
||||
if (!semesterId) {
|
||||
scheduleVersions = [];
|
||||
scheduleVersionSelect.innerHTML = '<option value="">Сначала выберите семестр</option>';
|
||||
updateVersionContext();
|
||||
setRuleEditorEnabled(false);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('magistr.schedule.yearId', scheduleVersionYearSelect?.value || '');
|
||||
localStorage.setItem('magistr.schedule.semesterId', semesterId);
|
||||
scheduleVersions = (await api.get(
|
||||
`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semesterId)}`
|
||||
)).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||||
const requestedVersionId = preferredVersionId
|
||||
|| localStorage.getItem('magistr.schedule.openVersionId')
|
||||
|| localStorage.getItem('magistr.schedule.versionId')
|
||||
|| '';
|
||||
localStorage.removeItem('magistr.schedule.openVersionId');
|
||||
const selected = chooseScheduleVersion(
|
||||
scheduleVersions,
|
||||
ruleSemesterSelect.value || defaultScheduleSemesterId(),
|
||||
semesterId,
|
||||
requestedVersionId
|
||||
);
|
||||
const published = scheduleVersions.filter(version => version.status === 'PUBLISHED');
|
||||
@@ -388,6 +459,7 @@ export async function initSchedule() {
|
||||
].filter(Boolean).join('')
|
||||
: '<option value="">Расписания не созданы</option>';
|
||||
scheduleVersionSelect.value = selected ? String(selected.id) : '';
|
||||
lastVersionValue = scheduleVersionSelect.value;
|
||||
if (selected) {
|
||||
localStorage.setItem('magistr.schedule.versionId', String(selected.id));
|
||||
}
|
||||
@@ -395,6 +467,64 @@ export async function initSchedule() {
|
||||
setRuleEditorEnabled(Boolean(selected));
|
||||
}
|
||||
|
||||
async function handleVersionYearChange() {
|
||||
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
|
||||
renderVersionPeriodSelects(lastVersionYearValue, lastVersionSemesterValue);
|
||||
return;
|
||||
}
|
||||
const yearId = scheduleVersionYearSelect?.value || '';
|
||||
const yearSemesters = semestersForAcademicYear(semesters, yearId);
|
||||
const selected = resolveAcademicPeriod(yearSemesters, { date: localIsoDate(new Date()) });
|
||||
renderVersionPeriodSelects(yearId, selected.semesterId);
|
||||
await applyVersionPeriodChange();
|
||||
}
|
||||
|
||||
async function handleVersionSemesterChange() {
|
||||
if (dirtyScope.isDirty() && !(await confirmAbandonDirtyChanges())) {
|
||||
renderVersionPeriodSelects(lastVersionYearValue, lastVersionSemesterValue);
|
||||
return;
|
||||
}
|
||||
await applyVersionPeriodChange();
|
||||
}
|
||||
|
||||
async function applyVersionPeriodChange() {
|
||||
lastVersionYearValue = scheduleVersionYearSelect?.value || '';
|
||||
lastVersionSemesterValue = scheduleVersionSemesterSelect?.value || '';
|
||||
localStorage.setItem('magistr.schedule.yearId', lastVersionYearValue);
|
||||
localStorage.setItem('magistr.schedule.semesterId', lastVersionSemesterValue);
|
||||
try {
|
||||
await loadScheduleVersions();
|
||||
resetRuleForm();
|
||||
syncRuleSemesterWithVersion();
|
||||
await loadRules();
|
||||
} catch (error) {
|
||||
showAlert('schedule-rule-alert', error.message || 'Не удалось сменить учебный период', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderVersionPeriodSelects(academicYearId, semesterId) {
|
||||
if (!scheduleVersionYearSelect || !scheduleVersionSemesterSelect) return;
|
||||
const years = academicYearsFromSemesters(semesters);
|
||||
const selectedYearId = String(academicYearId || years[0]?.id || '');
|
||||
scheduleVersionYearSelect.innerHTML = years.length
|
||||
? years.map(year => `<option value="${escapeHtml(year.id)}">${escapeHtml(year.title)}</option>`).join('')
|
||||
: '<option value="">Учебные годы не настроены</option>';
|
||||
scheduleVersionYearSelect.value = selectedYearId;
|
||||
|
||||
const yearSemesters = semestersForAcademicYear(semesters, selectedYearId);
|
||||
scheduleVersionSemesterSelect.innerHTML = yearSemesters.length
|
||||
? yearSemesters.map(semester => `
|
||||
<option value="${escapeHtml(String(semester.id))}">${escapeHtml(shortSemesterLabel(semester))}</option>
|
||||
`).join('')
|
||||
: '<option value="">Семестры не настроены</option>';
|
||||
const selectedSemester = yearSemesters.find(semester => String(semester.id) === String(semesterId))
|
||||
|| yearSemesters[0];
|
||||
scheduleVersionSemesterSelect.value = String(selectedSemester?.id || '');
|
||||
lastVersionYearValue = scheduleVersionYearSelect.value;
|
||||
lastVersionSemesterValue = scheduleVersionSemesterSelect.value;
|
||||
syncSelects(scheduleVersionYearSelect, scheduleVersionSemesterSelect);
|
||||
}
|
||||
|
||||
function versionOptionGroup(label, versions) {
|
||||
if (!versions.length) return '';
|
||||
return `<optgroup label="${escapeHtml(label)}">${versions.map(version => `
|
||||
@@ -405,12 +535,11 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function defaultScheduleSemesterId() {
|
||||
const savedSemesterId = localStorage.getItem('magistr.schedule.semesterId');
|
||||
const saved = semesters.find(semester => String(semester.id) === String(savedSemesterId));
|
||||
if (saved) return saved.id;
|
||||
const today = localIsoDate(new Date());
|
||||
const current = semesters.find(semester => semester.startDate <= today && semester.endDate >= today);
|
||||
return current?.id || semesters[0]?.id || '';
|
||||
return resolveAcademicPeriod(semesters, {
|
||||
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
|
||||
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
|
||||
date: localIsoDate(new Date())
|
||||
}).semesterId;
|
||||
}
|
||||
|
||||
function updateVersionContext() {
|
||||
@@ -972,7 +1101,12 @@ export async function initSchedule() {
|
||||
|
||||
async function deleteRuleFromVisual(rule) {
|
||||
closeVisualContextMenu();
|
||||
if (!confirm(`Удалить правило "${rule.subjectName || 'расписания'}"?`)) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить правило расписания?',
|
||||
message: `Будет удалено правило «${rule.subjectName || 'Без дисциплины'}» и его слоты.`,
|
||||
confirmLabel: 'Удалить правило',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/admin/schedule-rules/' + rule.id);
|
||||
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
|
||||
@@ -1265,7 +1399,12 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
if (!confirm('Удалить правило расписания?')) return;
|
||||
if (!await confirmAction({
|
||||
title: 'Удалить правило расписания?',
|
||||
message: 'Правило и связанные с ним слоты будут удалены из выбранной версии.',
|
||||
confirmLabel: 'Удалить правило',
|
||||
danger: true
|
||||
})) return;
|
||||
try {
|
||||
await api.delete('/api/admin/schedule-rules/' + deleteButton.dataset.id);
|
||||
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
|
||||
@@ -1302,7 +1441,11 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
async function finishSuccessfulRuleSave(saved) {
|
||||
const mode = ruleIdInput.value ? 'update' : 'create';
|
||||
const versionId = scheduleVersionSelect.value;
|
||||
dirtyScope.clear();
|
||||
resetRuleForm();
|
||||
trackUxEvent('правило_расписания_сохранено', { mode, versionId });
|
||||
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
|
||||
await loadRules();
|
||||
}
|
||||
@@ -1427,6 +1570,8 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function renderConflictSlots(rule, conflictFields = []) {
|
||||
conflictComboboxes.forEach(combobox => combobox.destroy());
|
||||
conflictComboboxes = [];
|
||||
const slots = rule.slots || [];
|
||||
if (!conflictSlotsContainer) return;
|
||||
if (!slots.length) {
|
||||
@@ -1450,11 +1595,12 @@ export async function initSchedule() {
|
||||
<div class="form-group"><label>День</label><select class="conflict-slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
|
||||
<div class="form-group"><label>Чётность</label><select class="conflict-slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
|
||||
<div class="form-group"><label>Пара</label><select class="conflict-slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
||||
<div class="form-group ${conflictFieldClass('teacher', conflictFields)}"><label>Преподаватель</label><select class="conflict-slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
||||
<div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
||||
<div class="form-group ${conflictFieldClass('teacher', conflictFields)}"><label>Преподаватель</label><select class="conflict-slot-teacher" aria-label="Преподаватель конфликтующего слота ${index + 1}" required>${selectedEntityOption(slot.teacherId, slot.teacherName)}</select></div>
|
||||
<div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" aria-label="Аудитория конфликтующего слота ${index + 1}" required>${selectedEntityOption(slot.classroomId, slot.classroomName)}</select></div>
|
||||
</section>
|
||||
`;
|
||||
}).join('');
|
||||
initializeEntityComboboxes(conflictSlotsContainer, '.conflict-slot-teacher', '.conflict-slot-classroom', conflictComboboxes);
|
||||
}
|
||||
|
||||
function conflictFieldClass(field, fields) {
|
||||
@@ -1578,7 +1724,7 @@ export async function initSchedule() {
|
||||
|
||||
function buildRulePayload() {
|
||||
const groupIds = selectedRuleGroupIds();
|
||||
const slots = readRuleSlots(true);
|
||||
const slots = readRuleSlots(true).map(ruleSlotToPayload);
|
||||
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
|
||||
if (!scheduleVersionSelect.value) throw new Error('Выберите версию расписания');
|
||||
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
|
||||
@@ -1597,17 +1743,22 @@ export async function initSchedule() {
|
||||
};
|
||||
}
|
||||
|
||||
function fillRuleForm(rule) {
|
||||
async function fillRuleForm(rule) {
|
||||
ruleFormTitle.textContent = 'Редактирование правила расписания';
|
||||
ruleIdInput.value = rule.id;
|
||||
ruleSubjectSelect.value = rule.subjectId || '';
|
||||
ruleSemesterSelect.value = rule.semesterId || '';
|
||||
fillTypeLimits(rule);
|
||||
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
||||
cacheRuleGroups(rule.groupIds || [], rule.groupNames || []);
|
||||
renderRuleGroups(rule.groupIds || []);
|
||||
searchRuleGroups(ruleGroupSearch?.value || '');
|
||||
ruleSlotsContainer.innerHTML = '<div class="loading-row"><span class="state-spinner" aria-hidden="true"></span>Загрузка подгрупп выбранных групп...</div>';
|
||||
if (!await loadSubgroupsForGroups(rule.groupIds || [])) return;
|
||||
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
|
||||
syncRuleSemesterWithVersion();
|
||||
hideAlert('schedule-rule-alert');
|
||||
dirtyScope.clear();
|
||||
ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
@@ -1616,11 +1767,14 @@ export async function initSchedule() {
|
||||
ruleFormTitle.textContent = 'Новое правило расписания';
|
||||
ruleIdInput.value = '';
|
||||
fillTypeLimits({});
|
||||
subgroupRequest += 1;
|
||||
subgroups = [];
|
||||
renderRuleGroups([]);
|
||||
renderRuleSlots([{}]);
|
||||
syncRuleSemesterWithVersion();
|
||||
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
||||
hideAlert('schedule-rule-alert');
|
||||
dirtyScope.clear();
|
||||
}
|
||||
|
||||
function renderRuleGroups(selectedIds) {
|
||||
@@ -1640,7 +1794,60 @@ export async function initSchedule() {
|
||||
updateRuleGroupsText();
|
||||
}
|
||||
|
||||
async function searchRuleGroups(query = '') {
|
||||
const request = ++groupSearchRequest;
|
||||
const selectedBeforeSearch = selectedRuleGroupIds();
|
||||
if (!groups.length) {
|
||||
ruleGroupCheckboxes.innerHTML = '<div class="loading-row"><span class="state-spinner" aria-hidden="true"></span>Поиск групп...</div>';
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams({ query: String(query || '').trim(), limit: '50' });
|
||||
const loaded = await api.get(`/api/groups/options?${params}`);
|
||||
if (request !== groupSearchRequest) return;
|
||||
const selectedIds = selectedRuleGroupIds().length ? selectedRuleGroupIds() : selectedBeforeSearch;
|
||||
const selectedGroups = groups.filter(group => selectedIds.includes(String(group.id)));
|
||||
groups = mergeEntitiesById([...selectedGroups, ...(loaded || [])]);
|
||||
renderRuleGroups(selectedIds);
|
||||
} catch (error) {
|
||||
if (request !== groupSearchRequest) return;
|
||||
ruleGroupCheckboxes.innerHTML = `<div class="loading-row view-state-error">${escapeHtml(error.message || 'Не удалось найти группы')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubgroupsForGroups(groupIds) {
|
||||
const request = ++subgroupRequest;
|
||||
const normalizedIds = [...new Set((groupIds || []).map(Number).filter(Boolean))];
|
||||
if (!normalizedIds.length) {
|
||||
subgroups = [];
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
normalizedIds.forEach(groupId => params.append('groupId', groupId));
|
||||
const loaded = await api.get(`/api/subgroups?${params}`);
|
||||
if (request !== subgroupRequest) return false;
|
||||
subgroups = loaded || [];
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (request !== subgroupRequest) return false;
|
||||
subgroups = [];
|
||||
showAlert('schedule-rule-alert', error.message || 'Не удалось загрузить подгруппы выбранных групп', 'error');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheRuleGroups(ids, names) {
|
||||
const additions = (ids || []).map((id, index) => ({ id, name: names?.[index] || `Группа ${id}` }));
|
||||
groups = mergeEntitiesById([...groups, ...additions]);
|
||||
}
|
||||
|
||||
function mergeEntitiesById(items) {
|
||||
return [...new Map((items || []).filter(Boolean).map(item => [String(item.id), item])).values()];
|
||||
}
|
||||
|
||||
function renderRuleSlots(slots) {
|
||||
slotComboboxes.forEach(combobox => combobox.destroy());
|
||||
slotComboboxes = [];
|
||||
const normalizedSlots = slots?.length ? slots : [{}];
|
||||
const isSingleSlot = normalizedSlots.length === 1;
|
||||
const actionLabel = scheduleSlotActionLabel(normalizedSlots.length);
|
||||
@@ -1650,8 +1857,8 @@ export async function initSchedule() {
|
||||
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
|
||||
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
|
||||
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
||||
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
|
||||
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
|
||||
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" aria-label="Преподаватель слота ${index + 1}" required>${selectedEntityOption(slot.teacherId, slot.teacherName)}</select></div>
|
||||
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" aria-label="Аудитория слота ${index + 1}" required>${selectedEntityOption(slot.classroomId, slot.classroomName)}</select></div>
|
||||
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
|
||||
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
|
||||
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
|
||||
@@ -1659,6 +1866,7 @@ export async function initSchedule() {
|
||||
<div class="schedule-preference-hint" data-preference-hint></div>
|
||||
</div>
|
||||
`).join('');
|
||||
initializeEntityComboboxes(ruleSlotsContainer, '.slot-teacher', '.slot-classroom', slotComboboxes);
|
||||
updatePreferenceHints();
|
||||
}
|
||||
|
||||
@@ -1721,6 +1929,8 @@ export async function initSchedule() {
|
||||
subgroupIds,
|
||||
teacherId: Number(row.querySelector('.slot-teacher').value),
|
||||
classroomId: Number(row.querySelector('.slot-classroom').value),
|
||||
teacherName: selectedOptionLabel(row.querySelector('.slot-teacher')),
|
||||
classroomName: selectedOptionLabel(row.querySelector('.slot-classroom')),
|
||||
lessonTypeId: Number(row.querySelector('.slot-lesson-type').value),
|
||||
lessonFormat: row.querySelector('.slot-format').value
|
||||
};
|
||||
@@ -2022,13 +2232,49 @@ export async function initSchedule() {
|
||||
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
|
||||
}
|
||||
|
||||
function toTeacherOption(teacher) {
|
||||
return { value: teacher.id, label: teacher.fullName || teacher.username || `ID ${teacher.id}` };
|
||||
function initializeEntityComboboxes(container, teacherSelector, classroomSelector, collector) {
|
||||
container.querySelectorAll(teacherSelector).forEach(select => {
|
||||
collector.push(new AsyncCombobox(select, {
|
||||
allowEmpty: false,
|
||||
placeholder: 'Найдите преподавателя',
|
||||
emptyLabel: 'Преподаватели не найдены',
|
||||
optionLabel: teacher => teacher.fullName || teacher.username || `Преподаватель ${teacher.id}`,
|
||||
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
|
||||
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}`);
|
||||
}
|
||||
}));
|
||||
});
|
||||
container.querySelectorAll(classroomSelector).forEach(select => {
|
||||
collector.push(new AsyncCombobox(select, {
|
||||
allowEmpty: false,
|
||||
placeholder: 'Найдите аудиторию',
|
||||
emptyLabel: 'Аудитории не найдены',
|
||||
optionLabel: classroom => classroom.name || `Аудитория ${classroom.id}`,
|
||||
optionDescription: classroom => [
|
||||
classroom.building,
|
||||
classroom.floor != null ? `${classroom.floor} этаж` : '',
|
||||
classroom.capacity ? `${classroom.capacity} мест` : ''
|
||||
].filter(Boolean).join(' · '),
|
||||
load: async (query, selectedId) => {
|
||||
const params = new URLSearchParams({ query, limit: '30' });
|
||||
if (selectedId) params.set('selectedId', selectedId);
|
||||
return api.get(`/api/classrooms/options?${params}`);
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function toClassroomOption(classroom) {
|
||||
const capacity = classroom.capacity ? `, ${classroom.capacity} чел.` : '';
|
||||
return { value: classroom.id, label: `${classroom.name}${capacity}` };
|
||||
function selectedEntityOption(id, label) {
|
||||
return id
|
||||
? `<option value="${escapeHtml(String(id))}" selected>${escapeHtml(label || `ID ${id}`)}</option>`
|
||||
: '<option value="" selected></option>';
|
||||
}
|
||||
|
||||
function selectedOptionLabel(select) {
|
||||
return select?.selectedOptions?.[0]?.textContent?.trim() || '';
|
||||
}
|
||||
|
||||
function toLessonTypeOption(lessonType) {
|
||||
@@ -2098,9 +2344,16 @@ export async function initSchedule() {
|
||||
}
|
||||
|
||||
function syncSelects(...selects) {
|
||||
selects.filter(Boolean).forEach(select => {
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
const targets = selects.filter(Boolean);
|
||||
const previousSyncState = syncingRuleForm;
|
||||
if (targets.some(select => ruleForm.contains(select))) syncingRuleForm = true;
|
||||
try {
|
||||
targets.forEach(select => {
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
} finally {
|
||||
syncingRuleForm = previousSyncState;
|
||||
}
|
||||
}
|
||||
|
||||
function visualCellKey(groupId, dayOfWeek, slotKey) {
|
||||
|
||||
Reference in New Issue
Block a user