подправил создание правил расписания

This commit is contained in:
Zuev
2026-05-08 13:41:25 +03:00
parent e12a5bcccd
commit 02c5ea9118
20 changed files with 592 additions and 126 deletions

View File

@@ -114,14 +114,18 @@
letter-spacing: 0.04em;
}
.schedule-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.35rem;
padding: 0.5rem;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
.schedule-group-select {
max-width: 520px;
}
.schedule-group-select .select-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-group-checkboxes {
max-height: 220px;
}
.schedule-hours-panel {
@@ -164,7 +168,7 @@
.schedule-slot-row {
display: grid;
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
grid-template-columns: repeat(7, minmax(125px, 1fr)) auto;
gap: 0.75rem;
align-items: end;
padding: 0.75rem;
@@ -173,6 +177,20 @@
border-radius: var(--radius-sm);
}
.schedule-slot-row.has-subgroup {
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
}
.slot-subgroup-multi .select-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slot-subgroup-multi .dropdown-menu {
min-width: 260px;
}
.schedule-slot-summary,
.schedule-semester-line {
margin-bottom: 0.35rem;
@@ -479,7 +497,8 @@
grid-template-columns: 1fr;
}
.schedule-slot-row {
.schedule-slot-row,
.schedule-slot-row.has-subgroup {
grid-template-columns: repeat(2, minmax(180px, 1fr));
}
@@ -499,7 +518,8 @@
grid-template-columns: 1fr;
}
.schedule-slot-row {
.schedule-slot-row,
.schedule-slot-row.has-subgroup {
grid-template-columns: 1fr;
}

View File

@@ -22,6 +22,8 @@ const SEMESTER_LABELS = {
spring: 'весенний'
};
const WHOLE_GROUP_SUBGROUP_VALUE = '__whole_group__';
const LESSON_TYPE_LIMITS = [
{
key: 'lecture',
@@ -59,7 +61,10 @@ export async function initSchedule() {
hours: document.getElementById(config.hoursInputId),
startWeek: document.getElementById(config.startWeekInputId)
}]));
const ruleGroupsContainer = document.getElementById('schedule-rule-groups');
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');
@@ -94,16 +99,44 @@ export async function initSchedule() {
ruleSlotsContainer.addEventListener('click', (event) => {
const removeButton = event.target.closest('.schedule-slot-remove');
if (!removeButton) return;
const slots = readRuleSlots(false);
slots.splice(Number(removeButton.dataset.index), 1);
renderRuleSlots(slots.length ? slots : [{}]);
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();
}
});
ruleGroupsContainer.addEventListener('change', () => renderRuleSlots(readRuleSlots(false)));
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);
@@ -167,7 +200,7 @@ export async function initSchedule() {
slot.dayName,
slot.timeSlotLabel,
parity,
slot.subgroupName,
slotSubgroupNames(slot).join(', '),
slot.teacherName,
slot.classroomName,
slot.lessonTypeName,
@@ -232,8 +265,7 @@ export async function initSchedule() {
}
function buildRulePayload() {
const groupIds = Array.from(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked'))
.map(input => Number(input.value));
const groupIds = selectedRuleGroupIds();
const slots = readRuleSlots(true);
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
@@ -277,29 +309,31 @@ export async function initSchedule() {
function renderRuleGroups(selectedIds) {
if (!groups.length) {
ruleGroupsContainer.innerHTML = '<div class="loading-row">Нет групп</div>';
ruleGroupCheckboxes.innerHTML = '<div class="loading-row">Нет групп</div>';
updateRuleGroupsText();
return;
}
const selected = new Set((selectedIds || []).map(String));
ruleGroupsContainer.innerHTML = groups.map(group => `
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" data-index="${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(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
<div class="form-group"><label>Подгруппа</label><select class="slot-subgroup" ${isLaboratorySlot(slot) ? '' : 'disabled'}>${subgroupOptions(slot)}</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-delete schedule-slot-remove" data-index="${index}">Удалить</button>
</div>
@@ -309,13 +343,14 @@ export async function initSchedule() {
function readRuleSlots(validate) {
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
return rows.map((row, index) => {
const subgroupValue = row.querySelector('.slot-subgroup')?.value || '';
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: subgroupValue ? Number(subgroupValue) : null,
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),
@@ -370,18 +405,26 @@ export async function initSchedule() {
function validateSlotSubgroup(slot, index) {
const typeKey = lessonTypeKeyById(slot.lessonTypeId);
if (slot.subgroupId && typeKey !== 'laboratory') {
const subgroupIds = slotSubgroupIds(slot);
if (subgroupIds.length && typeKey !== 'laboratory') {
throw new Error(`В слоте ${index + 1} подгруппа доступна только для лабораторных`);
}
if (!slot.subgroupId) return;
if (!subgroupIds.length) return;
const selectedSubgroup = subgroups.find(item => String(item.id) === String(slot.subgroupId));
if (!selectedSubgroup) {
throw new Error(`В слоте ${index + 1} подгруппа не найдена`);
}
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
if (!selectedGroups.has(String(selectedSubgroup.groupId))) {
throw new Error(`В слоте ${index + 1} подгруппа должна относиться к выбранным группам правила`);
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));
}
}
@@ -417,22 +460,196 @@ export async function initSchedule() {
if (!isLaboratorySlot(slot)) {
return '<option value="">Только для лабораторных</option>';
}
const selected = slot.subgroupId == null ? '' : String(slot.subgroupId);
const selectedGroups = new Set(selectedRuleGroupIds().map(String));
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="">Вся группа</option>' + matchingSubgroups.map(subgroup => {
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(ruleGroupsContainer.querySelectorAll('input[type="checkbox"]:checked'))
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)}` };
}

View File

@@ -61,7 +61,19 @@
<div class="schedule-groups-panel">
<label class="schedule-section-label">Группы</label>
<div id="schedule-rule-groups" class="schedule-checkbox-grid"></div>
<div id="schedule-rule-groups" class="custom-multi-select schedule-group-select">
<div class="select-box" id="schedule-rule-groups-box" role="button" tabindex="0" aria-expanded="false">
<span class="select-text" id="schedule-rule-groups-text">Выберите группы...</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" id="schedule-rule-groups-menu">
<div id="schedule-rule-group-checkboxes" class="checkbox-group-vertical schedule-group-checkboxes"></div>
</div>
</div>
</div>
<div class="schedule-slots-panel">

View File

@@ -416,13 +416,16 @@
function renderLesson(lesson) {
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
? lesson.subgroupNames.join(', ')
: lesson.subgroupName;
return `
<div class="lesson">
<div class="lesson-time">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</div>
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
<div class="lesson-meta">
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
${lesson.subgroupName ? `<span class="chip">${escapeHtml(lesson.subgroupName)}</span>` : ''}
${subgroups ? `<span class="chip">${escapeHtml(subgroups)}</span>` : ''}
<span class="chip">${escapeHtml(lesson.lessonFormat)}</span>
<span class="chip">${escapeHtml(lesson.teacherName)}</span>
<span class="chip">${escapeHtml(lesson.classroomName)}</span>

View File

@@ -431,13 +431,16 @@
function renderLesson(lesson) {
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
? lesson.subgroupNames.join(', ')
: lesson.subgroupName;
return `
<div class="lesson">
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
<div class="lesson-meta">
<span class="chip">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</span>
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
${lesson.subgroupName ? `<span class="chip">${escapeHtml(lesson.subgroupName)}</span>` : ''}
${subgroups ? `<span class="chip">${escapeHtml(subgroups)}</span>` : ''}
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
</div>