Files
magistr/frontend/admin/js/views/schedule.js

1930 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.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 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 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 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 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 teachers = [];
let classrooms = [];
let lessonTypes = [];
let subgroups = [];
let timeSlots = [];
let academicYears = [];
let semesters = [];
let selectedVisualYearId = '';
let selectedVisualSemesterId = '';
let selectedVisualGroupIds = new Set();
let visualGroupFilterTouched = false;
let visualPeriodTouched = false;
let syncingVisualPeriod = false;
let pendingConflictResolution = null;
let visualContextTarget = null;
let pendingSlotMove = null;
bindEvents();
try {
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]);
renderRuleGroups([]);
renderRuleSlots([{}]);
await loadRules();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
}
function bindEvents() {
refreshRulesButton?.addEventListener('click', loadRules);
ruleResetButton?.addEventListener('click', resetRuleForm);
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
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);
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', () => {
if (visualPeriodTouched) return;
selectDefaultVisualPeriod();
renderVisualSchedule();
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') 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 : [{}]);
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());
ruleGroupCheckboxes.addEventListener('change', () => {
updateRuleGroupsText();
renderRuleSlots(readRuleSlots(false));
});
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'));
}
});
ruleForm.addEventListener('submit', saveRule);
rulesTbody.addEventListener('click', handleRuleTableClick);
}
async function loadBaseLists() {
[subjects, groups, teachers, classrooms, lessonTypes, subgroups] = await Promise.all([
api.get('/api/subjects'),
api.get('/api/groups'),
api.get('/api/users/teachers'),
api.get('/api/classrooms'),
api.get('/api/lesson-types'),
api.get('/api/subgroups')
]);
populateSubjectSelects();
}
async function loadYears() {
academicYears = await api.get('/api/admin/calendar/years');
semesters = academicYears.flatMap(year => year.semesters || []);
populateSemesterSelects();
selectDefaultVisualPeriod();
}
async function loadRules() {
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
try {
rules = await api.get('/api/admin/schedule-rules');
if (!visualPeriodTouched) {
selectDefaultVisualPeriod();
}
renderRules();
renderVisualSchedule();
} catch (error) {
rulesTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
setVisualState('Ошибка загрузки правил расписания');
}
}
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;
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}`)}">&#8942;</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;
const rect = button.getBoundingClientRect();
visualContextMenu.hidden = false;
const menuRect = visualContextMenu.getBoundingClientRect();
const top = Math.min(rect.bottom + 6, window.innerHeight - menuRect.height - 12);
const left = Math.min(rect.left, window.innerWidth - menuRect.width - 12);
visualContextMenu.style.top = `${Math.max(12, top)}px`;
visualContextMenu.style.left = `${Math.max(12, left)}px`;
visualContextMenu.querySelector('button')?.focus();
}
function closeVisualContextMenu() {
visualContextTarget = null;
if (!visualContextMenu) return;
visualContextMenu.hidden = true;
visualContextMenu.style.top = '';
visualContextMenu.style.left = '';
}
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 (!confirm(`Удалить правило "${rule.subjectName || 'расписания'}"?`)) 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 (!confirm('Удалить правило расписания?')) 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) {
resetRuleForm();
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 = []) {
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" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
<div class="form-group ${conflictFieldClass('classroom', conflictFields)}"><label>Аудитория</label><select class="conflict-slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
</section>
`;
}).join('');
}
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),
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);
if (!ruleSubjectSelect.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),
...typeLimits,
groupIds,
slots
};
}
function fillRuleForm(rule) {
ruleFormTitle.textContent = 'Редактирование правила расписания';
ruleIdInput.value = rule.id;
ruleSubjectSelect.value = rule.subjectId || '';
ruleSemesterSelect.value = rule.semesterId || '';
fillTypeLimits(rule);
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
renderRuleGroups(rule.groupIds || []);
renderRuleSlots(rule.slots && rule.slots.length ? rule.slots : [{}]);
hideAlert('schedule-rule-alert');
ruleForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function resetRuleForm() {
ruleForm.reset();
ruleFormTitle.textContent = 'Новое правило расписания';
ruleIdInput.value = '';
fillTypeLimits({});
renderRuleGroups([]);
renderRuleSlots([{}]);
syncSelects(ruleSubjectSelect, ruleSemesterSelect);
hideAlert('schedule-rule-alert');
}
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();
}
function renderRuleSlots(slots) {
ruleSlotsContainer.innerHTML = (slots || [{}]).map((slot, index) => `
<div class="schedule-slot-row ${isLaboratorySlot(slot) ? 'has-subgroup' : ''}" data-index="${index}">
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
<div class="form-group"><label>Тип</label><select class="slot-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-sm btn-danger schedule-slot-remove" data-index="${index}">Удалить</button>
</div>
`).join('');
}
function readRuleSlots(validate) {
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
return rows.map((row, index) => {
const subgroupIds = readSlotSubgroupIds(row);
const slot = {
id: 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),
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 toTeacherOption(teacher) {
return { value: teacher.id, label: teacher.fullName || teacher.username || `ID ${teacher.id}` };
}
function toClassroomOption(classroom) {
const capacity = classroom.capacity ? `, ${classroom.capacity} чел.` : '';
return { value: classroom.id, label: `${classroom.name}${capacity}` };
}
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) {
selects.filter(Boolean).forEach(select => {
select.dispatchEvent(new Event('change', { bubbles: true }));
});
}
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 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 'строк';
}
}