@@ -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. s ubgroupName ,
slotS ubgroupNames ( 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 ( '.s lot-s ubgroup' ) ? . value || '' ;
const subgroupIds = readS lotS ubgroupIds ( 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 . s ubgroupId ) 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 Err or ( ` В слоте ${ index + 1 } подгруппа должна относиться к выбранным группам правила ` ) ;
const subgroupGroupIds = new Set ( ) ;
f or ( 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 . subg roupId == null ? '' : String ( slot . subg roupId) ;
const selectedGroups = new Set ( selectedRule GroupIds ( ) . map ( String ) ) ;
const selectedG roupIds = selectedRuleG roupIds ( ) ;
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 ) } ` } ;
}