Добавил визуальную матрицу расписания групп
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 'строк';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user