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

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

@@ -312,6 +312,389 @@
margin-top: 1rem;
}
.schedule-visual-handle {
position: fixed;
top: 50%;
right: 0;
z-index: 905;
width: 42px;
height: 74px;
border: 1px solid var(--bg-card-border);
border-right: 0;
border-radius: 10px 0 0 10px;
background: color-mix(in srgb, var(--bg-card) 92%, var(--accent));
color: var(--text-primary);
font: inherit;
font-size: 1.8rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.2);
transform: translateY(-50%);
transition: right var(--transition), background var(--transition), border-color var(--transition), color var(--transition);
}
.schedule-visual-handle:hover,
.schedule-visual-handle.open {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 20%, var(--bg-card));
color: var(--accent-hover);
}
.schedule-visual-handle.open {
right: min(92vw, 1180px);
}
.schedule-visual-backdrop {
position: fixed;
inset: 0;
z-index: 880;
background: rgba(0, 0, 0, 0.24);
backdrop-filter: blur(2px);
}
.schedule-visual-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 900;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: min(92vw, 1180px);
height: 100vh;
padding: 1rem;
background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-card));
border-left: 1px solid var(--bg-card-border);
box-shadow: -22px 0 55px rgba(0, 0, 0, 0.34);
transform: translateX(100%);
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.schedule-visual-drawer.open {
transform: translateX(0);
}
.schedule-visual-header {
display: grid;
gap: 1rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid var(--bg-card-border);
}
.schedule-visual-header-main,
.schedule-visual-filter-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.schedule-visual-header h2 {
margin: 0 0 0.25rem;
color: var(--text-primary);
font-size: 1rem;
text-transform: none;
letter-spacing: 0;
}
.schedule-visual-toolbar {
display: grid;
gap: 0.55rem;
}
.schedule-visual-filter-head {
align-items: center;
}
.schedule-visual-filter-head > span {
color: var(--text-secondary);
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.schedule-visual-filter-head div {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.4rem;
}
.schedule-visual-group-filter {
display: flex;
gap: 0.4rem;
overflow-x: auto;
padding-bottom: 0.15rem;
}
.schedule-visual-group-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 30px;
padding: 0.25rem 0.58rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition);
}
.schedule-visual-group-chip:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.schedule-visual-group-chip input {
width: 0.9rem;
height: 0.9rem;
margin: 0;
accent-color: var(--accent);
}
.schedule-visual-group-chip:has(input:checked) {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 14%, var(--bg-input));
color: var(--text-primary);
}
.schedule-visual-stage {
min-height: 0;
padding-top: 0.85rem;
overflow: hidden;
}
.schedule-visual-state {
height: 100%;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.schedule-visual-table-wrap {
width: 100%;
height: 100%;
overflow: auto;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-primary);
}
.schedule-visual-table {
min-width: 760px;
border-collapse: collapse;
table-layout: fixed;
}
.schedule-visual-table th,
.schedule-visual-table td {
border: 1px solid var(--bg-card-border);
}
.schedule-visual-table th {
position: sticky;
top: 0;
z-index: 6;
min-width: 168px;
padding: 0.52rem;
background: var(--bg-input);
color: var(--text-primary);
text-align: center;
font-size: 0.78rem;
font-weight: 800;
text-transform: none;
letter-spacing: 0;
}
.schedule-visual-table .schedule-visual-day-head,
.schedule-visual-table .schedule-visual-time-head {
z-index: 8;
min-width: 96px;
width: 96px;
}
.schedule-visual-table .schedule-visual-day-head {
left: 0;
}
.schedule-visual-table .schedule-visual-time-head {
left: 96px;
}
.schedule-visual-day-cell,
.schedule-visual-time-cell {
position: sticky;
background: var(--bg-input);
color: var(--text-primary);
box-shadow: 1px 0 0 var(--bg-card-border);
}
.schedule-visual-day-cell {
left: 0;
z-index: 4;
width: 96px;
min-width: 96px;
padding: 0.52rem;
vertical-align: top;
font-weight: 800;
font-size: 0.74rem;
line-height: 1.15;
overflow-wrap: anywhere;
word-break: break-word;
hyphens: auto;
}
.schedule-visual-time-cell {
left: 96px;
z-index: 4;
width: 96px;
min-width: 96px;
padding: 0.48rem;
vertical-align: middle;
}
.schedule-visual-time-cell strong,
.schedule-visual-time-cell span {
display: block;
line-height: 1.15;
}
.schedule-visual-time-cell span {
margin-top: 0.2rem;
color: var(--text-secondary);
font-size: 0.66rem;
}
.schedule-visual-lesson-cell {
height: 1px;
min-width: 168px;
padding: 0 !important;
vertical-align: stretch;
background: var(--bg-primary);
}
.schedule-visual-lesson-cell-empty {
background: color-mix(in srgb, var(--bg-input) 42%, var(--bg-primary));
}
.schedule-visual-unified {
min-height: 96px;
height: 100%;
display: flex;
flex-direction: column;
gap: 0.22rem;
padding: 0.34rem;
}
.schedule-visual-unified {
justify-content: flex-start;
background: color-mix(in srgb, var(--accent) 5%, var(--bg-primary));
}
.schedule-visual-half {
position: relative;
min-height: 56px;
display: flex;
flex-direction: column;
gap: 0.22rem;
padding: 1.28rem 0.34rem 0.34rem;
background: color-mix(in srgb, var(--accent) 13%, var(--bg-primary));
}
.schedule-visual-half + .schedule-visual-half {
border-top: 1px solid var(--bg-card-border);
}
.schedule-visual-half-even {
background: color-mix(in srgb, var(--success) 8%, var(--bg-primary));
}
.schedule-visual-half-label {
position: absolute;
top: 0.35rem;
left: 0.45rem;
max-width: calc(100% - 0.9rem);
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: var(--bg-input);
color: var(--text-secondary);
font-size: 0.58rem;
font-weight: 800;
line-height: 1.1;
white-space: nowrap;
}
.schedule-visual-half-odd .schedule-visual-half-label {
color: var(--accent-hover);
}
.schedule-visual-half-even .schedule-visual-half-label {
color: var(--success);
}
.schedule-visual-lesson {
display: grid;
gap: 0.12rem;
padding: 0.34rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-card);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.schedule-visual-lesson strong {
color: var(--text-primary);
font-size: 0.7rem;
line-height: 1.18;
}
.schedule-visual-lesson strong span {
color: var(--accent-hover);
font-weight: 800;
}
.schedule-visual-lesson small {
color: var(--text-secondary);
font-size: 0.6rem;
line-height: 1.14;
}
.schedule-visual-free {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
color: var(--text-secondary);
font-size: 0.78rem;
}
@media (max-width: 640px) {
.schedule-visual-drawer {
width: 100vw;
padding: 0.75rem;
}
.schedule-visual-handle.open {
right: calc(100vw - 42px);
}
.schedule-visual-header {
display: grid;
grid-template-columns: 1fr;
}
.schedule-visual-header-main,
.schedule-visual-filter-head {
display: grid;
grid-template-columns: 1fr;
}
}
.calendar-fill-panel {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr)) auto;
@@ -391,10 +774,9 @@
}
.calendar-semesters-grid {
--calendar-semester-block-height: 194px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
grid-auto-rows: auto;
align-items: stretch;
gap: 0.65rem;
padding: 0.65rem;
@@ -403,8 +785,8 @@
.calendar-semester-block {
display: flex;
flex-direction: column;
height: var(--calendar-semester-block-height);
min-height: 100%;
height: auto;
min-height: 0;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-card);
@@ -433,11 +815,11 @@
}
.calendar-semester-table-wrap {
flex: 1;
flex: 1 1 auto;
display: flex;
min-height: 0;
min-height: 151px;
overflow-x: auto;
overflow-y: hidden;
overflow-y: visible;
background: var(--bg-primary);
scrollbar-width: none;
}
@@ -450,7 +832,7 @@
.calendar-semester-table {
width: max-content;
min-width: 100%;
height: 100%;
min-height: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 0.62rem;
@@ -604,20 +986,30 @@
}
.calendar-day-tooltip-code {
width: 34px;
min-width: 34px;
height: 34px;
display: inline-grid;
place-items: center;
border-radius: 8px;
background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, transparent);
border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 56%, transparent);
color: var(--text-primary);
font-size: 0.86rem;
font-weight: 800;
line-height: 1;
padding-top: 2px;
text-align: center;
width: 34px;
min-width: 34px;
height: 34px;
box-sizing: border-box;
display: inline-grid;
place-items: center;
border-radius: 8px;
background: color-mix(in srgb, var(--activity-color, var(--accent)) 24%, transparent);
border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 56%, transparent);
color: var(--text-primary);
font-size: 0.86rem;
font-weight: 800;
line-height: 1;
padding: 0;
text-align: center;
}
.calendar-day-tooltip-code span {
display: block;
transform: translateY(2px);
}
.calendar-day-tooltip-body {

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 'строк';
}
}

View File

@@ -117,3 +117,38 @@
</table>
</div>
</div>
<button type="button"
class="schedule-visual-handle"
id="schedule-visual-toggle"
aria-label="Показать визуальное расписание"
aria-expanded="false"></button>
<div class="schedule-visual-backdrop" id="schedule-visual-backdrop" hidden></div>
<aside class="schedule-visual-drawer" id="schedule-visual-drawer" aria-hidden="true">
<div class="schedule-visual-header">
<div class="schedule-visual-header-main">
<div>
<h2>Визуальное расписание групп</h2>
<span class="muted" id="schedule-visual-summary">Правила ещё не загружены</span>
</div>
<button type="button" class="btn-edit-classroom" id="schedule-visual-close">Свернуть</button>
</div>
<div class="schedule-visual-toolbar">
<div class="schedule-visual-filter-head">
<span>Группы</span>
<div>
<button type="button" class="btn-edit-classroom" id="schedule-visual-use-form">Из формы</button>
<button type="button" class="btn-edit-classroom" id="schedule-visual-select-all">Все</button>
</div>
</div>
<div class="schedule-visual-group-filter" id="schedule-visual-group-filter">
<span class="muted">Группы появятся после загрузки правил</span>
</div>
</div>
</div>
<div class="schedule-visual-stage" id="schedule-visual-stage">
<div class="loading-row schedule-visual-state">Загрузка...</div>
</div>
</aside>