Добавил визуальную матрицу расписания групп

This commit is contained in:
dipatrik10
2026-05-27 21:18:57 +03:00
parent 467f78c3d0
commit c19a9b34c7
4 changed files with 949 additions and 24 deletions

View File

@@ -17,12 +17,18 @@ const PARITY_LABELS = {
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 = [
{
@@ -70,6 +76,15 @@ export async function initSchedule() {
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 visualGroupFilter = document.getElementById('schedule-visual-group-filter');
const visualUseFormButton = document.getElementById('schedule-visual-use-form');
const visualSelectAllButton = document.getElementById('schedule-visual-select-all');
let rules = [];
let subjects = [];
@@ -80,6 +95,8 @@ export async function initSchedule() {
let subgroups = [];
let timeSlots = [];
let semesters = [];
let selectedVisualGroupIds = new Set();
let visualGroupFilterTouched = false;
bindEvents();
@@ -96,6 +113,23 @@ export async function initSchedule() {
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);
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
visualUseFormButton?.addEventListener('click', () => {
visualGroupFilterTouched = false;
selectedVisualGroupIds = new Set();
renderVisualSchedule();
});
visualSelectAllButton?.addEventListener('click', () => {
selectedVisualGroupIds = new Set(collectVisualGroups().map(group => String(group.id)));
visualGroupFilterTouched = true;
renderVisualSchedule();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeVisualDrawer();
});
ruleSlotsContainer.addEventListener('click', (event) => {
const removeButton = event.target.closest('.schedule-slot-remove');
@@ -166,14 +200,17 @@ export async function initSchedule() {
try {
rules = await api.get('/api/admin/schedule-rules');
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 => `
@@ -192,6 +229,326 @@ export async function initSchedule() {
`).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 (visualToggleButton) visualToggleButton.textContent = '';
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 (visualToggleButton) visualToggleButton.textContent = '';
if (visualBackdrop) visualBackdrop.hidden = true;
}
function renderVisualSchedule() {
if (!visualStage || !visualSummary) return;
if (!rules.length) {
visualSummary.textContent = 'Нет сохранённых правил';
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
return;
}
const allGroups = collectVisualGroups();
syncVisualGroupFilter(allGroups);
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules();
if (!allGroups.length || !slots.length) {
visualSummary.textContent = 'Недостаточно данных для построения';
setVisualState('У правил должны быть группы и слоты занятий');
return;
}
if (!columns.length) {
visualSummary.textContent = 'Выберите группы для просмотра';
setVisualState('Отметьте одну или несколько групп в фильтре');
return;
}
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds);
const rows = buildVisualRows(columns, slots, lessonsByCell);
if (!rows.length) {
visualSummary.textContent = `${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
setVisualState('Для выбранных групп нет активных пар в сохранённых правилах');
return;
}
visualSummary.textContent = `${rules.length} ${ruleCountLabel(rules.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) {
const grouped = new Map();
const activeWeeks = buildActiveWeeksByRuleSlot();
rules.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}`,
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),
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() {
const activeWeeks = new Map();
rules.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 = oddLessons.length > 0
&& evenLessons.length > 0
&& visualLessonSignature(oddLessons) !== visualLessonSignature(evenLessons);
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>` : ''}
</article>
`;
}
function collectVisualGroups() {
const byId = new Map();
rules.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() {
const byKey = new Map();
rules.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 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 renderRuleSlotsSummary(slots) {
if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
return slots.map(slot => {
@@ -712,4 +1069,145 @@ export async function initSchedule() {
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) {
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
if (!uniqueWeeks.length) return '';
const ranges = [];
let start = uniqueWeeks[0];
let previous = uniqueWeeks[0];
for (let index = 1; index < uniqueWeeks.length; index += 1) {
const week = uniqueWeeks[index];
if (week === previous + 1) {
previous = week;
continue;
}
ranges.push(weekRangeText(start, previous));
start = week;
previous = week;
}
ranges.push(weekRangeText(start, previous));
return `(${ranges.join(', ')})`;
}
function weekRangeText(start, end) {
return start === end
? `${start} нед.`
: `с ${start} по ${end} нед.`;
}
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 'строк';
}
}