2511 lines
115 KiB
JavaScript
2511 lines
115 KiB
JavaScript
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: 'Понедельник' },
|
||
{ value: 2, label: 'Вторник' },
|
||
{ value: 3, label: 'Среда' },
|
||
{ value: 4, label: 'Четверг' },
|
||
{ value: 5, label: 'Пятница' },
|
||
{ value: 6, label: 'Суббота' },
|
||
{ value: 7, label: 'Воскресенье' }
|
||
];
|
||
|
||
const PARITY_LABELS = {
|
||
BOTH: 'каждую неделю',
|
||
ODD: 'нечётная',
|
||
EVEN: 'чётная'
|
||
};
|
||
|
||
const CELL_PARITY_LAYOUT = [
|
||
{ parity: 'ODD', label: 'Нечётная' },
|
||
{ parity: 'EVEN', label: 'Чётная' }
|
||
];
|
||
|
||
const SEMESTER_LABELS = {
|
||
autumn: 'осенний',
|
||
spring: 'весенний'
|
||
};
|
||
|
||
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
|
||
const ACADEMIC_HOURS_PER_SLOT = 2;
|
||
const VISUAL_MENU_GAP = 8;
|
||
const VISUAL_MENU_VIEWPORT_PADDING = 8;
|
||
|
||
export function calculateVisualContextMenuPosition({
|
||
anchorRect,
|
||
menuWidth,
|
||
menuHeight,
|
||
viewportWidth,
|
||
viewportHeight
|
||
}) {
|
||
const maxLeft = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportWidth - menuWidth - VISUAL_MENU_VIEWPORT_PADDING);
|
||
const maxTop = Math.max(VISUAL_MENU_VIEWPORT_PADDING, viewportHeight - menuHeight - VISUAL_MENU_VIEWPORT_PADDING);
|
||
let left = anchorRect.right + VISUAL_MENU_GAP;
|
||
let top = anchorRect.top;
|
||
|
||
if (left > maxLeft) {
|
||
left = anchorRect.left - menuWidth - VISUAL_MENU_GAP;
|
||
}
|
||
if (top > maxTop) {
|
||
top = anchorRect.bottom - menuHeight;
|
||
}
|
||
|
||
return {
|
||
left: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(left, maxLeft))),
|
||
top: Math.round(Math.max(VISUAL_MENU_VIEWPORT_PADDING, Math.min(top, maxTop)))
|
||
};
|
||
}
|
||
|
||
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))
|
||
|| available.find(version => version.status === 'PUBLISHED'
|
||
&& String(version.semesterId) === String(semesterId))
|
||
|| available.find(version => version.status === 'PUBLISHED')
|
||
|| available.find(version => String(version.semesterId) === String(semesterId))
|
||
|| available[0]
|
||
|| null;
|
||
}
|
||
|
||
const LESSON_TYPE_LIMITS = [
|
||
{
|
||
key: 'lecture',
|
||
label: 'Лекции',
|
||
hoursField: 'lectureAcademicHours',
|
||
startWeekField: 'lectureStartWeek',
|
||
hoursInputId: 'schedule-rule-lecture-hours',
|
||
startWeekInputId: 'schedule-rule-lecture-start-week'
|
||
},
|
||
{
|
||
key: 'laboratory',
|
||
label: 'Лабораторные',
|
||
hoursField: 'laboratoryAcademicHours',
|
||
startWeekField: 'laboratoryStartWeek',
|
||
hoursInputId: 'schedule-rule-laboratory-hours',
|
||
startWeekInputId: 'schedule-rule-laboratory-start-week'
|
||
},
|
||
{
|
||
key: 'practice',
|
||
label: 'Практики',
|
||
hoursField: 'practiceAcademicHours',
|
||
startWeekField: 'practiceStartWeek',
|
||
hoursInputId: 'schedule-rule-practice-hours',
|
||
startWeekInputId: 'schedule-rule-practice-start-week'
|
||
}
|
||
];
|
||
|
||
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');
|
||
const scheduleVersionTitle = document.getElementById('schedule-version-title');
|
||
const scheduleVersionNote = document.getElementById('schedule-version-note');
|
||
const scheduleVersionCreateDraft = document.getElementById('schedule-version-create-draft');
|
||
const scheduleVersionManage = document.getElementById('schedule-version-manage');
|
||
const draftModal = document.getElementById('schedule-draft-modal');
|
||
const draftForm = document.getElementById('schedule-draft-form');
|
||
const draftNameInput = document.getElementById('schedule-draft-name');
|
||
const draftCopy = document.getElementById('schedule-draft-copy');
|
||
const draftSummary = document.getElementById('schedule-draft-summary');
|
||
const draftCloseButton = document.getElementById('schedule-draft-close');
|
||
const draftCancelButton = document.getElementById('schedule-draft-cancel');
|
||
const draftSubmitButton = document.getElementById('schedule-draft-submit');
|
||
const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, {
|
||
hours: document.getElementById(config.hoursInputId),
|
||
startWeek: document.getElementById(config.startWeekInputId)
|
||
}]));
|
||
const ruleGroupsBox = document.getElementById('schedule-rule-groups-box');
|
||
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');
|
||
const rulesTbody = document.getElementById('schedule-tbody');
|
||
const refreshRulesButton = document.getElementById('schedule-refresh');
|
||
const visualToggleButton = document.getElementById('schedule-visual-toggle');
|
||
const visualCloseButton = document.getElementById('schedule-visual-close');
|
||
const visualDrawer = document.getElementById('schedule-visual-drawer');
|
||
const visualBackdrop = document.getElementById('schedule-visual-backdrop');
|
||
const visualStage = document.getElementById('schedule-visual-stage');
|
||
const visualSummary = document.getElementById('schedule-visual-summary');
|
||
const visualYearSelect = document.getElementById('schedule-visual-year');
|
||
const visualSemesterSelect = document.getElementById('schedule-visual-semester');
|
||
const visualGroupFilter = document.getElementById('schedule-visual-group-filter');
|
||
const visualUseFormButton = document.getElementById('schedule-visual-use-form');
|
||
const visualSelectAllButton = document.getElementById('schedule-visual-select-all');
|
||
const conflictModal = document.getElementById('schedule-conflict-modal');
|
||
const conflictForm = document.getElementById('schedule-conflict-form');
|
||
const conflictCloseButton = document.getElementById('schedule-conflict-close');
|
||
const conflictCancelButton = document.getElementById('schedule-conflict-cancel');
|
||
const conflictSubmitButton = document.getElementById('schedule-conflict-submit');
|
||
const conflictSlotsContainer = document.getElementById('schedule-conflict-slots');
|
||
const conflictSummary = document.getElementById('schedule-conflict-summary');
|
||
const conflictReasons = document.getElementById('schedule-conflict-reasons');
|
||
const conflictNewRuleTitle = document.getElementById('schedule-conflict-new-rule');
|
||
const conflictNewRuleMeta = document.getElementById('schedule-conflict-new-rule-meta');
|
||
const conflictExistingRuleTitle = document.getElementById('schedule-conflict-existing-rule');
|
||
const conflictExistingRuleMeta = document.getElementById('schedule-conflict-existing-rule-meta');
|
||
const visualContextMenu = document.getElementById('schedule-visual-context-menu');
|
||
const moveSlotModal = document.getElementById('schedule-move-slot-modal');
|
||
const moveSlotForm = document.getElementById('schedule-move-slot-form');
|
||
const moveSlotCloseButton = document.getElementById('schedule-move-slot-close');
|
||
const moveSlotCancelButton = document.getElementById('schedule-move-slot-cancel');
|
||
const moveSlotSubmitButton = document.getElementById('schedule-move-slot-submit');
|
||
const moveSlotTitle = document.getElementById('schedule-move-slot-title');
|
||
const moveSlotSummary = document.getElementById('schedule-move-slot-summary');
|
||
const moveSlotDaySelect = document.getElementById('schedule-move-slot-day');
|
||
const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time');
|
||
|
||
let rules = [];
|
||
let subjects = [];
|
||
let groups = [];
|
||
let lessonTypes = [];
|
||
let subgroups = [];
|
||
let timeSlots = [];
|
||
let academicYears = [];
|
||
let semesters = [];
|
||
let approvedTeacherPreferences = [];
|
||
let scheduleVersions = [];
|
||
let selectedVisualYearId = '';
|
||
let selectedVisualSemesterId = '';
|
||
let selectedVisualGroupIds = new Set();
|
||
let visualGroupFilterTouched = false;
|
||
let visualPeriodTouched = false;
|
||
let syncingVisualPeriod = false;
|
||
let pendingConflictResolution = null;
|
||
let visualContextTarget = null;
|
||
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();
|
||
|
||
try {
|
||
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadApprovedTeacherPreferences()]);
|
||
await loadScheduleVersions();
|
||
renderRuleGroups([]);
|
||
renderRuleSlots([{}]);
|
||
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);
|
||
}
|
||
updateVersionContext();
|
||
resetRuleForm();
|
||
syncRuleSemesterWithVersion();
|
||
await loadRules();
|
||
});
|
||
scheduleVersionCreateDraft?.addEventListener('click', openDraftModal);
|
||
scheduleVersionManage?.addEventListener('click', () => {
|
||
document.querySelector('.nav-item[data-tab="schedule-versions"]')?.click();
|
||
});
|
||
draftCloseButton?.addEventListener('click', closeDraftModal);
|
||
draftCancelButton?.addEventListener('click', closeDraftModal);
|
||
draftModal?.addEventListener('click', event => {
|
||
if (event.target === draftModal) closeDraftModal();
|
||
});
|
||
draftForm?.addEventListener('submit', createDraftFromSelectedVersion);
|
||
ruleResetButton?.addEventListener('click', resetRuleForm);
|
||
slotAddButton?.addEventListener('click', () => {
|
||
renderRuleSlots([...readRuleSlots(false), {}]);
|
||
dirtyScope.mark();
|
||
});
|
||
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
|
||
visualCloseButton?.addEventListener('click', closeVisualDrawer);
|
||
visualBackdrop?.addEventListener('click', closeVisualDrawer);
|
||
visualYearSelect?.addEventListener('change', handleVisualYearChange);
|
||
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
|
||
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
|
||
visualStage?.addEventListener('click', handleVisualStageClick);
|
||
visualDrawer?.addEventListener('scroll', closeVisualContextMenu, true);
|
||
conflictCloseButton?.addEventListener('click', closeConflictModal);
|
||
conflictCancelButton?.addEventListener('click', closeConflictModal);
|
||
conflictModal?.addEventListener('click', (event) => {
|
||
if (event.target === conflictModal) closeConflictModal();
|
||
});
|
||
conflictForm?.addEventListener('submit', resolveScheduleRuleConflict);
|
||
visualContextMenu?.addEventListener('click', handleVisualContextMenuClick);
|
||
moveSlotCloseButton?.addEventListener('click', closeMoveSlotModal);
|
||
moveSlotCancelButton?.addEventListener('click', closeMoveSlotModal);
|
||
moveSlotModal?.addEventListener('click', (event) => {
|
||
if (event.target === moveSlotModal) closeMoveSlotModal();
|
||
});
|
||
moveSlotForm?.addEventListener('submit', saveVisualSlotMove);
|
||
visualUseFormButton?.addEventListener('click', () => {
|
||
const formSemesterId = ruleSemesterSelect.value;
|
||
if (formSemesterId) {
|
||
visualPeriodTouched = true;
|
||
setVisualPeriodBySemesterId(formSemesterId);
|
||
}
|
||
visualGroupFilterTouched = false;
|
||
selectedVisualGroupIds = new Set();
|
||
renderVisualSchedule();
|
||
});
|
||
visualSelectAllButton?.addEventListener('click', () => {
|
||
selectedVisualGroupIds = new Set(collectVisualGroups(visualRulesForSelectedPeriod()).map(group => String(group.id)));
|
||
visualGroupFilterTouched = true;
|
||
renderVisualSchedule();
|
||
});
|
||
ruleSemesterSelect?.addEventListener('change', () => {
|
||
updatePreferenceHints();
|
||
if (visualPeriodTouched) return;
|
||
selectDefaultVisualPeriod();
|
||
renderVisualSchedule();
|
||
});
|
||
document.addEventListener('keydown', (event) => {
|
||
if (event.key !== 'Escape') return;
|
||
if (draftModal && !draftModal.hidden) {
|
||
closeDraftModal();
|
||
return;
|
||
}
|
||
if (moveSlotModal?.classList.contains('open')) {
|
||
closeMoveSlotModal();
|
||
return;
|
||
}
|
||
if (conflictModal?.classList.contains('open')) {
|
||
closeConflictModal();
|
||
return;
|
||
}
|
||
if (visualContextMenu && !visualContextMenu.hidden) {
|
||
closeVisualContextMenu();
|
||
return;
|
||
}
|
||
closeVisualDrawer();
|
||
});
|
||
document.addEventListener('click', (event) => {
|
||
if (visualContextMenu?.hidden) return;
|
||
if (event.target.closest('#schedule-visual-context-menu') || event.target.closest('.schedule-visual-edit-rule')) return;
|
||
closeVisualContextMenu();
|
||
});
|
||
|
||
ruleSlotsContainer.addEventListener('click', (event) => {
|
||
const removeButton = event.target.closest('.schedule-slot-remove');
|
||
const subgroupBox = event.target.closest('.slot-subgroup-box');
|
||
const subgroupMenu = event.target.closest('.slot-subgroup-menu');
|
||
|
||
if (removeButton) {
|
||
const slots = readRuleSlots(false);
|
||
slots.splice(Number(removeButton.dataset.index), 1);
|
||
renderRuleSlots(slots.length ? slots : [{}]);
|
||
dirtyScope.mark();
|
||
return;
|
||
}
|
||
|
||
if (subgroupBox) {
|
||
toggleSlotSubgroupMenu(event, subgroupBox);
|
||
return;
|
||
}
|
||
|
||
if (subgroupMenu) {
|
||
event.stopPropagation();
|
||
}
|
||
});
|
||
|
||
ruleGroupsBox.addEventListener('click', toggleRuleGroupsMenu);
|
||
ruleGroupsBox.addEventListener('keydown', handleRuleGroupsKeydown);
|
||
ruleGroupsMenu.addEventListener('click', event => event.stopPropagation());
|
||
ruleGroupSearch?.addEventListener('input', debounce(() => searchRuleGroups(ruleGroupSearch.value), 320));
|
||
ruleGroupCheckboxes.addEventListener('change', async () => {
|
||
const slots = readRuleSlots(false);
|
||
updateRuleGroupsText();
|
||
if (await loadSubgroupsForGroups(selectedRuleGroupIds())) renderRuleSlots(slots);
|
||
});
|
||
document.addEventListener('click', () => {
|
||
closeRuleGroupsMenu();
|
||
closeSlotSubgroupMenus();
|
||
});
|
||
ruleSlotsContainer.addEventListener('change', (event) => {
|
||
if (event.target.closest('.slot-lesson-type')) {
|
||
renderRuleSlots(readRuleSlots(false));
|
||
return;
|
||
}
|
||
if (event.target.closest('.slot-subgroup-checkboxes')) {
|
||
updateSlotSubgroupText(event.target.closest('.schedule-slot-row'));
|
||
}
|
||
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, lessonTypes] = await Promise.all([
|
||
api.get('/api/subjects'),
|
||
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();
|
||
}
|
||
|
||
async function loadApprovedTeacherPreferences() {
|
||
approvedTeacherPreferences = await api.get('/api/teacher-preferences?status=APPROVED');
|
||
updatePreferenceHints();
|
||
}
|
||
|
||
async function loadScheduleVersions(preferredVersionId = null) {
|
||
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,
|
||
semesterId,
|
||
requestedVersionId
|
||
);
|
||
const published = scheduleVersions.filter(version => version.status === 'PUBLISHED');
|
||
const drafts = scheduleVersions.filter(version => version.status === 'DRAFT');
|
||
scheduleVersionSelect.innerHTML = scheduleVersions.length
|
||
? [
|
||
versionOptionGroup('Опубликованные расписания', published),
|
||
versionOptionGroup('Черновики', drafts)
|
||
].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));
|
||
}
|
||
updateVersionContext();
|
||
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 => `
|
||
<option value="${version.id}">
|
||
${escapeHtml(version.semesterLabel)} · v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)}
|
||
</option>
|
||
`).join('')}</optgroup>`;
|
||
}
|
||
|
||
function defaultScheduleSemesterId() {
|
||
return resolveAcademicPeriod(semesters, {
|
||
academicYearId: localStorage.getItem('magistr.schedule.yearId'),
|
||
semesterId: localStorage.getItem('magistr.schedule.semesterId'),
|
||
date: localIsoDate(new Date())
|
||
}).semesterId;
|
||
}
|
||
|
||
function updateVersionContext() {
|
||
const version = selectedScheduleVersion();
|
||
const isDraft = version?.status === 'DRAFT';
|
||
if (scheduleVersionContext) scheduleVersionContext.dataset.status = isDraft ? 'DRAFT' : 'PUBLISHED';
|
||
if (scheduleVersionEyebrow) scheduleVersionEyebrow.textContent = isDraft ? 'Безопасная рабочая копия' : 'Основной режим';
|
||
if (scheduleVersionTitle) scheduleVersionTitle.textContent = isDraft ? 'Редактирование черновика' : 'Опубликованное расписание';
|
||
if (!version) {
|
||
scheduleVersionNote.textContent = 'Расписание для редактирования не найдено.';
|
||
return;
|
||
}
|
||
scheduleVersionNote.textContent = isDraft
|
||
? `Версия ${version.versionNumber} · ${version.name}. Изменения не видны пользователям до публикации.`
|
||
: `Версия ${version.versionNumber} · ${version.name}. Сохранённые изменения сразу видны пользователям.`;
|
||
}
|
||
|
||
function openDraftModal() {
|
||
const base = selectedScheduleVersion();
|
||
if (!base || creatingDraft) return;
|
||
draftForm?.reset();
|
||
if (draftCopy) {
|
||
draftCopy.textContent = `За основу будет взято «${base.name}» — все его правила и слоты будут скопированы.`;
|
||
}
|
||
if (draftSummary) {
|
||
draftSummary.textContent = `${base.semesterLabel} · версия ${base.versionNumber}`;
|
||
draftSummary.dataset.tone = '';
|
||
}
|
||
draftModal.hidden = false;
|
||
draftNameInput?.focus();
|
||
}
|
||
|
||
function closeDraftModal() {
|
||
if (!draftModal || creatingDraft) return;
|
||
draftModal.hidden = true;
|
||
}
|
||
|
||
async function createDraftFromSelectedVersion(event) {
|
||
event.preventDefault();
|
||
const base = selectedScheduleVersion();
|
||
const name = draftNameInput?.value.trim();
|
||
if (!base || !name || creatingDraft) return;
|
||
creatingDraft = true;
|
||
if (draftSubmitButton) {
|
||
draftSubmitButton.disabled = true;
|
||
draftSubmitButton.textContent = 'Создаём…';
|
||
}
|
||
try {
|
||
const created = await api.post('/api/edu-office/schedule/versions', {
|
||
semesterId: Number(base.semesterId),
|
||
name,
|
||
basedOnVersionId: Number(base.id)
|
||
});
|
||
draftModal.hidden = true;
|
||
await loadScheduleVersions(created.id);
|
||
resetRuleForm();
|
||
syncRuleSemesterWithVersion();
|
||
await loadRules();
|
||
showAlert('schedule-rule-alert', `Черновик «${created.name}» создан и открыт.`, 'success');
|
||
} catch (error) {
|
||
if (draftSummary) {
|
||
draftSummary.textContent = error.message || 'Не удалось создать черновик.';
|
||
draftSummary.dataset.tone = 'error';
|
||
}
|
||
} finally {
|
||
creatingDraft = false;
|
||
if (draftSubmitButton) {
|
||
draftSubmitButton.disabled = false;
|
||
draftSubmitButton.textContent = 'Создать и открыть';
|
||
}
|
||
}
|
||
}
|
||
|
||
function selectedScheduleVersion() {
|
||
return scheduleVersions.find(version => String(version.id) === String(scheduleVersionSelect?.value)) || null;
|
||
}
|
||
|
||
function syncRuleSemesterWithVersion() {
|
||
const version = selectedScheduleVersion();
|
||
if (!version) return;
|
||
ruleSemesterSelect.value = String(version.semesterId);
|
||
ruleSemesterSelect.disabled = true;
|
||
syncSelects(ruleSemesterSelect);
|
||
}
|
||
|
||
function setRuleEditorEnabled(enabled) {
|
||
document.getElementById('schedule-rule-editor')?.classList.toggle('is-disabled', !enabled);
|
||
ruleForm.querySelectorAll('input, select, button').forEach(control => {
|
||
control.disabled = !enabled;
|
||
});
|
||
if (enabled) ruleSemesterSelect.disabled = true;
|
||
}
|
||
|
||
async function loadRules() {
|
||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
|
||
const version = selectedScheduleVersion();
|
||
if (!version) {
|
||
rules = [];
|
||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет расписания для редактирования</td></tr>';
|
||
setVisualState('Нет выбранного расписания');
|
||
return;
|
||
}
|
||
try {
|
||
rules = await api.get(`/api/admin/schedule-rules?versionId=${encodeURIComponent(version.id)}`);
|
||
if (!visualPeriodTouched) {
|
||
selectDefaultVisualPeriod();
|
||
}
|
||
renderRules();
|
||
renderVisualSchedule();
|
||
openRequestedRule();
|
||
} catch (error) {
|
||
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||
setVisualState('Ошибка загрузки правил расписания');
|
||
}
|
||
}
|
||
|
||
function openRequestedRule() {
|
||
const requestedRuleId = localStorage.getItem('magistr.schedule.openRuleId');
|
||
if (!requestedRuleId) return;
|
||
localStorage.removeItem('magistr.schedule.openRuleId');
|
||
const rule = rules.find(item => String(item.id) === requestedRuleId);
|
||
if (!rule) {
|
||
showAlert('schedule-rule-alert', 'Не удалось найти выбранное правило в этом расписании.', 'error');
|
||
return;
|
||
}
|
||
fillRuleForm(rule);
|
||
}
|
||
|
||
function renderRules() {
|
||
if (!rules.length) {
|
||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Правила расписания не созданы</td></tr>';
|
||
renderVisualSchedule();
|
||
return;
|
||
}
|
||
rulesTbody.innerHTML = rules.map(rule => `
|
||
<tr>
|
||
<td>${rule.id}</td>
|
||
<td>${escapeHtml(rule.subjectName || '-')}</td>
|
||
<td>${escapeHtml(formatSemester(rule))}</td>
|
||
<td>${escapeHtml((rule.groupNames || []).join(', ') || '-')}</td>
|
||
<td>${renderRuleLoadSummary(rule)}</td>
|
||
<td>${renderRuleSlotsSummary(rule.slots)}</td>
|
||
<td>
|
||
<div class="schedule-rule-table-actions">
|
||
<button type="button" class="btn btn-sm btn-secondary btn-edit-rule" data-id="${rule.id}">Изменить</button>
|
||
<button type="button" class="btn btn-sm btn-danger btn-delete-rule" data-id="${rule.id}">Удалить</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function toggleVisualDrawer() {
|
||
if (!visualDrawer) return;
|
||
if (visualDrawer.classList.contains('open')) {
|
||
closeVisualDrawer();
|
||
} else {
|
||
openVisualDrawer();
|
||
}
|
||
}
|
||
|
||
function openVisualDrawer() {
|
||
if (!visualDrawer) return;
|
||
visualDrawer.classList.add('open');
|
||
visualDrawer.setAttribute('aria-hidden', 'false');
|
||
visualToggleButton?.classList.add('open');
|
||
visualToggleButton?.setAttribute('aria-expanded', 'true');
|
||
visualToggleButton?.setAttribute('aria-label', 'Скрыть визуальное расписание');
|
||
if (visualBackdrop) visualBackdrop.hidden = false;
|
||
}
|
||
|
||
function closeVisualDrawer() {
|
||
if (!visualDrawer) return;
|
||
closeVisualContextMenu();
|
||
visualDrawer.classList.remove('open');
|
||
visualDrawer.setAttribute('aria-hidden', 'true');
|
||
visualToggleButton?.classList.remove('open');
|
||
visualToggleButton?.setAttribute('aria-expanded', 'false');
|
||
visualToggleButton?.setAttribute('aria-label', 'Показать визуальное расписание');
|
||
if (visualBackdrop) visualBackdrop.hidden = true;
|
||
}
|
||
|
||
function renderVisualSchedule() {
|
||
if (!visualStage || !visualSummary) return;
|
||
if (!selectedVisualSemesterId) {
|
||
syncVisualGroupFilter([]);
|
||
visualSummary.textContent = 'Выберите учебный год и семестр';
|
||
setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
|
||
return;
|
||
}
|
||
if (!rules.length) {
|
||
syncVisualGroupFilter([]);
|
||
visualSummary.textContent = 'Нет сохранённых правил';
|
||
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
|
||
return;
|
||
}
|
||
|
||
const visualRules = visualRulesForSelectedPeriod();
|
||
const periodLabel = visualPeriodLabel();
|
||
if (!visualRules.length) {
|
||
syncVisualGroupFilter([]);
|
||
visualSummary.textContent = `${periodLabel} · правил нет`;
|
||
setVisualState('В выбранном семестре нет сохранённых правил расписания');
|
||
return;
|
||
}
|
||
|
||
const allGroups = collectVisualGroups(visualRules);
|
||
syncVisualGroupFilter(allGroups);
|
||
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
|
||
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(visualRules);
|
||
if (!allGroups.length || !slots.length) {
|
||
visualSummary.textContent = 'Недостаточно данных для построения';
|
||
setVisualState('У правил должны быть группы и слоты занятий');
|
||
return;
|
||
}
|
||
if (!columns.length) {
|
||
visualSummary.textContent = 'Выберите группы для просмотра';
|
||
setVisualState('Отметьте одну или несколько групп в фильтре');
|
||
return;
|
||
}
|
||
|
||
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules);
|
||
const rows = buildVisualRows(columns, slots, lessonsByCell);
|
||
if (!rows.length) {
|
||
visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
|
||
setVisualState('Для выбранных групп нет активных пар в правилах выбранного семестра');
|
||
return;
|
||
}
|
||
|
||
visualSummary.textContent = `${periodLabel} · ${visualRules.length} ${ruleCountLabel(visualRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
|
||
visualStage.innerHTML = `
|
||
<div class="schedule-visual-table-wrap">
|
||
<table class="schedule-visual-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="schedule-visual-day-head">День</th>
|
||
<th class="schedule-visual-time-head">Время</th>
|
||
${columns.map(group => `<th>${escapeHtml(group.name)}</th>`).join('')}
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows.map(renderVisualRow).join('')}</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function buildVisualRows(columns, slots, lessonsByCell) {
|
||
const rows = [];
|
||
DAY_OPTIONS.forEach(day => {
|
||
const dayRows = slots
|
||
.map(slot => ({
|
||
day,
|
||
slot,
|
||
cells: columns.map(group => ({
|
||
group,
|
||
lessons: lessonsByCell.get(visualCellKey(group.id, day.value, slot.key)) || []
|
||
}))
|
||
}))
|
||
.filter(row => row.cells.some(cell => cell.lessons.length));
|
||
|
||
dayRows.forEach((row, index) => {
|
||
rows.push({
|
||
...row,
|
||
dayRowspan: index === 0 ? dayRows.length : 0
|
||
});
|
||
});
|
||
});
|
||
return rows;
|
||
}
|
||
|
||
function renderVisualRow(row) {
|
||
return `
|
||
<tr>
|
||
${row.dayRowspan ? `<td class="schedule-visual-day-cell" rowspan="${row.dayRowspan}">${escapeHtml(row.day.label)}</td>` : ''}
|
||
<td class="schedule-visual-time-cell">
|
||
<strong>${escapeHtml(row.slot.orderLabel)}</strong>
|
||
<span>${escapeHtml(row.slot.timeLabel)}</span>
|
||
</td>
|
||
${row.cells.map(cell => {
|
||
const emptyClass = cell.lessons.length ? '' : ' schedule-visual-lesson-cell-empty';
|
||
return `<td class="schedule-visual-lesson-cell${emptyClass}">${renderVisualCellContent(cell.lessons)}</td>`;
|
||
}).join('')}
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
function buildVisualLessonsByCell(allowedGroupIds, visualRules) {
|
||
const grouped = new Map();
|
||
const activeWeeks = buildActiveWeeksByRuleSlot(visualRules);
|
||
visualRules.forEach(rule => {
|
||
(rule.slots || []).forEach((slot, index) => {
|
||
const slotKey = visualRuleSlotKey(slot, index);
|
||
(rule.groupIds || []).forEach((groupId, groupIndex) => {
|
||
if (!allowedGroupIds.has(String(groupId))) return;
|
||
const weeks = activeWeeks.get(activeWeekKey(rule.id, groupId, slotKey)) || [];
|
||
if (!weeks.length) return;
|
||
const lesson = {
|
||
id: `${rule.id}:${slotKey}:${groupId}`,
|
||
ruleId: rule.id,
|
||
slotId: slot.id || '',
|
||
slotIndex: index,
|
||
dayOfWeek: slot.dayOfWeek,
|
||
timeSlotId: slot.timeSlotId,
|
||
parity: slot.parity || 'BOTH',
|
||
subjectName: rule.subjectName || 'Без дисциплины',
|
||
teacherName: slot.teacherName || '',
|
||
classroomName: slot.classroomName || '',
|
||
lessonTypeName: slot.lessonTypeName || '',
|
||
lessonFormat: slot.lessonFormat || '',
|
||
subgroupNames: slotSubgroupNames(slot),
|
||
weekText: compactWeekRanges(weeks, maxWeeksForRule(rule), slot.parity),
|
||
groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId)
|
||
};
|
||
const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot));
|
||
if (!grouped.has(key)) grouped.set(key, []);
|
||
grouped.get(key).push(lesson);
|
||
});
|
||
});
|
||
});
|
||
grouped.forEach(items => items.sort(compareVisualLessons));
|
||
return grouped;
|
||
}
|
||
|
||
function buildActiveWeeksByRuleSlot(visualRules) {
|
||
const activeWeeks = new Map();
|
||
visualRules.forEach(rule => {
|
||
const maxWeeks = maxWeeksForRule(rule);
|
||
(rule.groupIds || []).forEach(groupId => {
|
||
LESSON_TYPE_LIMITS.forEach(config => {
|
||
const totalHours = Number(rule[config.hoursField] || 0);
|
||
if (totalHours <= 0) return;
|
||
const categorySlots = (rule.slots || [])
|
||
.map((slot, index) => ({ slot, index }))
|
||
.filter(item => lessonTypeKeyById(item.slot.lessonTypeId) === config.key)
|
||
.sort(compareSlotItems);
|
||
if (!categorySlots.length) return;
|
||
|
||
let consumedHours = 0;
|
||
const startWeek = Math.max(1, Number(rule[config.startWeekField] || 1));
|
||
for (let week = startWeek; week <= maxWeeks && consumedHours < totalHours; week += 1) {
|
||
for (const item of categorySlots) {
|
||
if (consumedHours >= totalHours) break;
|
||
if (!slotAppliesToWeek(item.slot, week)) continue;
|
||
const key = activeWeekKey(rule.id, groupId, visualRuleSlotKey(item.slot, item.index));
|
||
if (!activeWeeks.has(key)) activeWeeks.set(key, []);
|
||
activeWeeks.get(key).push(week);
|
||
consumedHours += ACADEMIC_HOURS_PER_SLOT;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
});
|
||
return activeWeeks;
|
||
}
|
||
|
||
function renderVisualCellContent(lessons) {
|
||
if (!lessons.length) return '';
|
||
const oddLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'ODD');
|
||
const evenLessons = lessons.filter(lesson => lesson.parity === 'BOTH' || lesson.parity === 'EVEN');
|
||
const shouldSplit = lessons.some(l => l.parity !== 'BOTH');
|
||
|
||
if (!shouldSplit) {
|
||
return `<div class="schedule-visual-unified">${uniqueVisualLessons(lessons).map(renderVisualLesson).join('')}</div>`;
|
||
}
|
||
|
||
return CELL_PARITY_LAYOUT.map(({ parity, label }) => {
|
||
const parityLessons = parity === 'ODD' ? oddLessons : evenLessons;
|
||
return `
|
||
<div class="schedule-visual-half schedule-visual-half-${parity.toLowerCase()}">
|
||
<span class="schedule-visual-half-label">${escapeHtml(label)}</span>
|
||
${parityLessons.length ? parityLessons.map(renderVisualLesson).join('') : ''}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function uniqueVisualLessons(lessons) {
|
||
const unique = new Map();
|
||
lessons.forEach(lesson => {
|
||
unique.set(visualLessonSignature([lesson]), lesson);
|
||
});
|
||
return Array.from(unique.values()).sort(compareVisualLessons);
|
||
}
|
||
|
||
function renderVisualLesson(lesson) {
|
||
const meta = [
|
||
lesson.lessonTypeName,
|
||
lesson.lessonFormat,
|
||
lesson.teacherName,
|
||
lesson.classroomName
|
||
].filter(Boolean).join(' · ');
|
||
const subgroupText = lesson.subgroupNames.join(', ');
|
||
return `
|
||
<article class="schedule-visual-lesson">
|
||
<strong>${escapeHtml(lesson.subjectName)}${lesson.weekText ? ` <span>${escapeHtml(lesson.weekText)}</span>` : ''}</strong>
|
||
${subgroupText ? `<small>${escapeHtml(subgroupText)}</small>` : ''}
|
||
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
|
||
<button type="button"
|
||
class="schedule-visual-edit-rule"
|
||
data-rule-id="${escapeHtml(String(lesson.ruleId))}"
|
||
data-slot-id="${escapeHtml(String(lesson.slotId || ''))}"
|
||
data-slot-index="${escapeHtml(String(lesson.slotIndex ?? ''))}"
|
||
title="Действия со слотом"
|
||
aria-label="${escapeHtml(`Действия со слотом: ${lesson.subjectName}`)}">⋮</button>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function collectVisualGroups(visualRules = rules) {
|
||
const byId = new Map();
|
||
visualRules.forEach(rule => {
|
||
(rule.groupIds || []).forEach((id, index) => {
|
||
if (!byId.has(String(id))) {
|
||
byId.set(String(id), {
|
||
id,
|
||
name: (rule.groupNames || [])[index] || groupNameById(id)
|
||
});
|
||
}
|
||
});
|
||
});
|
||
return Array.from(byId.values()).sort((a, b) => naturalCompare(a.name, b.name));
|
||
}
|
||
|
||
function collectVisualSlotsFromRules(visualRules = rules) {
|
||
const byKey = new Map();
|
||
visualRules.flatMap(rule => rule.slots || []).forEach(slot => {
|
||
const key = visualSlotKey(slot);
|
||
if (!byKey.has(key)) {
|
||
byKey.set(key, {
|
||
key,
|
||
orderNumber: slot.timeSlotOrder,
|
||
orderLabel: slot.timeSlotOrder ? `${slot.timeSlotOrder} пара` : 'Пара',
|
||
timeLabel: slot.timeSlotLabel || 'Время не указано'
|
||
});
|
||
}
|
||
});
|
||
return Array.from(byKey.values()).sort(compareVisualSlots);
|
||
}
|
||
|
||
function visualSlotFromTimeSlot(slot) {
|
||
return {
|
||
key: String(slot.id),
|
||
orderNumber: slot.orderNumber,
|
||
orderLabel: slot.orderNumber ? `${slot.orderNumber} пара` : 'Пара',
|
||
timeLabel: slot.startTime || slot.endTime ? `${trimTime(slot.startTime)}-${trimTime(slot.endTime)}` : 'Время не указано'
|
||
};
|
||
}
|
||
|
||
function setVisualState(message) {
|
||
if (!visualStage) return;
|
||
visualStage.innerHTML = `<div class="loading-row schedule-visual-state">${escapeHtml(message)}</div>`;
|
||
}
|
||
|
||
function handleVisualGroupFilterChange(event) {
|
||
if (!event.target.matches('input[type="checkbox"]')) return;
|
||
selectedVisualGroupIds = new Set(Array.from(visualGroupFilter.querySelectorAll('input[type="checkbox"]:checked'))
|
||
.map(input => String(input.value)));
|
||
visualGroupFilterTouched = true;
|
||
renderVisualSchedule();
|
||
}
|
||
|
||
function handleVisualYearChange() {
|
||
if (syncingVisualPeriod) return;
|
||
selectedVisualYearId = visualYearSelect?.value || '';
|
||
selectedVisualSemesterId = firstSemesterForYear(selectedVisualYearId)?.id
|
||
? String(firstSemesterForYear(selectedVisualYearId).id)
|
||
: '';
|
||
visualPeriodTouched = true;
|
||
visualGroupFilterTouched = false;
|
||
populateVisualSemesterSelect();
|
||
renderVisualSchedule();
|
||
}
|
||
|
||
function handleVisualSemesterChange() {
|
||
if (syncingVisualPeriod) return;
|
||
selectedVisualSemesterId = visualSemesterSelect?.value || '';
|
||
visualPeriodTouched = true;
|
||
visualGroupFilterTouched = false;
|
||
renderVisualSchedule();
|
||
}
|
||
|
||
function handleVisualStageClick(event) {
|
||
const editButton = event.target.closest('.schedule-visual-edit-rule');
|
||
if (!editButton) return;
|
||
event.stopPropagation();
|
||
openVisualContextMenu(editButton);
|
||
}
|
||
|
||
function openVisualContextMenu(button) {
|
||
const rule = rules.find(item => String(item.id) === String(button.dataset.ruleId));
|
||
if (!rule) {
|
||
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
||
return;
|
||
}
|
||
visualContextTarget = {
|
||
ruleId: button.dataset.ruleId,
|
||
slotId: button.dataset.slotId || '',
|
||
slotIndex: button.dataset.slotIndex || ''
|
||
};
|
||
if (!visualContextMenu) return;
|
||
visualContextMenu.hidden = false;
|
||
const anchorRect = button.getBoundingClientRect();
|
||
const position = calculateVisualContextMenuPosition({
|
||
anchorRect,
|
||
menuWidth: visualContextMenu.offsetWidth,
|
||
menuHeight: visualContextMenu.offsetHeight,
|
||
viewportWidth: window.innerWidth,
|
||
viewportHeight: window.innerHeight
|
||
});
|
||
visualContextMenuPositionAnimation?.cancel();
|
||
const positionedTransform = `translate3d(${position.left}px, ${position.top}px, 0)`;
|
||
visualContextMenuPositionAnimation = visualContextMenu.animate(
|
||
[
|
||
{ transform: positionedTransform },
|
||
{ transform: positionedTransform }
|
||
],
|
||
{ duration: 1, fill: 'forwards' }
|
||
);
|
||
visualContextMenu.querySelector('button')?.focus();
|
||
}
|
||
|
||
function closeVisualContextMenu() {
|
||
visualContextTarget = null;
|
||
if (!visualContextMenu) return;
|
||
visualContextMenu.hidden = true;
|
||
visualContextMenuPositionAnimation?.cancel();
|
||
visualContextMenuPositionAnimation = null;
|
||
}
|
||
|
||
async function handleVisualContextMenuClick(event) {
|
||
const actionButton = event.target.closest('button[data-action]');
|
||
if (!actionButton || !visualContextTarget) return;
|
||
const action = actionButton.dataset.action;
|
||
const target = { ...visualContextTarget };
|
||
const rule = rules.find(item => String(item.id) === String(target.ruleId));
|
||
if (!rule) {
|
||
closeVisualContextMenu();
|
||
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
|
||
return;
|
||
}
|
||
|
||
if (action === 'edit-rule') {
|
||
closeVisualContextMenu();
|
||
fillRuleForm(rule);
|
||
closeVisualDrawer();
|
||
showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success');
|
||
return;
|
||
}
|
||
|
||
if (action === 'move-slot') {
|
||
openMoveSlotModal(target);
|
||
return;
|
||
}
|
||
|
||
if (action === 'delete-rule') {
|
||
await deleteRuleFromVisual(rule);
|
||
}
|
||
}
|
||
|
||
async function deleteRuleFromVisual(rule) {
|
||
closeVisualContextMenu();
|
||
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');
|
||
await loadRules();
|
||
} catch (error) {
|
||
showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error');
|
||
}
|
||
}
|
||
|
||
function openMoveSlotModal(target) {
|
||
const rule = rules.find(item => String(item.id) === String(target.ruleId));
|
||
const slotIndex = findRuleSlotIndex(rule, target);
|
||
const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null;
|
||
if (!rule || !slot) {
|
||
closeVisualContextMenu();
|
||
showAlert('schedule-rule-alert', 'Слот правила не найден. Обновите список правил.', 'error');
|
||
return;
|
||
}
|
||
|
||
pendingSlotMove = {
|
||
ruleId: String(rule.id),
|
||
slotIndex
|
||
};
|
||
if (moveSlotTitle) {
|
||
moveSlotTitle.textContent = rule.subjectName || 'Изменить день и пару';
|
||
}
|
||
if (moveSlotSummary) {
|
||
moveSlotSummary.textContent = `Текущий слот: ${conflictSlotCurrentText(slot) || 'не указан'}`;
|
||
}
|
||
if (moveSlotDaySelect) {
|
||
moveSlotDaySelect.innerHTML = options(DAY_OPTIONS, slot.dayOfWeek);
|
||
}
|
||
if (moveSlotTimeSelect) {
|
||
moveSlotTimeSelect.innerHTML = options(timeSlots.map(toTimeSlotOption), slot.timeSlotId);
|
||
}
|
||
hideAlert('schedule-move-slot-alert');
|
||
closeVisualContextMenu();
|
||
moveSlotModal?.classList.add('open');
|
||
moveSlotModal?.setAttribute('aria-hidden', 'false');
|
||
moveSlotDaySelect?.focus();
|
||
}
|
||
|
||
function closeMoveSlotModal() {
|
||
pendingSlotMove = null;
|
||
moveSlotModal?.classList.remove('open');
|
||
moveSlotModal?.setAttribute('aria-hidden', 'true');
|
||
hideAlert('schedule-move-slot-alert');
|
||
}
|
||
|
||
async function saveVisualSlotMove(event) {
|
||
event.preventDefault();
|
||
if (!pendingSlotMove) return;
|
||
hideAlert('schedule-move-slot-alert');
|
||
const rule = rules.find(item => String(item.id) === pendingSlotMove.ruleId);
|
||
const slot = rule?.slots?.[pendingSlotMove.slotIndex];
|
||
if (!rule || !slot) {
|
||
showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error');
|
||
return;
|
||
}
|
||
const dayOfWeek = Number(moveSlotDaySelect?.value);
|
||
const timeSlotId = Number(moveSlotTimeSelect?.value);
|
||
if (!dayOfWeek || !timeSlotId) {
|
||
showAlert('schedule-move-slot-alert', 'Выберите день и пару', 'error');
|
||
return;
|
||
}
|
||
|
||
const previousButtonText = moveSlotSubmitButton?.textContent || '';
|
||
if (moveSlotSubmitButton) {
|
||
moveSlotSubmitButton.disabled = true;
|
||
moveSlotSubmitButton.textContent = 'Сохраняем...';
|
||
}
|
||
try {
|
||
const slots = (rule.slots || []).map(ruleSlotToPayload);
|
||
slots[pendingSlotMove.slotIndex] = {
|
||
...slots[pendingSlotMove.slotIndex],
|
||
dayOfWeek,
|
||
timeSlotId
|
||
};
|
||
const payload = buildRulePayloadFromRule(rule, slots);
|
||
const saved = await api.put('/api/admin/schedule-rules/' + rule.id, payload);
|
||
closeMoveSlotModal();
|
||
showAlert('schedule-rule-alert', `Слот правила "${saved.subjectName || 'расписания'}" перенесён`, 'success');
|
||
await loadRules();
|
||
} catch (error) {
|
||
if (isScheduleRuleConflictError(error)) {
|
||
showAlert(
|
||
'schedule-move-slot-alert',
|
||
`Новое место занято правилом "${ruleTitle(error.data.conflictRule)}". Выберите другой день или пару.`,
|
||
'error'
|
||
);
|
||
return;
|
||
}
|
||
showAlert('schedule-move-slot-alert', error.message || 'Ошибка переноса слота', 'error');
|
||
} finally {
|
||
if (moveSlotSubmitButton) {
|
||
moveSlotSubmitButton.disabled = false;
|
||
moveSlotSubmitButton.textContent = previousButtonText || 'Сохранить перенос';
|
||
}
|
||
}
|
||
}
|
||
|
||
function findRuleSlotIndex(rule, target) {
|
||
if (!rule?.slots?.length) return -1;
|
||
if (target.slotId) {
|
||
const byId = rule.slots.findIndex(slot => String(slot.id) === String(target.slotId));
|
||
if (byId >= 0) return byId;
|
||
}
|
||
const index = Number(target.slotIndex);
|
||
return Number.isInteger(index) && index >= 0 && index < rule.slots.length ? index : -1;
|
||
}
|
||
|
||
function ruleSlotToPayload(slot) {
|
||
return {
|
||
id: slot.id || null,
|
||
dayOfWeek: Number(slot.dayOfWeek),
|
||
parity: slot.parity || 'BOTH',
|
||
timeSlotId: Number(slot.timeSlotId),
|
||
subgroupId: slot.subgroupId || null,
|
||
subgroupIds: slotSubgroupIds(slot),
|
||
teacherId: Number(slot.teacherId),
|
||
classroomId: Number(slot.classroomId),
|
||
lessonTypeId: Number(slot.lessonTypeId),
|
||
lessonFormat: slot.lessonFormat || 'Очно'
|
||
};
|
||
}
|
||
|
||
function syncVisualGroupFilter(allGroups) {
|
||
ensureVisualGroupSelection(allGroups);
|
||
if (!visualGroupFilter) return;
|
||
if (!allGroups.length) {
|
||
visualGroupFilter.innerHTML = '<span class="muted">Нет групп в правилах</span>';
|
||
return;
|
||
}
|
||
visualGroupFilter.innerHTML = allGroups.map(group => {
|
||
const id = String(group.id);
|
||
return `
|
||
<label class="schedule-visual-group-chip">
|
||
<input type="checkbox" value="${escapeHtml(id)}" ${selectedVisualGroupIds.has(id) ? 'checked' : ''}>
|
||
<span>${escapeHtml(group.name)}</span>
|
||
</label>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function ensureVisualGroupSelection(allGroups) {
|
||
const availableIds = new Set(allGroups.map(group => String(group.id)));
|
||
selectedVisualGroupIds = new Set(Array.from(selectedVisualGroupIds).filter(id => availableIds.has(id)));
|
||
if (visualGroupFilterTouched) return;
|
||
|
||
const formIds = selectedRuleGroupIds()
|
||
.map(String)
|
||
.filter(id => availableIds.has(id));
|
||
if (!visualGroupFilterTouched && formIds.length) {
|
||
selectedVisualGroupIds = new Set(formIds);
|
||
return;
|
||
}
|
||
|
||
if (!selectedVisualGroupIds.size && allGroups.length) {
|
||
selectedVisualGroupIds = new Set([String(allGroups[0].id)]);
|
||
}
|
||
}
|
||
|
||
function selectDefaultVisualPeriod() {
|
||
const formSemester = semesterById(ruleSemesterSelect?.value);
|
||
const ruleSemester = firstSemesterWithRules();
|
||
const fallbackSemester = semesters[0];
|
||
const semester = formSemester || ruleSemester || fallbackSemester;
|
||
setVisualPeriodBySemesterId(semester?.id || '');
|
||
}
|
||
|
||
function setVisualPeriodBySemesterId(semesterId) {
|
||
const semester = semesterById(semesterId);
|
||
selectedVisualSemesterId = semester ? String(semester.id) : '';
|
||
selectedVisualYearId = semester ? String(semester.academicYearId) : '';
|
||
populateVisualYearSelect();
|
||
populateVisualSemesterSelect();
|
||
}
|
||
|
||
function populateVisualYearSelect() {
|
||
if (!visualYearSelect) return;
|
||
visualYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
|
||
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||
if (selectedVisualYearId && academicYears.some(year => String(year.id) === selectedVisualYearId)) {
|
||
visualYearSelect.value = selectedVisualYearId;
|
||
} else if (academicYears.length) {
|
||
selectedVisualYearId = String(academicYears[0].id);
|
||
visualYearSelect.value = selectedVisualYearId;
|
||
} else {
|
||
selectedVisualYearId = '';
|
||
visualYearSelect.value = '';
|
||
}
|
||
syncVisualPeriodSelects(visualYearSelect);
|
||
}
|
||
|
||
function populateVisualSemesterSelect() {
|
||
if (!visualSemesterSelect) return;
|
||
const yearSemesters = semestersForYear(selectedVisualYearId);
|
||
visualSemesterSelect.innerHTML = yearSemesters.length
|
||
? '<option value="">Выберите семестр</option>' + yearSemesters
|
||
.map(semester => `<option value="${semester.id}">${escapeHtml(shortSemesterLabel(semester))}</option>`)
|
||
.join('')
|
||
: '<option value="">Нет семестров</option>';
|
||
|
||
if (selectedVisualSemesterId && yearSemesters.some(semester => String(semester.id) === selectedVisualSemesterId)) {
|
||
visualSemesterSelect.value = selectedVisualSemesterId;
|
||
} else if (yearSemesters.length) {
|
||
selectedVisualSemesterId = String(yearSemesters[0].id);
|
||
visualSemesterSelect.value = selectedVisualSemesterId;
|
||
} else {
|
||
selectedVisualSemesterId = '';
|
||
visualSemesterSelect.value = '';
|
||
}
|
||
syncVisualPeriodSelects(visualSemesterSelect);
|
||
}
|
||
|
||
function syncVisualPeriodSelects(...selects) {
|
||
syncingVisualPeriod = true;
|
||
syncSelects(...selects);
|
||
syncingVisualPeriod = false;
|
||
}
|
||
|
||
function visualRulesForSelectedPeriod() {
|
||
if (!selectedVisualSemesterId) return [];
|
||
return rules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
|
||
}
|
||
|
||
function firstSemesterWithRules() {
|
||
const ruleSemesterIds = new Set(rules.map(rule => String(rule.semesterId)));
|
||
return semesters.find(semester => ruleSemesterIds.has(String(semester.id)));
|
||
}
|
||
|
||
function semesterById(id) {
|
||
return semesters.find(semester => String(semester.id) === String(id));
|
||
}
|
||
|
||
function semestersForYear(yearId) {
|
||
return semesters.filter(semester => String(semester.academicYearId) === String(yearId));
|
||
}
|
||
|
||
function firstSemesterForYear(yearId) {
|
||
return semestersForYear(yearId)[0] || null;
|
||
}
|
||
|
||
function visualPeriodLabel() {
|
||
const semester = semesterById(selectedVisualSemesterId);
|
||
return semester ? shortSemesterLabel(semester) : 'Семестр не выбран';
|
||
}
|
||
|
||
function renderRuleSlotsSummary(slots) {
|
||
if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
|
||
return slots.map(slot => {
|
||
const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
|
||
const parts = [
|
||
slot.dayName,
|
||
slot.timeSlotLabel,
|
||
parity,
|
||
slotSubgroupNames(slot).join(', '),
|
||
slot.teacherName,
|
||
slot.classroomName,
|
||
slot.lessonTypeName,
|
||
slot.lessonFormat
|
||
].filter(Boolean);
|
||
return `<div class="schedule-slot-summary">${escapeHtml(parts.join(' · '))}</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderRuleLoadSummary(rule) {
|
||
const rows = LESSON_TYPE_LIMITS
|
||
.map(config => ({
|
||
label: config.label,
|
||
hours: Number(rule[config.hoursField] || 0),
|
||
startWeek: Number(rule[config.startWeekField] || 1)
|
||
}))
|
||
.filter(item => item.hours > 0);
|
||
if (!rows.length) return '<span class="muted">Часы не указаны</span>';
|
||
return rows.map(item => (
|
||
`<div class="schedule-slot-summary">${escapeHtml(`${item.label}: ${item.hours} ч, с ${item.startWeek} недели`)}</div>`
|
||
)).join('');
|
||
}
|
||
|
||
async function handleRuleTableClick(event) {
|
||
const editButton = event.target.closest('.btn-edit-rule');
|
||
const deleteButton = event.target.closest('.btn-delete-rule');
|
||
|
||
if (editButton) {
|
||
const rule = rules.find(item => item.id == editButton.dataset.id)
|
||
|| await api.get('/api/admin/schedule-rules/' + editButton.dataset.id);
|
||
fillRuleForm(rule);
|
||
return;
|
||
}
|
||
|
||
if (deleteButton) {
|
||
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');
|
||
await loadRules();
|
||
} catch (error) {
|
||
showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
async function saveRule(event) {
|
||
event.preventDefault();
|
||
hideAlert('schedule-rule-alert');
|
||
let payload = null;
|
||
let id = '';
|
||
try {
|
||
payload = buildRulePayload();
|
||
id = ruleIdInput.value;
|
||
const saved = await saveRulePayload(id, payload);
|
||
await finishSuccessfulRuleSave(saved);
|
||
} catch (error) {
|
||
if (payload && isScheduleRuleConflictError(error)) {
|
||
openConflictModal(error, payload, id);
|
||
return;
|
||
}
|
||
showAlert('schedule-rule-alert', error.message || 'Ошибка сохранения правила', 'error');
|
||
}
|
||
}
|
||
|
||
async function saveRulePayload(id, payload) {
|
||
return id
|
||
? api.put('/api/admin/schedule-rules/' + id, payload)
|
||
: api.post('/api/admin/schedule-rules', payload);
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
function openConflictModal(error, pendingPayload, pendingRuleId) {
|
||
const conflictRule = error.data.conflictRule;
|
||
pendingConflictResolution = {
|
||
conflictRule: clonePlain(conflictRule),
|
||
conflictFields: Array.isArray(error.data.conflictFields) ? [...error.data.conflictFields] : [],
|
||
pendingPayload: clonePlain(pendingPayload),
|
||
pendingRuleId: pendingRuleId || ''
|
||
};
|
||
if (conflictSummary) {
|
||
conflictSummary.textContent = error.message || 'Ранее созданное правило занимает выбранный слот.';
|
||
}
|
||
if (conflictNewRuleTitle) {
|
||
conflictNewRuleTitle.textContent = ruleDraftTitle(pendingPayload);
|
||
}
|
||
if (conflictNewRuleMeta) {
|
||
conflictNewRuleMeta.textContent = ruleDraftMeta(pendingPayload);
|
||
}
|
||
if (conflictExistingRuleTitle) {
|
||
conflictExistingRuleTitle.textContent = ruleTitle(conflictRule);
|
||
}
|
||
if (conflictExistingRuleMeta) {
|
||
conflictExistingRuleMeta.textContent = ruleMeta(conflictRule);
|
||
}
|
||
renderConflictReasons(pendingConflictResolution.conflictFields, error.data.conflictReasons);
|
||
renderConflictSlots(conflictRule, pendingConflictResolution.conflictFields);
|
||
hideAlert('schedule-conflict-alert');
|
||
conflictModal?.classList.add('open');
|
||
conflictModal?.setAttribute('aria-hidden', 'false');
|
||
}
|
||
|
||
function closeConflictModal() {
|
||
pendingConflictResolution = null;
|
||
conflictModal?.classList.remove('open');
|
||
conflictModal?.setAttribute('aria-hidden', 'true');
|
||
if (conflictSlotsContainer) conflictSlotsContainer.innerHTML = '';
|
||
if (conflictReasons) {
|
||
conflictReasons.hidden = true;
|
||
conflictReasons.innerHTML = '';
|
||
}
|
||
hideAlert('schedule-conflict-alert');
|
||
}
|
||
|
||
async function resolveScheduleRuleConflict(event) {
|
||
event.preventDefault();
|
||
if (!pendingConflictResolution) return;
|
||
hideAlert('schedule-conflict-alert');
|
||
const previousButtonText = conflictSubmitButton?.textContent || '';
|
||
if (conflictSubmitButton) {
|
||
conflictSubmitButton.disabled = true;
|
||
conflictSubmitButton.textContent = 'Сохраняем...';
|
||
}
|
||
|
||
try {
|
||
const currentResolution = pendingConflictResolution;
|
||
const conflictPayload = buildRulePayloadFromRule(
|
||
currentResolution.conflictRule,
|
||
readConflictSlots(true)
|
||
);
|
||
await api.put('/api/admin/schedule-rules/' + currentResolution.conflictRule.id, conflictPayload);
|
||
|
||
try {
|
||
const saved = await saveRulePayload(
|
||
currentResolution.pendingRuleId,
|
||
currentResolution.pendingPayload
|
||
);
|
||
closeConflictModal();
|
||
await finishSuccessfulRuleSave(saved);
|
||
} catch (retryError) {
|
||
if (isScheduleRuleConflictError(retryError)) {
|
||
await loadRules();
|
||
openConflictModal(
|
||
retryError,
|
||
currentResolution.pendingPayload,
|
||
currentResolution.pendingRuleId
|
||
);
|
||
showAlert(
|
||
'schedule-conflict-alert',
|
||
'Один конфликт устранён, но найдено ещё одно занятое место. Измените следующее правило и повторите сохранение.',
|
||
'error'
|
||
);
|
||
return;
|
||
}
|
||
throw retryError;
|
||
}
|
||
} catch (error) {
|
||
if (isScheduleRuleConflictError(error)) {
|
||
showAlert(
|
||
'schedule-conflict-alert',
|
||
`Выбранное место для переносимого правила тоже занято: ${ruleTitle(error.data.conflictRule)}. Выберите другой день, пару, преподавателя или аудиторию.`,
|
||
'error'
|
||
);
|
||
return;
|
||
}
|
||
showAlert('schedule-conflict-alert', error.message || 'Ошибка разрешения конфликта', 'error');
|
||
} finally {
|
||
if (conflictSubmitButton) {
|
||
conflictSubmitButton.disabled = false;
|
||
conflictSubmitButton.textContent = previousButtonText || 'Сохранить и повторить';
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderConflictReasons(fields, reasons) {
|
||
if (!conflictReasons) return;
|
||
const labels = Array.isArray(reasons) && reasons.length
|
||
? reasons
|
||
: (fields || []).map(conflictFieldLabel).filter(Boolean);
|
||
if (!labels.length) {
|
||
conflictReasons.hidden = true;
|
||
conflictReasons.innerHTML = '';
|
||
return;
|
||
}
|
||
conflictReasons.hidden = false;
|
||
conflictReasons.innerHTML = `
|
||
<span>Причина:</span>
|
||
${labels.map(label => `<strong>${escapeHtml(label)}</strong>`).join('')}
|
||
`;
|
||
}
|
||
|
||
function renderConflictSlots(rule, conflictFields = []) {
|
||
conflictComboboxes.forEach(combobox => combobox.destroy());
|
||
conflictComboboxes = [];
|
||
const slots = rule.slots || [];
|
||
if (!conflictSlotsContainer) return;
|
||
if (!slots.length) {
|
||
conflictSlotsContainer.innerHTML = '<div class="loading-row">У конфликтующего правила нет слотов</div>';
|
||
return;
|
||
}
|
||
conflictSlotsContainer.innerHTML = slots.map((slot, index) => {
|
||
const fixedParts = [
|
||
slot.lessonTypeName,
|
||
slot.lessonFormat,
|
||
slotSubgroupNames(slot).join(', ')
|
||
].filter(Boolean);
|
||
const groupHitClass = conflictFieldClass('group', conflictFields);
|
||
return `
|
||
<section class="schedule-conflict-slot-row" data-index="${index}">
|
||
<div class="schedule-conflict-slot-fixed ${groupHitClass}">
|
||
<span>Слот ${index + 1}</span>
|
||
<strong>${escapeHtml(fixedParts.join(' · ') || 'Занятие')}</strong>
|
||
<small>${escapeHtml(conflictSlotCurrentText(slot))}</small>
|
||
</div>
|
||
<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" 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) {
|
||
return (fields || []).includes(field) ? 'schedule-conflict-field-hit' : '';
|
||
}
|
||
|
||
function conflictFieldLabel(field) {
|
||
if (field === 'teacher') return 'Преподаватель';
|
||
if (field === 'classroom') return 'Аудитория';
|
||
if (field === 'group') return 'Группа';
|
||
return field;
|
||
}
|
||
|
||
function readConflictSlots(validate) {
|
||
const conflictRule = pendingConflictResolution?.conflictRule;
|
||
const rows = Array.from(conflictSlotsContainer?.querySelectorAll('.schedule-conflict-slot-row') || []);
|
||
return rows.map((row, index) => {
|
||
const original = conflictRule?.slots?.[index] || {};
|
||
const slot = {
|
||
id: original.id || null,
|
||
dayOfWeek: Number(row.querySelector('.conflict-slot-day').value),
|
||
parity: row.querySelector('.conflict-slot-parity').value,
|
||
timeSlotId: Number(row.querySelector('.conflict-slot-time').value),
|
||
subgroupId: original.subgroupId || null,
|
||
subgroupIds: slotSubgroupIds(original),
|
||
teacherId: Number(row.querySelector('.conflict-slot-teacher').value),
|
||
classroomId: Number(row.querySelector('.conflict-slot-classroom').value),
|
||
lessonTypeId: Number(original.lessonTypeId),
|
||
lessonFormat: original.lessonFormat || 'Очно'
|
||
};
|
||
if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
|
||
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) {
|
||
throw new Error(`Заполните слот ${index + 1} конфликтующего правила`);
|
||
}
|
||
return slot;
|
||
});
|
||
}
|
||
|
||
function buildRulePayloadFromRule(rule, slots) {
|
||
const groupIds = (rule.groupIds || []).map(Number).filter(Boolean);
|
||
if (!rule.id) throw new Error('Конфликтующее правило не найдено');
|
||
if (!rule.subjectId) throw new Error('У конфликтующего правила не указана дисциплина');
|
||
if (!rule.semesterId) throw new Error('У конфликтующего правила не указан семестр');
|
||
if (!groupIds.length) throw new Error('У конфликтующего правила нет групп');
|
||
if (!slots.length) throw new Error('У конфликтующего правила нет слотов');
|
||
return {
|
||
id: Number(rule.id),
|
||
subjectId: Number(rule.subjectId),
|
||
semesterId: Number(rule.semesterId),
|
||
scheduleVersionId: Number(rule.scheduleVersionId || scheduleVersionSelect.value),
|
||
lectureAcademicHours: Number(rule.lectureAcademicHours || 0),
|
||
laboratoryAcademicHours: Number(rule.laboratoryAcademicHours || 0),
|
||
practiceAcademicHours: Number(rule.practiceAcademicHours || 0),
|
||
lectureStartWeek: Number(rule.lectureStartWeek || 1),
|
||
laboratoryStartWeek: Number(rule.laboratoryStartWeek || 1),
|
||
practiceStartWeek: Number(rule.practiceStartWeek || 1),
|
||
groupIds,
|
||
slots
|
||
};
|
||
}
|
||
|
||
function isScheduleRuleConflictError(error) {
|
||
return error?.status === 409 && Boolean(error.data?.conflictRule);
|
||
}
|
||
|
||
function clonePlain(value) {
|
||
return JSON.parse(JSON.stringify(value));
|
||
}
|
||
|
||
function ruleDraftTitle(payload) {
|
||
const subject = subjects.find(item => String(item.id) === String(payload.subjectId));
|
||
return subject?.name || `Правило по дисциплине ${payload.subjectId}`;
|
||
}
|
||
|
||
function ruleTitle(rule) {
|
||
return rule?.subjectName || ruleDraftTitle(rule || {});
|
||
}
|
||
|
||
function ruleDraftMeta(payload) {
|
||
const groupNames = (payload.groupIds || []).map(groupNameById).join(', ') || 'группы не выбраны';
|
||
return [semesterTextById(payload.semesterId), groupNames, typeLoadText(payload)]
|
||
.filter(Boolean)
|
||
.join(' · ');
|
||
}
|
||
|
||
function ruleMeta(rule) {
|
||
const groupNames = (rule.groupNames || []).join(', ')
|
||
|| (rule.groupIds || []).map(groupNameById).join(', ')
|
||
|| 'группы не выбраны';
|
||
return [formatSemester(rule), groupNames, typeLoadText(rule)]
|
||
.filter(Boolean)
|
||
.join(' · ');
|
||
}
|
||
|
||
function typeLoadText(rule) {
|
||
return LESSON_TYPE_LIMITS
|
||
.map(config => {
|
||
const hours = Number(rule?.[config.hoursField] || 0);
|
||
const startWeek = Number(rule?.[config.startWeekField] || 1);
|
||
return hours > 0 ? `${config.label}: ${hours} ч, с ${startWeek} недели` : '';
|
||
})
|
||
.filter(Boolean)
|
||
.join('; ');
|
||
}
|
||
|
||
function semesterTextById(semesterId) {
|
||
const semester = semesterById(semesterId);
|
||
return semester ? shortSemesterLabel(semester) : 'семестр не выбран';
|
||
}
|
||
|
||
function conflictSlotCurrentText(slot) {
|
||
const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
|
||
return [
|
||
slot.dayName,
|
||
slot.timeSlotLabel,
|
||
parity,
|
||
slot.teacherName,
|
||
slot.classroomName
|
||
].filter(Boolean).join(' · ');
|
||
}
|
||
|
||
function buildRulePayload() {
|
||
const groupIds = selectedRuleGroupIds();
|
||
const slots = readRuleSlots(true).map(ruleSlotToPayload);
|
||
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
|
||
if (!scheduleVersionSelect.value) throw new Error('Выберите версию расписания');
|
||
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
|
||
const typeLimits = readTypeLimits();
|
||
validateTypeLimits(typeLimits, slots);
|
||
if (!groupIds.length) throw new Error('Выберите хотя бы одну группу');
|
||
if (!slots.length) throw new Error('Добавьте хотя бы один слот занятия');
|
||
return {
|
||
id: ruleIdInput.value ? Number(ruleIdInput.value) : null,
|
||
subjectId: Number(ruleSubjectSelect.value),
|
||
semesterId: Number(ruleSemesterSelect.value),
|
||
scheduleVersionId: Number(scheduleVersionSelect.value),
|
||
...typeLimits,
|
||
groupIds,
|
||
slots
|
||
};
|
||
}
|
||
|
||
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' });
|
||
}
|
||
|
||
function resetRuleForm() {
|
||
ruleForm.reset();
|
||
ruleFormTitle.textContent = 'Новое правило расписания';
|
||
ruleIdInput.value = '';
|
||
fillTypeLimits({});
|
||
subgroupRequest += 1;
|
||
subgroups = [];
|
||
renderRuleGroups([]);
|
||
renderRuleSlots([{}]);
|
||
syncRuleSemesterWithVersion();
|
||
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
|
||
hideAlert('schedule-rule-alert');
|
||
dirtyScope.clear();
|
||
}
|
||
|
||
function renderRuleGroups(selectedIds) {
|
||
if (!groups.length) {
|
||
ruleGroupCheckboxes.innerHTML = '<div class="loading-row">Нет групп</div>';
|
||
updateRuleGroupsText();
|
||
return;
|
||
}
|
||
const selected = new Set((selectedIds || []).map(String));
|
||
ruleGroupCheckboxes.innerHTML = groups.map(group => `
|
||
<label class="checkbox-item">
|
||
<input type="checkbox" value="${group.id}" ${selected.has(String(group.id)) ? 'checked' : ''}>
|
||
<span class="checkmark"></span>
|
||
<span>${escapeHtml(group.name)}</span>
|
||
</label>
|
||
`).join('');
|
||
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);
|
||
const actionClass = isSingleSlot ? 'btn-ghost' : 'btn-danger';
|
||
ruleSlotsContainer.innerHTML = normalizedSlots.map((slot, index) => `
|
||
<div class="schedule-slot-row ${isLaboratorySlot(slot) ? 'has-subgroup' : ''}" data-index="${index}" data-slot-id="${escapeHtml(String(slot.id || ''))}">
|
||
<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" 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>
|
||
<button type="button" class="btn btn-md ${actionClass} schedule-slot-remove" data-index="${index}">${actionLabel}</button>
|
||
<div class="schedule-preference-hint" data-preference-hint></div>
|
||
</div>
|
||
`).join('');
|
||
initializeEntityComboboxes(ruleSlotsContainer, '.slot-teacher', '.slot-classroom', slotComboboxes);
|
||
updatePreferenceHints();
|
||
}
|
||
|
||
function updatePreferenceHints() {
|
||
const semesterId = Number(ruleSemesterSelect?.value);
|
||
Array.from(ruleSlotsContainer?.querySelectorAll('.schedule-slot-row') || []).forEach(row => {
|
||
const hint = row.querySelector('[data-preference-hint]');
|
||
if (!hint) return;
|
||
const teacherId = Number(row.querySelector('.slot-teacher')?.value);
|
||
const dayOfWeek = Number(row.querySelector('.slot-day')?.value);
|
||
const timeSlotId = Number(row.querySelector('.slot-time')?.value);
|
||
if (!semesterId || !teacherId) {
|
||
hint.innerHTML = '';
|
||
return;
|
||
}
|
||
const teacherItems = approvedTeacherPreferences.filter(item =>
|
||
Number(item.semesterId) === semesterId && Number(item.teacherId) === teacherId
|
||
);
|
||
const exact = teacherItems.filter(item =>
|
||
Number(item.dayOfWeek) === dayOfWeek && Number(item.timeSlotId) === timeSlotId
|
||
);
|
||
const dateCount = teacherItems.filter(item => item.preferenceType === 'HARD_UNAVAILABLE' && item.preferenceDate).length;
|
||
const flags = teacherItems.filter(item => ['CONSECUTIVE', 'NO_GAPS'].includes(item.preferenceType));
|
||
const pills = [
|
||
...exact.map(item => ({
|
||
text: preferenceHintLabel(item.preferenceType),
|
||
kind: item.preferenceType === 'HARD_UNAVAILABLE' ? 'hard'
|
||
: item.preferenceType === 'SOFT_PREFERRED' ? 'preferred' : 'unwanted'
|
||
})),
|
||
...(dateCount ? [{ text: `Недоступных дат: ${dateCount}`, kind: 'dates' }] : []),
|
||
...flags.map(item => ({ text: preferenceHintLabel(item.preferenceType), kind: 'compact' }))
|
||
];
|
||
hint.innerHTML = pills.length
|
||
? `<span class="schedule-preference-caption">Пожелания преподавателя</span>${pills.map(pill => `<span class="schedule-preference-pill ${pill.kind}">${escapeHtml(pill.text)}</span>`).join('')}`
|
||
: '<span class="schedule-preference-caption muted">Согласованных пожеланий для слота нет</span>';
|
||
});
|
||
}
|
||
|
||
function preferenceHintLabel(type) {
|
||
return {
|
||
HARD_UNAVAILABLE: 'Строгая недоступность',
|
||
SOFT_PREFERRED: 'Предпочтительно',
|
||
SOFT_UNWANTED: 'Нежелательно',
|
||
CONSECUTIVE: 'Пары подряд',
|
||
NO_GAPS: 'Без окон'
|
||
}[type] || type;
|
||
}
|
||
|
||
function readRuleSlots(validate) {
|
||
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
|
||
return rows.map((row, index) => {
|
||
const subgroupIds = readSlotSubgroupIds(row);
|
||
const slotId = Number(row.dataset.slotId);
|
||
const slot = {
|
||
id: slotId || null,
|
||
dayOfWeek: Number(row.querySelector('.slot-day').value),
|
||
parity: row.querySelector('.slot-parity').value,
|
||
timeSlotId: Number(row.querySelector('.slot-time').value),
|
||
subgroupId: subgroupIds[0] || null,
|
||
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
|
||
};
|
||
if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
|
||
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) {
|
||
throw new Error(`Заполните слот ${index + 1}`);
|
||
}
|
||
if (validate) {
|
||
validateSlotSubgroup(slot, index);
|
||
}
|
||
return slot;
|
||
});
|
||
}
|
||
|
||
function readTypeLimits() {
|
||
return LESSON_TYPE_LIMITS.reduce((payload, config) => {
|
||
const inputs = ruleTypeInputs[config.key];
|
||
payload[config.hoursField] = Number(inputs.hours.value || 0);
|
||
payload[config.startWeekField] = Number(inputs.startWeek.value || 1);
|
||
return payload;
|
||
}, {});
|
||
}
|
||
|
||
function fillTypeLimits(rule) {
|
||
LESSON_TYPE_LIMITS.forEach(config => {
|
||
const inputs = ruleTypeInputs[config.key];
|
||
inputs.hours.value = rule[config.hoursField] ?? 0;
|
||
inputs.startWeek.value = rule[config.startWeekField] ?? 1;
|
||
});
|
||
}
|
||
|
||
function validateTypeLimits(typeLimits, slots) {
|
||
const selectedTypeKeys = new Set(slots.map(slot => lessonTypeKeyById(slot.lessonTypeId)).filter(Boolean));
|
||
let hasHours = false;
|
||
for (const config of LESSON_TYPE_LIMITS) {
|
||
const hours = typeLimits[config.hoursField];
|
||
const startWeek = typeLimits[config.startWeekField];
|
||
if (hours < 0) throw new Error('Количество часов по типам занятий не может быть отрицательным');
|
||
if (!startWeek || startWeek <= 0) throw new Error('Неделя начала занятий должна быть больше нуля');
|
||
if (hours > 0) hasHours = true;
|
||
if (hours > 0 && !selectedTypeKeys.has(config.key)) {
|
||
throw new Error(`Для типа "${config.label}" добавьте хотя бы один слот`);
|
||
}
|
||
if (hours === 0 && selectedTypeKeys.has(config.key)) {
|
||
throw new Error(`Для типа "${config.label}" укажите часы или удалите слоты этого типа`);
|
||
}
|
||
}
|
||
if (!hasHours) throw new Error('Укажите часы хотя бы для одного типа занятий');
|
||
}
|
||
|
||
function validateSlotSubgroup(slot, index) {
|
||
const typeKey = lessonTypeKeyById(slot.lessonTypeId);
|
||
const subgroupIds = slotSubgroupIds(slot);
|
||
if (subgroupIds.length && typeKey !== 'laboratory') {
|
||
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
|
||
}
|
||
if (!subgroupIds.length) return;
|
||
|
||
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
|
||
const subgroupGroupIds = new Set();
|
||
for (const subgroupId of subgroupIds) {
|
||
const selectedSubgroup = subgroups.find(item => String(item.id) === String(subgroupId));
|
||
if (!selectedSubgroup) {
|
||
throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
|
||
}
|
||
if (!selectedGroups.has(String(selectedSubgroup.groupId))) {
|
||
throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`);
|
||
}
|
||
if (subgroupGroupIds.has(String(selectedSubgroup.groupId))) {
|
||
throw new Error(`В слоте ${index + 1} выберите не больше одной подгруппы каждой группы`);
|
||
}
|
||
subgroupGroupIds.add(String(selectedSubgroup.groupId));
|
||
}
|
||
}
|
||
|
||
async function loadTimeSlots() {
|
||
const slots = await api.get('/api/admin/time-slots');
|
||
timeSlots = slots
|
||
.filter(slot => slot.scopeApplyMode === 'DEFAULT')
|
||
.sort((a, b) => (a.orderNumber || 0) - (b.orderNumber || 0));
|
||
}
|
||
|
||
function populateSubjectSelects() {
|
||
ruleSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
|
||
subjects.map(subject => `<option value="${subject.id}">${escapeHtml(subject.name)}</option>`).join('');
|
||
syncSelects(ruleSubjectSelect);
|
||
}
|
||
|
||
function populateSemesterSelects() {
|
||
const semesterOptions = '<option value="">Выберите семестр</option>' +
|
||
semesters.map(semester => `<option value="${semester.id}">${escapeHtml(semesterLabel(semester))}</option>`).join('');
|
||
ruleSemesterSelect.innerHTML = semesterOptions;
|
||
syncSelects(ruleSemesterSelect);
|
||
}
|
||
|
||
function options(items, selectedValue) {
|
||
const selected = selectedValue == null ? '' : String(selectedValue);
|
||
return '<option value="">Выберите</option>' + items.map(item => {
|
||
const value = String(item.value);
|
||
return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(item.label)}</option>`;
|
||
}).join('');
|
||
}
|
||
|
||
function subgroupOptions(slot) {
|
||
if (!isLaboratorySlot(slot)) {
|
||
return '<option value="">Только для лабораторных</option>';
|
||
}
|
||
const selectedGroupIds = selectedRuleGroupIds();
|
||
if (selectedGroupIds.length !== 1) {
|
||
return '<option value="">Выберите группу правила</option>';
|
||
}
|
||
const selected = slotSubgroupIds(slot)[0] == null
|
||
? WHOLE_GROUP_SUBGROUP_VALUE
|
||
: String(slotSubgroupIds(slot)[0]);
|
||
const selectedGroups = new Set(selectedGroupIds.map(String));
|
||
const matchingSubgroups = subgroups
|
||
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
|
||
.sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU'));
|
||
return `<option value="${WHOLE_GROUP_SUBGROUP_VALUE}" ${selected === WHOLE_GROUP_SUBGROUP_VALUE ? 'selected' : ''}>Вся группа</option>` + matchingSubgroups.map(subgroup => {
|
||
const value = String(subgroup.id);
|
||
return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(subgroupLabel(subgroup))}</option>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderSubgroupField(slot, index) {
|
||
return selectedRuleGroupIds().length > 1
|
||
? renderSubgroupMultiSelect(slot, index)
|
||
: `<div class="form-group"><label>Подгруппа</label><select class="slot-subgroup">${subgroupOptions(slot)}</select></div>`;
|
||
}
|
||
|
||
function renderSubgroupMultiSelect(slot, index) {
|
||
const selected = new Set(slotSubgroupIds(slot).map(String));
|
||
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
|
||
const matchingSubgroups = subgroups
|
||
.filter(subgroup => selectedGroups.has(String(subgroup.groupId)))
|
||
.sort((a, b) => subgroupLabel(a).localeCompare(subgroupLabel(b), 'ru-RU'));
|
||
const items = matchingSubgroups.length
|
||
? matchingSubgroups.map(subgroup => {
|
||
const value = String(subgroup.id);
|
||
return `
|
||
<label class="checkbox-item">
|
||
<input type="checkbox" value="${escapeHtml(value)}" ${selected.has(value) ? 'checked' : ''}>
|
||
<span class="checkmark"></span>
|
||
<span>${escapeHtml(subgroupLabel(subgroup))}</span>
|
||
</label>
|
||
`;
|
||
}).join('')
|
||
: '<div class="loading-row">Нет подгрупп выбранных групп</div>';
|
||
|
||
return `
|
||
<div class="form-group slot-subgroup-multi">
|
||
<label>Подгруппы</label>
|
||
<div class="custom-multi-select">
|
||
<div class="select-box slot-subgroup-box" role="button" tabindex="0" aria-expanded="false">
|
||
<span class="select-text">${escapeHtml(slotSubgroupText(slot))}</span>
|
||
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||
</svg>
|
||
</div>
|
||
<div class="dropdown-menu slot-subgroup-menu">
|
||
<div class="checkbox-group-vertical slot-subgroup-checkboxes" data-index="${index}">
|
||
${items}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function readSlotSubgroupIds(row) {
|
||
const subgroupValue = row.querySelector('.slot-subgroup')?.value || '';
|
||
const checkboxValues = Array.from(row.querySelectorAll('.slot-subgroup-checkboxes input[type="checkbox"]:checked'))
|
||
.map(input => Number(input.value))
|
||
.filter(Boolean);
|
||
return checkboxValues.length
|
||
? checkboxValues
|
||
: (subgroupValue && subgroupValue !== WHOLE_GROUP_SUBGROUP_VALUE ? [Number(subgroupValue)] : []);
|
||
}
|
||
|
||
function slotSubgroupIds(slot) {
|
||
if (Array.isArray(slot.subgroupIds) && slot.subgroupIds.length) {
|
||
return slot.subgroupIds.map(Number).filter(Boolean);
|
||
}
|
||
return slot.subgroupId ? [Number(slot.subgroupId)] : [];
|
||
}
|
||
|
||
function slotSubgroupNames(slot) {
|
||
if (Array.isArray(slot.subgroupNames) && slot.subgroupNames.length) {
|
||
return slot.subgroupNames.filter(Boolean);
|
||
}
|
||
return slot.subgroupName ? [slot.subgroupName] : [];
|
||
}
|
||
|
||
function slotSubgroupText(slot) {
|
||
const selectedIds = slotSubgroupIds(slot);
|
||
if (!selectedIds.length) return 'Все выбранные группы';
|
||
if (selectedIds.length === 1) {
|
||
const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0]));
|
||
return subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
|
||
}
|
||
return `Выбрано подгрупп: ${selectedIds.length}`;
|
||
}
|
||
|
||
function selectedRuleGroupIds() {
|
||
return Array.from(ruleGroupCheckboxes.querySelectorAll('input[type="checkbox"]:checked'))
|
||
.map(input => Number(input.value));
|
||
}
|
||
|
||
function toggleRuleGroupsMenu(event) {
|
||
event.stopPropagation();
|
||
const isOpen = ruleGroupsMenu.classList.contains('open');
|
||
document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open'));
|
||
document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active'));
|
||
if (isOpen) {
|
||
closeRuleGroupsMenu();
|
||
return;
|
||
}
|
||
ruleGroupsMenu.classList.add('open');
|
||
ruleGroupsBox.classList.add('active');
|
||
ruleGroupsBox.setAttribute('aria-expanded', 'true');
|
||
}
|
||
|
||
function handleRuleGroupsKeydown(event) {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
toggleRuleGroupsMenu(event);
|
||
}
|
||
if (event.key === 'Escape') {
|
||
closeRuleGroupsMenu();
|
||
}
|
||
}
|
||
|
||
function closeRuleGroupsMenu() {
|
||
ruleGroupsMenu.classList.remove('open');
|
||
ruleGroupsBox.classList.remove('active');
|
||
ruleGroupsBox.setAttribute('aria-expanded', 'false');
|
||
}
|
||
|
||
function toggleSlotSubgroupMenu(event, box) {
|
||
event.stopPropagation();
|
||
const menu = box.closest('.custom-multi-select')?.querySelector('.slot-subgroup-menu');
|
||
if (!menu) return;
|
||
const isOpen = menu.classList.contains('open');
|
||
document.querySelectorAll('.dropdown-menu').forEach(item => item.classList.remove('open'));
|
||
document.querySelectorAll('.select-box').forEach(item => item.classList.remove('active'));
|
||
if (isOpen) {
|
||
box.setAttribute('aria-expanded', 'false');
|
||
return;
|
||
}
|
||
menu.classList.add('open');
|
||
box.classList.add('active');
|
||
box.setAttribute('aria-expanded', 'true');
|
||
}
|
||
|
||
function closeSlotSubgroupMenus() {
|
||
ruleSlotsContainer.querySelectorAll('.slot-subgroup-menu').forEach(menu => menu.classList.remove('open'));
|
||
ruleSlotsContainer.querySelectorAll('.slot-subgroup-box').forEach(box => {
|
||
box.classList.remove('active');
|
||
box.setAttribute('aria-expanded', 'false');
|
||
});
|
||
}
|
||
|
||
function updateSlotSubgroupText(row) {
|
||
if (!row) return;
|
||
const text = row.querySelector('.slot-subgroup-box .select-text');
|
||
if (!text) return;
|
||
const selectedIds = readSlotSubgroupIds(row);
|
||
if (!selectedIds.length) {
|
||
text.textContent = 'Все выбранные группы';
|
||
return;
|
||
}
|
||
if (selectedIds.length === 1) {
|
||
const subgroup = subgroups.find(item => String(item.id) === String(selectedIds[0]));
|
||
text.textContent = subgroup ? subgroupLabel(subgroup) : 'Выбрана 1 подгруппа';
|
||
return;
|
||
}
|
||
text.textContent = `Выбрано подгрупп: ${selectedIds.length}`;
|
||
}
|
||
|
||
function updateRuleGroupsText() {
|
||
const selectedIds = selectedRuleGroupIds();
|
||
if (!groups.length) {
|
||
ruleGroupsText.textContent = 'Нет групп';
|
||
return;
|
||
}
|
||
if (!selectedIds.length) {
|
||
ruleGroupsText.textContent = 'Выберите группы...';
|
||
return;
|
||
}
|
||
if (selectedIds.length === 1) {
|
||
const selectedGroup = groups.find(group => String(group.id) === String(selectedIds[0]));
|
||
ruleGroupsText.textContent = selectedGroup?.name || 'Выбрана 1 группа';
|
||
return;
|
||
}
|
||
ruleGroupsText.textContent = `Выбрано групп: ${selectedIds.length}`;
|
||
}
|
||
|
||
function toTimeSlotOption(slot) {
|
||
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
|
||
}
|
||
|
||
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 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) {
|
||
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
|
||
}
|
||
|
||
function orderedLessonTypes() {
|
||
return [...lessonTypes].sort(compareLessonTypes);
|
||
}
|
||
|
||
function compareLessonTypes(first, second) {
|
||
return lessonTypeRank(first) - lessonTypeRank(second)
|
||
|| naturalCompare(toLessonTypeOption(first).label, toLessonTypeOption(second).label);
|
||
}
|
||
|
||
function lessonTypeRank(lessonType) {
|
||
const key = lessonTypeKey(lessonType);
|
||
if (key === 'lecture') return 0;
|
||
if (key === 'laboratory') return 1;
|
||
if (key === 'practice') return 2;
|
||
return 3;
|
||
}
|
||
|
||
function lessonTypeKeyById(lessonTypeId) {
|
||
const lessonType = lessonTypes.find(item => String(item.id) === String(lessonTypeId));
|
||
return lessonType ? lessonTypeKey(lessonType) : null;
|
||
}
|
||
|
||
function isLaboratorySlot(slot) {
|
||
return lessonTypeKeyById(slot.lessonTypeId) === 'laboratory';
|
||
}
|
||
|
||
function subgroupLabel(subgroup) {
|
||
return `${subgroup.groupName || `Группа ${subgroup.groupId}`}: ${subgroup.name}`;
|
||
}
|
||
|
||
function lessonTypeKey(lessonType) {
|
||
const name = String(lessonType.name || lessonType.lessonType || '').toLowerCase();
|
||
if (name.includes('лекц')) return 'lecture';
|
||
if (name.includes('лаб')) return 'laboratory';
|
||
if (name.includes('практ')) return 'practice';
|
||
return null;
|
||
}
|
||
|
||
function semesterLabel(semester) {
|
||
const type = normalizeSemesterType(semester.semesterType);
|
||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
|
||
}
|
||
|
||
function shortSemesterLabel(semester) {
|
||
const type = normalizeSemesterType(semester.semesterType);
|
||
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type}`;
|
||
}
|
||
|
||
function formatSemester(rule) {
|
||
const type = normalizeSemesterType(rule.semesterType);
|
||
return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`;
|
||
}
|
||
|
||
function normalizeSemesterType(type) {
|
||
return String(type || '').toLowerCase();
|
||
}
|
||
|
||
function trimTime(value) {
|
||
if (!value) return '';
|
||
return String(value).slice(0, 5);
|
||
}
|
||
|
||
function syncSelects(...selects) {
|
||
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) {
|
||
return `${groupId}:${dayOfWeek}:${slotKey}`;
|
||
}
|
||
|
||
function visualSlotKey(slot) {
|
||
return String(slot.timeSlotId ?? slot.timeSlotOrder ?? slot.timeSlotLabel ?? 'unknown');
|
||
}
|
||
|
||
function visualRuleSlotKey(slot, index) {
|
||
return String(slot.id ?? `${visualSlotKey(slot)}:${slot.dayOfWeek}:${slot.parity}:${index}`);
|
||
}
|
||
|
||
function activeWeekKey(ruleId, groupId, slotKey) {
|
||
return `${ruleId}:${groupId}:${slotKey}`;
|
||
}
|
||
|
||
function slotAppliesToWeek(slot, week) {
|
||
if (!slot.parity || slot.parity === 'BOTH') return true;
|
||
const weekParity = week % 2 === 0 ? 'EVEN' : 'ODD';
|
||
return slot.parity === weekParity;
|
||
}
|
||
|
||
function maxWeeksForRule(rule) {
|
||
const semester = semesters.find(item => String(item.id) === String(rule.semesterId));
|
||
if (semester?.startDate && semester?.endDate) {
|
||
const start = parseIsoDate(semester.startDate);
|
||
const end = parseIsoDate(semester.endDate);
|
||
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
|
||
return Math.max(1, Math.ceil(days / 7));
|
||
}
|
||
return 18;
|
||
}
|
||
|
||
function parseIsoDate(value) {
|
||
const [year, month, day] = String(value).split('-').map(Number);
|
||
return new Date(year, month - 1, day);
|
||
}
|
||
|
||
function localIsoDate(date) {
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const day = String(date.getDate()).padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
function compactWeekRanges(weeks, maxWeeks, parity) {
|
||
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
|
||
if (!uniqueWeeks.length) return '';
|
||
const start = uniqueWeeks[0];
|
||
const end = uniqueWeeks[uniqueWeeks.length - 1];
|
||
const semesterEndWeek = lastApplicableSemesterWeek(maxWeeks, parity);
|
||
const semesterStartWeek = firstApplicableSemesterWeek(parity);
|
||
if (semesterEndWeek && start <= semesterStartWeek && end >= semesterEndWeek) return '';
|
||
if (semesterEndWeek && end >= semesterEndWeek) return `(с ${start} нед.)`;
|
||
return weekRangeText(start, end);
|
||
}
|
||
|
||
function weekRangeText(start, end) {
|
||
return start === end
|
||
? `(${start} нед.)`
|
||
: `(с ${start} по ${end} нед.)`;
|
||
}
|
||
|
||
function firstApplicableSemesterWeek(parity) {
|
||
return parity === 'EVEN' ? 2 : 1;
|
||
}
|
||
|
||
function lastApplicableSemesterWeek(maxWeeks, parity) {
|
||
const totalWeeks = Number(maxWeeks || 0);
|
||
if (!totalWeeks) return 0;
|
||
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
|
||
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
|
||
return totalWeeks;
|
||
}
|
||
|
||
function compareSlotItems(first, second) {
|
||
return Number(first.slot.dayOfWeek || 0) - Number(second.slot.dayOfWeek || 0)
|
||
|| compareVisualSlots(slotDescriptor(first.slot), slotDescriptor(second.slot))
|
||
|| Number(first.index || 0) - Number(second.index || 0);
|
||
}
|
||
|
||
function slotDescriptor(slot) {
|
||
return {
|
||
key: visualSlotKey(slot),
|
||
orderNumber: slot.timeSlotOrder,
|
||
timeLabel: slot.timeSlotLabel
|
||
};
|
||
}
|
||
|
||
function compareVisualSlots(first, second) {
|
||
const firstOrder = Number(first.orderNumber || first.key);
|
||
const secondOrder = Number(second.orderNumber || second.key);
|
||
if (!Number.isNaN(firstOrder) && !Number.isNaN(secondOrder) && firstOrder !== secondOrder) {
|
||
return firstOrder - secondOrder;
|
||
}
|
||
return naturalCompare(first.timeLabel, second.timeLabel) || naturalCompare(first.key, second.key);
|
||
}
|
||
|
||
function compareVisualLessons(first, second) {
|
||
return naturalCompare(first.subjectName, second.subjectName)
|
||
|| naturalCompare(first.weekText, second.weekText)
|
||
|| naturalCompare(first.lessonTypeName, second.lessonTypeName)
|
||
|| naturalCompare(first.teacherName, second.teacherName);
|
||
}
|
||
|
||
function visualLessonSignature(lessons) {
|
||
return lessons
|
||
.map(lesson => [
|
||
lesson.id,
|
||
lesson.subjectName,
|
||
lesson.weekText,
|
||
lesson.lessonTypeName,
|
||
lesson.teacherName,
|
||
lesson.classroomName,
|
||
lesson.subgroupNames.join(',')
|
||
].join(':'))
|
||
.sort((a, b) => naturalCompare(a, b))
|
||
.join('|');
|
||
}
|
||
|
||
function groupNameById(id) {
|
||
return groups.find(group => String(group.id) === String(id))?.name || `Группа ${id}`;
|
||
}
|
||
|
||
function naturalCompare(a, b) {
|
||
return String(a || '').localeCompare(String(b || ''), 'ru', { numeric: true, sensitivity: 'base' });
|
||
}
|
||
|
||
function ruleCountLabel(count) {
|
||
const tail = count % 100;
|
||
if (tail >= 11 && tail <= 14) return 'правил';
|
||
if (count % 10 === 1) return 'правило';
|
||
if ([2, 3, 4].includes(count % 10)) return 'правила';
|
||
return 'правил';
|
||
}
|
||
|
||
function groupCountLabel(count) {
|
||
const tail = count % 100;
|
||
if (tail >= 11 && tail <= 14) return 'групп';
|
||
if (count % 10 === 1) return 'группа';
|
||
if ([2, 3, 4].includes(count % 10)) return 'группы';
|
||
return 'групп';
|
||
}
|
||
|
||
function rowCountLabel(count) {
|
||
const tail = count % 100;
|
||
if (tail >= 11 && tail <= 14) return 'строк';
|
||
if (count % 10 === 1) return 'строка';
|
||
if ([2, 3, 4].includes(count % 10)) return 'строки';
|
||
return 'строк';
|
||
}
|
||
}
|