доработал отображение учебного графика

This commit is contained in:
Zuev
2026-05-01 14:02:01 +03:00
parent 89c822a073
commit bf02efb8b8
28 changed files with 1644 additions and 1145 deletions

View File

@@ -199,40 +199,138 @@
font-size: 1rem;
}
.calendar-course-header span,
.calendar-week-label {
.calendar-course-header span {
color: var(--text-secondary);
font-size: 0.78rem;
}
.calendar-days-grid {
.calendar-semesters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
gap: 0.5rem;
gap: 0.75rem;
padding: 0.75rem;
}
.calendar-day {
display: grid;
gap: 0.4rem;
min-height: 92px;
padding: 0.55rem;
background: var(--bg-card);
.calendar-semester-block {
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-card);
overflow: hidden;
}
.calendar-day-meta {
.calendar-semester-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.35rem;
gap: 1rem;
min-height: 36px;
padding: 0.45rem 0.65rem;
border-bottom: 1px solid var(--bg-card-border);
}
.calendar-semester-header h4 {
margin: 0;
font-size: 0.86rem;
}
.calendar-semester-header span {
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 600;
font-size: 0.72rem;
white-space: nowrap;
}
.calendar-semester-table-wrap {
overflow-x: auto;
background: var(--bg-primary);
}
.calendar-semester-table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 0.68rem;
}
.calendar-semester-table th,
.calendar-semester-table td {
border: 1px solid var(--bg-card-border);
}
.calendar-semester-table thead th {
position: sticky;
top: 0;
z-index: 1;
height: 22px;
min-width: 26px;
padding: 0 0.2rem;
background: var(--bg-input);
color: var(--text-secondary);
font-weight: 700;
text-align: center;
}
.calendar-semester-table .calendar-day-head {
position: sticky;
left: 0;
z-index: 2;
width: 36px;
min-width: 36px;
background: var(--bg-input);
color: var(--text-secondary);
font-weight: 700;
text-align: center;
}
.calendar-day {
position: relative;
width: 26px;
min-width: 26px;
height: 24px;
min-height: 24px;
padding: 0;
background: color-mix(in srgb, var(--activity-color, var(--accent)) 9%, var(--bg-card));
cursor: pointer;
outline: none;
box-shadow: inset 0 2px 0 var(--activity-color, var(--accent));
text-align: center;
transition: background var(--transition), box-shadow var(--transition), transform var(--transition);
}
.calendar-day:hover,
.calendar-day:focus-visible {
background: color-mix(in srgb, var(--activity-color, var(--accent)) 18%, var(--bg-card));
box-shadow: inset 0 2px 0 var(--activity-color, var(--accent)), 0 0 0 2px color-mix(in srgb, var(--activity-color, var(--accent)) 36%, transparent);
transform: scale(1.08);
z-index: 3;
}
.calendar-day-code {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--text-primary);
font-size: 0.68rem;
line-height: 1;
font-weight: 800;
}
.calendar-day-activity {
min-width: 0;
display: none !important;
}
.calendar-empty-day {
width: 26px;
min-width: 26px;
height: 24px;
background: color-mix(in srgb, var(--bg-input) 65%, transparent);
}
.calendar-empty-semester {
padding: 0.75rem;
color: var(--text-secondary);
font-size: 0.82rem;
}
.calendar-totals {
@@ -254,6 +352,71 @@
font-weight: 600;
}
.calendar-day-dialog-card {
max-width: 560px;
}
.calendar-day-dialog-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin: 0.75rem 0;
}
.calendar-day-dialog-grid div {
padding: 0.75rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.calendar-day-dialog-grid span {
display: block;
color: var(--text-secondary);
font-size: 0.78rem;
margin-bottom: 0.25rem;
}
.calendar-day-dialog-grid strong {
font-size: 0.95rem;
}
.calendar-day-dialog-activity {
display: flex;
gap: 0.8rem;
align-items: center;
margin: 0.8rem 0 1rem;
padding: 0.75rem;
border-radius: var(--radius-sm);
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
}
.calendar-day-dialog-code {
width: 46px;
min-width: 46px;
height: 46px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: color-mix(in srgb, var(--activity-color, var(--accent)) 20%, transparent);
border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 55%, transparent);
font-weight: 800;
}
.calendar-day-dialog-activity p {
margin: 0.2rem 0 0;
color: var(--text-secondary);
font-size: 0.85rem;
}
.profiles-context {
color: var(--text-secondary);
font-weight: 600;
margin: -0.25rem 0 0.75rem;
}
.muted {
color: var(--text-secondary);
}
@@ -266,6 +429,12 @@
.calendar-fill-panel {
grid-template-columns: repeat(2, minmax(180px, 1fr));
}
.calendar-semester-header {
align-items: flex-start;
flex-direction: column;
gap: 0.2rem;
}
}
@media (max-width: 640px) {
@@ -276,6 +445,10 @@
.calendar-fill-panel {
grid-template-columns: 1fr;
}
.calendar-day-dialog-grid {
grid-template-columns: 1fr;
}
}
/* Hide Number Arrows */

View File

@@ -60,6 +60,14 @@
</svg>
<span>Создание кафедры/специальности</span>
</a>
<a href="#" class="nav-item" data-tab="profiles">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
<path d="M16 11.5 18 14l3-5"></path>
</svg>
<span>Профили обучения</span>
</a>
<a href="#" class="nav-item" data-tab="groups">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
@@ -110,6 +118,16 @@
</svg>
<span>Расписание занятий</span>
</a>
<a href="#" class="nav-item" data-tab="academic-calendar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3h18v18H3z"></path>
<path d="M3 9h18"></path>
<path d="M9 3v18"></path>
<path d="M15 3v18"></path>
<path d="M3 15h18"></path>
</svg>
<span>Календарный график</span>
</a>
<a href="#" class="nav-item" data-tab="auditorium-workload">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>

View File

@@ -28,6 +28,8 @@ import {initSchedule} from "./views/schedule.js";
import {initDatabase} from "./views/database.js";
import {initDepartmentsData} from "./views/departments-data.js";
import {initAuditoriumWorkload} from "./views/auditorium-workload.js";
import {initProfiles} from "./views/profiles.js";
import {initAcademicCalendar} from "./views/academic-calendar.js";
// Configuration
const ROUTES = {
@@ -38,9 +40,11 @@ const ROUTES = {
classrooms: { title: 'Аудитории', file: 'views/classrooms.html', init: initClassrooms },
subjects: { title: 'Дисциплины и преподаватели', file: 'views/subjects.html', init: initSubjects },
schedule: { title: 'Расписание занятий', file: 'views/schedule.html', init: initSchedule },
'academic-calendar': { title: 'Календарный учебный график', file: 'views/academic-calendar.html', init: initAcademicCalendar },
'auditorium-workload': { title: 'Загруженность аудиторий', file: 'views/auditorium-workload.html', init: initAuditoriumWorkload },
database: { title: 'База данных', file: 'views/database.html', init: initDatabase },
'departments-data': { title: 'Создание кафедры/специальности', file: 'views/departments-data.html', init: initDepartmentsData },
profiles: { title: 'Профили обучения', file: 'views/profiles.html', init: initProfiles },
};
let currentTab = null;

View File

@@ -0,0 +1,869 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
const DAY_OPTIONS = [
{ value: 1, label: 'Понедельник' },
{ value: 2, label: 'Вторник' },
{ value: 3, label: 'Среда' },
{ value: 4, label: 'Четверг' },
{ value: 5, label: 'Пятница' },
{ value: 6, label: 'Суббота' },
{ value: 7, label: 'Воскресенье' }
];
const SEMESTER_LABELS = {
autumn: 'осенний',
spring: 'весенний'
};
export async function initAcademicCalendar() {
const academicYearForm = document.getElementById('academic-year-form');
const academicYearFormTitle = document.getElementById('academic-year-form-title');
const academicYearIdInput = document.getElementById('academic-year-id');
const academicYearTitleInput = document.getElementById('academic-year-title');
const academicYearStartInput = document.getElementById('academic-year-start');
const academicYearEndInput = document.getElementById('academic-year-end');
const academicYearResetButton = document.getElementById('academic-year-reset');
const academicYearsTbody = document.getElementById('academic-years-tbody');
const academicYearsRefreshButton = document.getElementById('academic-years-refresh');
const semesterForm = document.getElementById('semester-form');
const semesterFormTitle = document.getElementById('semester-form-title');
const semesterIdInput = document.getElementById('semester-id');
const semesterYearSelect = document.getElementById('semester-year');
const semesterTypeSelect = document.getElementById('semester-type');
const semesterStartInput = document.getElementById('semester-start');
const semesterEndInput = document.getElementById('semester-end');
const semesterResetButton = document.getElementById('semester-reset');
const calendarForm = document.getElementById('academic-calendar-form');
const calendarFormTitle = document.getElementById('academic-calendar-form-title');
const calendarIdInput = document.getElementById('academic-calendar-id');
const calendarTitleInput = document.getElementById('academic-calendar-title');
const calendarYearSelect = document.getElementById('academic-calendar-year');
const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty');
const calendarProfileSelect = document.getElementById('academic-calendar-profile');
const calendarStudyFormSelect = document.getElementById('academic-calendar-study-form');
const calendarCourseCountInput = document.getElementById('academic-calendar-course-count');
const calendarResetButton = document.getElementById('academic-calendar-reset');
const calendarsRefreshButton = document.getElementById('academic-calendars-refresh');
const calendarsTbody = document.getElementById('academic-calendars-tbody');
const editorCalendarSelect = document.getElementById('calendar-editor-calendar');
const editorLoadButton = document.getElementById('calendar-editor-load');
const editorSaveButton = document.getElementById('calendar-editor-save');
const fillCourseSelect = document.getElementById('calendar-fill-course');
const fillStartInput = document.getElementById('calendar-fill-start');
const fillEndInput = document.getElementById('calendar-fill-end');
const fillActivitySelect = document.getElementById('calendar-fill-activity');
const fillApplyButton = document.getElementById('calendar-fill-apply');
const calendarGrid = document.getElementById('calendar-grid');
const calendarTotals = document.getElementById('calendar-totals');
const calendarDayDialog = document.getElementById('calendar-day-dialog');
const calendarDayDialogClose = document.getElementById('calendar-day-dialog-close');
const calendarDayDialogDate = document.getElementById('calendar-day-dialog-date');
const calendarDayDialogCourse = document.getElementById('calendar-day-dialog-course');
const calendarDayDialogWeek = document.getElementById('calendar-day-dialog-week');
const calendarDayDialogDay = document.getElementById('calendar-day-dialog-day');
const calendarDayDialogActivitySummary = document.getElementById('calendar-day-dialog-activity-summary');
const calendarDayDialogActivitySelect = document.getElementById('calendar-day-dialog-activity');
let academicYears = [];
let semesters = [];
let specialties = [];
let profilesBySpecialty = new Map();
let studyForms = [];
let activityTypes = [];
let calendars = [];
let activeCalendarDay = null;
bindEvents();
try {
await Promise.all([loadBaseLists(), loadYears(), loadStudyForms(), loadActivityTypes()]);
await loadCalendars();
} catch (error) {
showAlert('academic-calendar-alert', error.message || 'Ошибка загрузки календарных графиков', 'error');
}
function bindEvents() {
academicYearsRefreshButton?.addEventListener('click', loadYears);
calendarsRefreshButton?.addEventListener('click', loadCalendars);
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
semesterResetButton?.addEventListener('click', resetSemesterForm);
calendarResetButton?.addEventListener('click', resetCalendarForm);
calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value));
editorLoadButton?.addEventListener('click', loadCalendarGrid);
editorSaveButton?.addEventListener('click', saveCalendarGrid);
fillApplyButton?.addEventListener('click', applyCalendarFill);
calendarGrid?.addEventListener('change', (event) => {
if (event.target.matches('.calendar-day-activity')) {
updateCalendarDayVisual(event.target.closest('.calendar-day'));
renderCalendarTotals();
}
});
calendarGrid?.addEventListener('click', (event) => {
const day = event.target.closest('.calendar-day');
if (day) openCalendarDayDialog(day);
});
calendarGrid?.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
const day = event.target.closest('.calendar-day');
if (!day) return;
event.preventDefault();
openCalendarDayDialog(day);
});
calendarDayDialogClose?.addEventListener('click', closeCalendarDayDialog);
calendarDayDialog?.addEventListener('click', (event) => {
if (event.target === calendarDayDialog) closeCalendarDayDialog();
});
calendarDayDialogActivitySelect?.addEventListener('change', () => {
if (!activeCalendarDay) return;
const select = activeCalendarDay.querySelector('.calendar-day-activity');
select.value = calendarDayDialogActivitySelect.value;
updateCalendarDayVisual(activeCalendarDay);
updateCalendarDayDialogSummary(activeCalendarDay);
renderCalendarTotals();
});
academicYearForm?.addEventListener('submit', saveAcademicYear);
semesterForm?.addEventListener('submit', saveSemester);
calendarForm?.addEventListener('submit', saveCalendar);
academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick);
calendarsTbody?.addEventListener('click', handleCalendarTableClick);
}
async function loadBaseLists() {
specialties = await api.get('/api/specialties');
await loadProfiles();
populateSpecialtySelects();
}
async function loadProfiles() {
const pairs = await Promise.all(specialties.map(async (speciality) => {
const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`);
return [String(speciality.id), profiles];
}));
profilesBySpecialty = new Map(pairs);
}
async function loadYears() {
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
academicYears = await api.get('/api/admin/calendar/years');
semesters = academicYears.flatMap(year => year.semesters || []);
renderAcademicYears();
populateYearSelects();
} catch (error) {
academicYearsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
throw error;
}
}
function renderAcademicYears() {
if (!academicYears.length) {
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Учебные годы не созданы</td></tr>';
return;
}
academicYearsTbody.innerHTML = academicYears.map(year => `
<tr>
<td>${escapeHtml(year.title)}</td>
<td>${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)}</td>
<td>${renderSemesterList(year.semesters || [])}</td>
<td>
<button class="btn-edit-classroom btn-edit-year" data-id="${year.id}">Изменить</button>
<button class="btn-delete btn-delete-year" data-id="${year.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function renderSemesterList(items) {
if (!items.length) return '<span class="muted">Нет семестров</span>';
return items.map(semester => `
<div class="schedule-semester-line">
${escapeHtml(semesterLabel(semester))}
<button class="btn-edit-classroom btn-edit-semester" data-id="${semester.id}">Изменить</button>
</div>
`).join('');
}
async function saveAcademicYear(event) {
event.preventDefault();
hideAlert('academic-year-alert');
const payload = {
title: academicYearTitleInput.value.trim(),
startDate: academicYearStartInput.value,
endDate: academicYearEndInput.value
};
try {
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
const id = academicYearIdInput.value;
await (id
? api.put('/api/admin/calendar/years/' + id, payload)
: api.post('/api/admin/calendar/years', payload));
showAlert('academic-year-alert', 'Учебный год сохранён', 'success');
resetAcademicYearForm();
await loadYears();
await loadCalendars();
} catch (error) {
showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error');
}
}
async function handleAcademicYearTableClick(event) {
const editYearButton = event.target.closest('.btn-edit-year');
const deleteYearButton = event.target.closest('.btn-delete-year');
const editSemesterButton = event.target.closest('.btn-edit-semester');
if (editYearButton) {
const year = academicYears.find(item => item.id == editYearButton.dataset.id);
if (!year) return;
academicYearFormTitle.textContent = 'Редактирование учебного года';
academicYearIdInput.value = year.id;
academicYearTitleInput.value = year.title || '';
academicYearStartInput.value = year.startDate || '';
academicYearEndInput.value = year.endDate || '';
hideAlert('academic-year-alert');
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (editSemesterButton) {
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
if (!semester) return;
semesterFormTitle.textContent = 'Редактирование семестра';
semesterIdInput.value = semester.id;
semesterYearSelect.value = semester.academicYearId || '';
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
semesterStartInput.value = semester.startDate || '';
semesterEndInput.value = semester.endDate || '';
syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert');
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteYearButton) {
if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return;
try {
await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id);
showAlert('academic-year-alert', 'Учебный год удалён', 'success');
await loadYears();
await loadCalendars();
} catch (error) {
showAlert('academic-year-alert', error.message || 'Ошибка удаления учебного года', 'error');
}
}
}
function resetAcademicYearForm() {
academicYearForm.reset();
academicYearFormTitle.textContent = 'Учебный год';
academicYearIdInput.value = '';
hideAlert('academic-year-alert');
}
async function saveSemester(event) {
event.preventDefault();
hideAlert('semester-alert');
const yearId = semesterYearSelect.value;
const payload = {
semesterType: semesterTypeSelect.value,
startDate: semesterStartInput.value,
endDate: semesterEndInput.value
};
try {
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) {
throw new Error('Заполните учебный год, тип и даты семестра');
}
const id = semesterIdInput.value;
await (id
? api.put('/api/admin/calendar/semesters/' + id, payload)
: api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload));
showAlert('semester-alert', 'Семестр сохранён', 'success');
resetSemesterForm();
await loadYears();
} catch (error) {
showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error');
}
}
function resetSemesterForm() {
semesterForm.reset();
semesterFormTitle.textContent = 'Семестр';
semesterIdInput.value = '';
syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert');
}
async function loadStudyForms() {
studyForms = await api.get('/api/education-forms');
populateStudyFormSelect();
}
async function loadActivityTypes() {
activityTypes = await api.get('/api/admin/calendar/activity-types');
populateActivitySelects();
}
async function loadCalendars() {
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
try {
calendars = await api.get('/api/admin/academic-calendars');
renderCalendars();
populateEditorCalendarSelect();
} catch (error) {
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
throw error;
}
}
function renderCalendars() {
if (!calendars.length) {
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Календарные графики не созданы</td></tr>';
return;
}
calendarsTbody.innerHTML = calendars.map(calendar => `
<tr>
<td>${escapeHtml(calendar.title)}</td>
<td>${escapeHtml(calendar.academicYearTitle || '-')}</td>
<td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td>
<td>${escapeHtml(calendar.specialtyProfileName || '-')}</td>
<td>${escapeHtml(calendar.studyFormName || '-')}</td>
<td>${escapeHtml(String(calendar.courseCount || '-'))}</td>
<td>
<button class="btn-edit-classroom btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
<button class="btn-edit-classroom btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
<button class="btn-delete btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
</td>
</tr>
`).join('');
}
async function saveCalendar(event) {
event.preventDefault();
hideAlert('academic-calendar-alert');
const payload = {
id: calendarIdInput.value ? Number(calendarIdInput.value) : null,
title: calendarTitleInput.value.trim(),
academicYearId: Number(calendarYearSelect.value),
specialtyId: Number(calendarSpecialtySelect.value),
specialtyProfileId: Number(calendarProfileSelect.value),
studyFormId: Number(calendarStudyFormSelect.value),
courseCount: Number(calendarCourseCountInput.value)
};
try {
if (!payload.title || !payload.academicYearId || !payload.specialtyId
|| !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) {
throw new Error('Заполните все поля календарного графика');
}
const id = calendarIdInput.value;
const saved = id
? await api.put('/api/admin/academic-calendars/' + id, payload)
: await api.post('/api/admin/academic-calendars', payload);
showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success');
resetCalendarForm();
await loadCalendars();
} catch (error) {
showAlert('academic-calendar-alert', error.message || 'Ошибка сохранения календарного графика', 'error');
}
}
async function handleCalendarTableClick(event) {
const editButton = event.target.closest('.btn-edit-calendar');
const gridButton = event.target.closest('.btn-open-calendar-grid');
const deleteButton = event.target.closest('.btn-delete-calendar');
if (editButton) {
const calendar = calendars.find(item => item.id == editButton.dataset.id);
if (!calendar) return;
calendarFormTitle.textContent = 'Редактирование календарного графика';
calendarIdInput.value = calendar.id;
calendarTitleInput.value = calendar.title || '';
calendarYearSelect.value = calendar.academicYearId || '';
calendarSpecialtySelect.value = calendar.specialtyId || '';
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId);
calendarStudyFormSelect.value = calendar.studyFormId || '';
calendarCourseCountInput.value = calendar.courseCount || 4;
syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect);
hideAlert('academic-calendar-alert');
calendarForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (gridButton) {
editorCalendarSelect.value = gridButton.dataset.id;
syncSelects(editorCalendarSelect);
await loadCalendarGrid();
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteButton) {
if (!confirm('Удалить календарный график?')) return;
try {
await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id);
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
await loadCalendars();
} catch (error) {
showAlert('academic-calendar-alert', error.message || 'Ошибка удаления календарного графика', 'error');
}
}
}
function resetCalendarForm() {
calendarForm.reset();
calendarFormTitle.textContent = 'Календарный учебный график';
calendarIdInput.value = '';
calendarCourseCountInput.value = 4;
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value);
syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect);
hideAlert('academic-calendar-alert');
}
async function loadCalendarGrid() {
hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar();
if (!calendar) {
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
return;
}
try {
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`);
const gridRows = rows.length ? rows : buildDefaultGrid(calendar);
renderCalendarGrid(gridRows);
showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
} catch (error) {
showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error');
}
}
async function saveCalendarGrid() {
hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar();
if (!calendar) {
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
return;
}
const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({
calendarId: calendar.id,
courseNumber: Number(day.dataset.course),
date: day.dataset.date,
weekNumber: Number(day.dataset.week),
dayOfWeek: Number(day.dataset.day),
activityTypeId: Number(day.querySelector('.calendar-day-activity').value)
}));
if (!rows.length) {
showAlert('calendar-editor-alert', 'Сначала загрузите сетку графика', 'error');
return;
}
try {
await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows);
showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success');
await loadCalendarGrid();
} catch (error) {
showAlert('calendar-editor-alert', error.message || 'Ошибка сохранения сетки', 'error');
}
}
function applyCalendarFill() {
hideAlert('calendar-editor-alert');
const activityId = fillActivitySelect.value;
const start = fillStartInput.value;
const end = fillEndInput.value;
if (!activityId || !start || !end) {
showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error');
return;
}
if (end < start) {
showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error');
return;
}
const course = fillCourseSelect.value;
let changed = 0;
calendarGrid.querySelectorAll('.calendar-day').forEach(day => {
const sameCourse = !course || day.dataset.course === course;
const inRange = day.dataset.date >= start && day.dataset.date <= end;
if (sameCourse && inRange) {
day.querySelector('.calendar-day-activity').value = activityId;
updateCalendarDayVisual(day);
changed += 1;
}
});
renderCalendarTotals();
showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success');
}
function renderCalendarGrid(rows) {
const grouped = groupBy(rows, row => row.courseNumber);
calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => `
<section class="calendar-course-block">
<div class="calendar-course-header">
<h3>${course} курс</h3>
<span>${courseRows.length} дней</span>
</div>
<div class="calendar-semesters-grid">
${renderCourseSemesters(Number(course), courseRows)}
</div>
</section>
`).join('');
populateFillCourseSelect();
renderCalendarTotals();
}
function renderCourseSemesters(course, courseRows) {
const calendar = selectedEditorCalendar();
const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows);
return semestersForCourse.map(block => `
<section class="calendar-semester-block">
<div class="calendar-semester-header">
<h4>${block.semesterNumber} семестр</h4>
<span>${escapeHtml(block.caption)}</span>
</div>
${renderSemesterTable(block.rows)}
</section>
`).join('');
}
function renderSemesterTable(rows) {
if (!rows.length) {
return '<div class="calendar-empty-semester">Нет дат в этом семестре</div>';
}
const rowsByWeek = groupBy(rows, row => row.weekNumber);
const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b));
const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row]));
return `
<div class="calendar-semester-table-wrap">
<table class="calendar-semester-table">
<thead>
<tr>
<th class="calendar-day-head">День</th>
${weeks.map(week => `<th title="Неделя ${week} учебного года">${week}</th>`).join('')}
</tr>
</thead>
<tbody>
${DAY_OPTIONS.map(day => `
<tr>
<th class="calendar-day-head">${escapeHtml(day.label.slice(0, 2))}</th>
${weeks.map(week => {
const row = cellByWeekAndDay.get(`${week}:${day.value}`);
return row ? renderCalendarDay(row) : '<td class="calendar-empty-day"></td>';
}).join('')}
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
function renderCalendarDay(row) {
const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId();
const dayName = shortDayLabel(row.dayOfWeek);
const activity = activityById(selected) || activityTypes[0] || {};
const code = activity.code || row.activityCode || 'Т';
const color = activity.colorCode || '#64748b';
return `
<td class="calendar-day"
data-course="${row.courseNumber}"
data-date="${row.date}"
data-week="${row.weekNumber}"
data-day="${row.dayOfWeek}"
role="button"
tabindex="0"
aria-label="${escapeHtml(`${formatShortDate(row.date)} ${dayName}, ${row.weekNumber} неделя, код ${code}`)}"
title="${escapeHtml(`${formatLongDate(row.date)}, ${dayName}, ${row.weekNumber} неделя, ${code} - ${activity.name || ''}`)}"
style="--activity-color:${escapeHtml(color)}">
<strong class="calendar-day-code">${escapeHtml(code)}</strong>
<select class="calendar-day-activity custom-select-initialized" aria-hidden="true" tabindex="-1">${options(activityTypes.map(toActivityOption), selected)}</select>
</td>
`;
}
function openCalendarDayDialog(day) {
activeCalendarDay = day;
calendarDayDialogDate.textContent = `${formatLongDate(day.dataset.date)} (${shortDayLabel(day.dataset.day)})`;
calendarDayDialogCourse.textContent = `${day.dataset.course} курс`;
calendarDayDialogWeek.textContent = `${day.dataset.week} неделя`;
calendarDayDialogDay.textContent = dayLabel(day.dataset.day);
calendarDayDialogActivitySelect.innerHTML = options(
activityTypes.map(toActivityOption),
day.querySelector('.calendar-day-activity').value
);
syncSelects(calendarDayDialogActivitySelect);
updateCalendarDayDialogSummary(day);
calendarDayDialog.classList.add('open');
}
function closeCalendarDayDialog() {
activeCalendarDay = null;
calendarDayDialog.classList.remove('open');
}
function updateCalendarDayDialogSummary(day) {
const activity = activityById(day.querySelector('.calendar-day-activity').value);
if (!activity) {
calendarDayDialogActivitySummary.innerHTML = '';
return;
}
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code" style="--activity-color:${escapeHtml(activity.colorCode || '#64748b')}">${escapeHtml(activity.code)}</span>
<div>
<strong>${escapeHtml(activity.name)}</strong>
<p>${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}</p>
</div>
`;
}
function updateCalendarDayVisual(day) {
if (!day) return;
const select = day.querySelector('.calendar-day-activity');
const activity = activityById(select.value);
const code = activity?.code || '-';
const color = activity?.colorCode || '#64748b';
day.style.setProperty('--activity-color', color);
day.querySelector('.calendar-day-code').textContent = code;
day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`);
day.setAttribute('title', `${formatLongDate(day.dataset.date)}, ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, ${code} - ${activity?.name || ''}`);
}
function buildDefaultGrid(calendar) {
const rows = [];
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
for (let course = 1; course <= calendar.courseCount; course += 1) {
dates.forEach((date, index) => {
rows.push({
id: null,
calendarId: calendar.id,
courseNumber: course,
date: date.iso,
weekNumber: Math.floor(index / 7) + 1,
dayOfWeek: date.dayOfWeek,
activityTypeId: theoryActivityId(),
activityCode: 'Т'
});
});
}
return rows;
}
function semesterBlocksForCourse(calendar, course, courseRows) {
const sortedRows = [...courseRows].sort((a, b) => String(a.date).localeCompare(String(b.date)));
const year = academicYears.find(item => String(item.id) === String(calendar?.academicYearId));
const yearSemesters = [...(year?.semesters || [])]
.sort((a, b) => String(a.startDate).localeCompare(String(b.startDate)));
if (!yearSemesters.length) {
return [{
semesterNumber: `${course * 2 - 1}-${course * 2}`,
caption: dateRangeCaption(sortedRows),
rows: sortedRows
}];
}
return yearSemesters.map((semester, index) => {
const nextSemester = yearSemesters[index + 1];
const startDate = index === 0
? minIso(calendar.academicYearStartDate, semester.startDate)
: semester.startDate;
const endDate = nextSemester
? previousIsoDate(nextSemester.startDate)
: maxIso(calendar.academicYearEndDate, semester.endDate);
const rows = sortedRows.filter(row => row.date >= startDate && row.date <= endDate);
return {
semesterNumber: course * 2 - 1 + index,
caption: dateRangeCaption(rows) || `${formatLongDate(startDate)} - ${formatLongDate(endDate)}`,
rows
};
});
}
function renderCalendarTotals() {
const counts = new Map();
calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => {
const activity = activityById(select.value);
const key = activity ? activity.code : '-';
counts.set(key, (counts.get(key) || 0) + 1);
});
calendarTotals.innerHTML = Array.from(counts.entries())
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
.map(([code, count]) => {
const activity = activityTypes.find(item => item.code === code);
return `<span class="calendar-total-chip" style="--chip-color:${escapeHtml(activity?.colorCode || '#64748b')}">${escapeHtml(code)}: ${count}</span>`;
}).join('');
}
function populateYearSelects() {
const yearOptions = '<option value="">Выберите учебный год</option>' +
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
semesterYearSelect.innerHTML = yearOptions;
calendarYearSelect.innerHTML = yearOptions;
syncSelects(semesterYearSelect, calendarYearSelect);
}
function populateSpecialtySelects() {
calendarSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
specialties.map(speciality => `<option value="${speciality.id}">${escapeHtml(specialityLabel(speciality))}</option>`).join('');
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value);
syncSelects(calendarSpecialtySelect);
}
function populateProfileSelect(select, specialtyId, selectedProfileId = null) {
const profiles = profilesBySpecialty.get(String(specialtyId)) || [];
select.innerHTML = profiles.length
? '<option value="">Выберите профиль</option>' + profiles.map(profile =>
`<option value="${profile.id}">${escapeHtml(profile.name)}</option>`
).join('')
: '<option value="">Нет профилей</option>';
if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) {
select.value = selectedProfileId;
}
syncSelects(select);
}
function populateStudyFormSelect() {
calendarStudyFormSelect.innerHTML = '<option value="">Выберите форму</option>' +
studyForms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
syncSelects(calendarStudyFormSelect);
}
function populateActivitySelects() {
const activityOptions = '<option value="">Выберите код</option>' +
activityTypes.map(type => `<option value="${type.id}">${escapeHtml(type.code)} - ${escapeHtml(type.name)}</option>`).join('');
fillActivitySelect.innerHTML = activityOptions;
syncSelects(fillActivitySelect);
}
function populateEditorCalendarSelect() {
const current = editorCalendarSelect.value;
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
calendars.map(calendar => `<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`).join('');
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
editorCalendarSelect.value = current;
}
syncSelects(editorCalendarSelect);
}
function populateFillCourseSelect() {
const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course));
fillCourseSelect.innerHTML = '<option value="">Все курсы</option>' +
Array.from(courses).sort((a, b) => Number(a) - Number(b))
.map(course => `<option value="${course}">${course} курс</option>`).join('');
syncSelects(fillCourseSelect);
}
function options(items, selectedValue) {
const selected = selectedValue == null ? '' : String(selectedValue);
return '<option value="">Выберите</option>' + items.map(item => {
const value = String(item.value);
return `<option value="${escapeHtml(value)}" ${value === selected ? 'selected' : ''}>${escapeHtml(item.label)}</option>`;
}).join('');
}
function toActivityOption(type) {
return { value: type.id, label: `${type.code} - ${type.name}` };
}
function selectedEditorCalendar() {
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value));
}
function theoryActivityId() {
return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || '';
}
function activityIdByCode(code) {
return activityTypes.find(type => type.code === code)?.id || null;
}
function activityById(id) {
return activityTypes.find(type => String(type.id) === String(id));
}
function semesterLabel(semester) {
const type = normalizeSemesterType(semester.semesterType);
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
}
function normalizeSemesterType(type) {
return String(type || '').toLowerCase();
}
function specialityLabel(speciality) {
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
const name = speciality.specialityName || speciality.name || '';
return name ? `${code} - ${name}` : String(code);
}
function dayLabel(value) {
return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-';
}
function shortDayLabel(value) {
const label = dayLabel(value);
return label === '-' ? '-' : label.slice(0, 2);
}
function formatShortDate(value) {
const [year, month, day] = String(value).split('-');
return `${day}.${month}`;
}
function formatLongDate(value) {
const [year, month, day] = String(value).split('-');
return `${day}.${month}.${year}`;
}
function dateRangeCaption(rows) {
if (!rows.length) return '';
const dates = rows.map(row => row.date).sort();
return `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`;
}
function minIso(a, b) {
return String(a) <= String(b) ? a : b;
}
function maxIso(a, b) {
return String(a) >= String(b) ? a : b;
}
function previousIsoDate(value) {
const date = new Date(`${value}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() - 1);
return date.toISOString().slice(0, 10);
}
function enumerateDates(startIso, endIso) {
const result = [];
const current = new Date(`${startIso}T00:00:00Z`);
const end = new Date(`${endIso}T00:00:00Z`);
while (current <= end) {
const iso = current.toISOString().slice(0, 10);
const jsDay = current.getUTCDay();
result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay });
current.setUTCDate(current.getUTCDate() + 1);
}
return result;
}
function groupBy(items, keyFn) {
const grouped = new Map();
items.forEach(item => {
const key = keyFn(item);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(item);
});
return grouped;
}
function syncSelects(...selects) {
selects.filter(Boolean).forEach(select => {
select.dispatchEvent(new Event('change', { bubbles: true }));
});
}
}

View File

@@ -7,22 +7,15 @@ export async function initDepartmentsData() {
const createDeptForm = document.getElementById('create-department-form');
const createSpecForm = document.getElementById('create-specialty-form');
const createProfileForm = document.getElementById('create-profile-form');
const profileSpecialtySelect = document.getElementById('profile-specialty');
const profilesTbody = document.getElementById('profiles-tbody');
const editDeptForm = document.getElementById('edit-department-form');
const editSpecForm = document.getElementById('edit-specialty-form');
const editProfileForm = document.getElementById('edit-profile-form');
const editDeptModal = document.getElementById('modal-edit-department');
const editSpecModal = document.getElementById('modal-edit-specialty');
const editProfileModal = document.getElementById('modal-edit-profile');
const editDeptClose = document.getElementById('modal-edit-department-close');
const editSpecClose = document.getElementById('modal-edit-specialty-close');
const editProfileClose = document.getElementById('modal-edit-profile-close');
let departments = [];
let specialties = [];
let profiles = [];
async function loadData() {
// Load Departments
@@ -37,11 +30,8 @@ export async function initDepartmentsData() {
try {
specialties = await api.get('/api/specialties');
renderSpecialties();
renderProfileSpecialtySelect();
await loadProfiles();
} catch (e) {
specTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">-</td></tr>';
}
}
@@ -81,52 +71,6 @@ export async function initDepartmentsData() {
`).join('');
}
function renderProfileSpecialtySelect() {
const current = profileSpecialtySelect.value;
profileSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
specialties.map(s => `<option value="${s.id}">${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}${escapeHtml(s.specialityName || s.name)}</option>`).join('');
if (current && specialties.some(s => String(s.id) === String(current))) {
profileSpecialtySelect.value = current;
} else if (specialties.length) {
profileSpecialtySelect.value = String(specialties[0].id);
}
syncSelect(profileSpecialtySelect);
}
async function loadProfiles() {
const specialtyId = profileSpecialtySelect.value;
if (!specialtyId) {
profiles = [];
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Выберите специальность</td></tr>';
return;
}
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
profiles = await api.get(`/api/specialties/${specialtyId}/profiles`);
renderProfiles();
} catch (error) {
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderProfiles() {
if (!profiles.length) {
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Профили не созданы</td></tr>';
return;
}
profilesTbody.innerHTML = profiles.map(profile => `
<tr>
<td>${profile.id}</td>
<td>${escapeHtml(profile.name)}</td>
<td>${escapeHtml(profile.description || '-')}</td>
<td>
<button class="btn-edit-classroom btn-edit-profile" data-id="${profile.id}">Изменить</button>
<button class="btn-delete btn-delete-profile" data-id="${profile.id}">Удалить</button>
</td>
</tr>
`).join('');
}
createDeptForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-dept-alert');
@@ -197,59 +141,6 @@ export async function initDepartmentsData() {
}
});
profileSpecialtySelect.addEventListener('change', loadProfiles);
createProfileForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-profile-alert');
const specialtyId = profileSpecialtySelect.value;
const name = document.getElementById('profile-name').value.trim();
const description = document.getElementById('profile-description').value.trim();
if (!specialtyId || !name) {
showAlert('create-profile-alert', 'Выберите специальность и заполните название профиля', 'error');
return;
}
try {
await api.post(`/api/specialties/${specialtyId}/profiles`, { name, description });
showAlert('create-profile-alert', `Профиль "${name}" создан`, 'success');
createProfileForm.reset();
profileSpecialtySelect.value = specialtyId;
syncSelect(profileSpecialtySelect);
await loadProfiles();
} catch (error) {
showAlert('create-profile-alert', error.message || 'Ошибка создания профиля', 'error');
}
});
profilesTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-profile');
const deleteBtn = e.target.closest('.btn-delete-profile');
if (editBtn) {
const profile = profiles.find(item => item.id == editBtn.dataset.id);
if (!profile) return;
document.getElementById('edit-profile-id').value = profile.id;
document.getElementById('edit-profile-name').value = profile.name || '';
document.getElementById('edit-profile-description').value = profile.description || '';
hideAlert('edit-profile-alert');
editProfileModal.classList.add('open');
return;
}
if (deleteBtn) {
if (!confirm('Удалить профиль обучения?')) return;
try {
await api.delete(`/api/specialties/${profileSpecialtySelect.value}/profiles/${deleteBtn.dataset.id}`);
showAlert('create-profile-alert', 'Профиль удалён', 'success');
await loadProfiles();
} catch (error) {
showAlert('create-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
}
}
});
specTbody.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.btn-edit-specialty');
const deleteBtn = e.target.closest('.btn-delete-specialty');
@@ -322,45 +213,14 @@ export async function initDepartmentsData() {
}
});
editProfileForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-profile-alert');
const specialtyId = profileSpecialtySelect.value;
const id = document.getElementById('edit-profile-id').value;
const name = document.getElementById('edit-profile-name').value.trim();
const description = document.getElementById('edit-profile-description').value.trim();
if (!specialtyId || !id || !name) {
showAlert('edit-profile-alert', 'Заполните название профиля', 'error');
return;
}
try {
await api.put(`/api/specialties/${specialtyId}/profiles/${id}`, { id: Number(id), name, description });
editProfileModal.classList.remove('open');
showAlert('create-profile-alert', `Профиль "${name}" обновлён`, 'success');
await loadProfiles();
} catch (error) {
showAlert('edit-profile-alert', error.message || 'Ошибка обновления профиля', 'error');
}
});
editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open'));
editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open'));
editProfileClose.addEventListener('click', () => editProfileModal.classList.remove('open'));
editDeptModal.addEventListener('click', (e) => {
if (e.target === editDeptModal) editDeptModal.classList.remove('open');
});
editSpecModal.addEventListener('click', (e) => {
if (e.target === editSpecModal) editSpecModal.classList.remove('open');
});
editProfileModal.addEventListener('click', (e) => {
if (e.target === editProfileModal) editProfileModal.classList.remove('open');
});
await loadData();
function syncSelect(select) {
select.dispatchEvent(new Event('change', { bubbles: true }));
}
}

View File

@@ -225,6 +225,7 @@ export async function initGroups() {
String(calendar.academicYearId) === String(yearId)
&& String(calendar.specialtyId) === String(group.specialtyId || group.specialityCode)
&& String(calendar.specialtyProfileId) === String(group.specialtyProfileId)
&& String(calendar.studyFormId) === String(group.educationFormId)
)
: [];
calendarSelect.innerHTML = matching.length

View File

@@ -0,0 +1,192 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initProfiles() {
const createProfileForm = document.getElementById('create-profile-form');
const profileSpecialtySelect = document.getElementById('profile-specialty');
const profileNameInput = document.getElementById('profile-name');
const profileDescriptionInput = document.getElementById('profile-description');
const profilesTbody = document.getElementById('profiles-tbody');
const profilesContext = document.getElementById('profiles-context');
const refreshButton = document.getElementById('profiles-refresh');
const editProfileForm = document.getElementById('edit-profile-form');
const editProfileModal = document.getElementById('modal-edit-profile');
const editProfileClose = document.getElementById('modal-edit-profile-close');
let specialties = [];
let profiles = [];
await loadSpecialties();
profileSpecialtySelect.addEventListener('change', loadProfiles);
refreshButton.addEventListener('click', loadProfiles);
createProfileForm.addEventListener('submit', async (event) => {
event.preventDefault();
hideAlert('create-profile-alert');
const specialtyId = profileSpecialtySelect.value;
const name = profileNameInput.value.trim();
const description = profileDescriptionInput.value.trim();
if (!specialtyId || !name) {
showAlert('create-profile-alert', 'Выберите специальность и заполните название профиля', 'error');
return;
}
try {
await api.post(`/api/specialties/${specialtyId}/profiles`, { name, description });
showAlert('create-profile-alert', `Профиль "${name}" создан`, 'success');
createProfileForm.reset();
profileSpecialtySelect.value = specialtyId;
syncSelect(profileSpecialtySelect);
await loadProfiles();
} catch (error) {
showAlert('create-profile-alert', error.message || 'Ошибка создания профиля', 'error');
}
});
profilesTbody.addEventListener('click', async (event) => {
const editButton = event.target.closest('.btn-edit-profile');
const deleteButton = event.target.closest('.btn-delete-profile');
if (editButton) {
const profile = profiles.find(item => String(item.id) === String(editButton.dataset.id));
if (!profile) return;
document.getElementById('edit-profile-id').value = profile.id;
document.getElementById('edit-profile-name').value = profile.name || '';
document.getElementById('edit-profile-description').value = profile.description || '';
hideAlert('edit-profile-alert');
editProfileModal.classList.add('open');
return;
}
if (deleteButton) {
if (!confirm('Удалить профиль обучения?')) return;
try {
await api.delete(`/api/specialties/${profileSpecialtySelect.value}/profiles/${deleteButton.dataset.id}`);
showAlert('create-profile-alert', 'Профиль удалён', 'success');
await loadProfiles();
} catch (error) {
showAlert('create-profile-alert', error.message || 'Ошибка удаления профиля', 'error');
}
}
});
editProfileForm.addEventListener('submit', async (event) => {
event.preventDefault();
hideAlert('edit-profile-alert');
const specialtyId = profileSpecialtySelect.value;
const id = document.getElementById('edit-profile-id').value;
const name = document.getElementById('edit-profile-name').value.trim();
const description = document.getElementById('edit-profile-description').value.trim();
if (!specialtyId || !id || !name) {
showAlert('edit-profile-alert', 'Заполните название профиля', 'error');
return;
}
try {
await api.put(`/api/specialties/${specialtyId}/profiles/${id}`, { id: Number(id), name, description });
editProfileModal.classList.remove('open');
showAlert('create-profile-alert', `Профиль "${name}" обновлён`, 'success');
await loadProfiles();
} catch (error) {
showAlert('edit-profile-alert', error.message || 'Ошибка обновления профиля', 'error');
}
});
editProfileClose.addEventListener('click', () => editProfileModal.classList.remove('open'));
editProfileModal.addEventListener('click', (event) => {
if (event.target === editProfileModal) {
editProfileModal.classList.remove('open');
}
});
async function loadSpecialties() {
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
specialties = await api.get('/api/specialties');
renderSpecialtySelect();
await loadProfiles();
} catch (error) {
profileSpecialtySelect.innerHTML = '<option value="">Не удалось загрузить специальности</option>';
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
syncSelect(profileSpecialtySelect);
}
}
function renderSpecialtySelect() {
if (!specialties.length) {
profileSpecialtySelect.innerHTML = '<option value="">Сначала создайте специальность</option>';
syncSelect(profileSpecialtySelect);
return;
}
const current = profileSpecialtySelect.value;
profileSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
specialties.map(specialty => {
const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id;
const name = specialty.specialityName || specialty.name || 'Без названия';
return `<option value="${specialty.id}">${escapeHtml(code)}${escapeHtml(name)}</option>`;
}).join('');
if (current && specialties.some(specialty => String(specialty.id) === String(current))) {
profileSpecialtySelect.value = current;
} else {
profileSpecialtySelect.value = String(specialties[0].id);
}
syncSelect(profileSpecialtySelect);
}
async function loadProfiles() {
const specialtyId = profileSpecialtySelect.value;
const specialty = specialties.find(item => String(item.id) === String(specialtyId));
profilesContext.textContent = specialty ? specialtyLabel(specialty) : '';
if (!specialtyId) {
profiles = [];
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Выберите специальность</td></tr>';
return;
}
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
profiles = await api.get(`/api/specialties/${specialtyId}/profiles`);
renderProfiles();
} catch (error) {
profilesTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderProfiles() {
if (!profiles.length) {
profilesTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Профили не созданы</td></tr>';
return;
}
profilesTbody.innerHTML = profiles.map(profile => `
<tr>
<td>${profile.id}</td>
<td>${escapeHtml(profile.name)}</td>
<td>${escapeHtml(profile.description || '-')}</td>
<td>
<button class="btn-edit-classroom btn-edit-profile" data-id="${profile.id}">Изменить</button>
<button class="btn-delete btn-delete-profile" data-id="${profile.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function specialtyLabel(specialty) {
const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id;
const name = specialty.specialityName || specialty.name || 'Без названия';
return `${code} · ${name}`;
}
function syncSelect(select) {
select.dispatchEvent(new Event('change', { bubbles: true }));
}
}

View File

@@ -48,49 +48,6 @@ export async function initSchedule() {
const timeSlotsTbody = document.getElementById('time-slots-tbody');
const refreshSlotsButton = document.getElementById('time-slots-refresh');
const academicYearForm = document.getElementById('academic-year-form');
const academicYearFormTitle = document.getElementById('academic-year-form-title');
const academicYearIdInput = document.getElementById('academic-year-id');
const academicYearTitleInput = document.getElementById('academic-year-title');
const academicYearStartInput = document.getElementById('academic-year-start');
const academicYearEndInput = document.getElementById('academic-year-end');
const academicYearResetButton = document.getElementById('academic-year-reset');
const academicYearsTbody = document.getElementById('academic-years-tbody');
const academicYearsRefreshButton = document.getElementById('academic-years-refresh');
const semesterForm = document.getElementById('semester-form');
const semesterFormTitle = document.getElementById('semester-form-title');
const semesterIdInput = document.getElementById('semester-id');
const semesterYearSelect = document.getElementById('semester-year');
const semesterTypeSelect = document.getElementById('semester-type');
const semesterStartInput = document.getElementById('semester-start');
const semesterEndInput = document.getElementById('semester-end');
const semesterResetButton = document.getElementById('semester-reset');
const calendarForm = document.getElementById('academic-calendar-form');
const calendarFormTitle = document.getElementById('academic-calendar-form-title');
const calendarIdInput = document.getElementById('academic-calendar-id');
const calendarTitleInput = document.getElementById('academic-calendar-title');
const calendarYearSelect = document.getElementById('academic-calendar-year');
const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty');
const calendarProfileSelect = document.getElementById('academic-calendar-profile');
const calendarStudyFormSelect = document.getElementById('academic-calendar-study-form');
const calendarCourseCountInput = document.getElementById('academic-calendar-course-count');
const calendarResetButton = document.getElementById('academic-calendar-reset');
const calendarsRefreshButton = document.getElementById('academic-calendars-refresh');
const calendarsTbody = document.getElementById('academic-calendars-tbody');
const editorCalendarSelect = document.getElementById('calendar-editor-calendar');
const editorLoadButton = document.getElementById('calendar-editor-load');
const editorSaveButton = document.getElementById('calendar-editor-save');
const fillCourseSelect = document.getElementById('calendar-fill-course');
const fillStartInput = document.getElementById('calendar-fill-start');
const fillEndInput = document.getElementById('calendar-fill-end');
const fillActivitySelect = document.getElementById('calendar-fill-activity');
const fillApplyButton = document.getElementById('calendar-fill-apply');
const calendarGrid = document.getElementById('calendar-grid');
const calendarTotals = document.getElementById('calendar-totals');
let rules = [];
let subjects = [];
let groups = [];
@@ -98,22 +55,15 @@ export async function initSchedule() {
let classrooms = [];
let lessonTypes = [];
let timeSlots = [];
let academicYears = [];
let semesters = [];
let specialties = [];
let profilesBySpecialty = new Map();
let studyForms = [];
let activityTypes = [];
let calendars = [];
let currentGridRows = [];
bindEvents();
try {
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadStudyForms(), loadActivityTypes()]);
await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]);
renderRuleGroups([]);
renderRuleSlots([{}]);
await Promise.all([loadRules(), loadCalendars()]);
await loadRules();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error');
}
@@ -121,18 +71,9 @@ export async function initSchedule() {
function bindEvents() {
refreshRulesButton?.addEventListener('click', loadRules);
refreshSlotsButton?.addEventListener('click', loadTimeSlots);
academicYearsRefreshButton?.addEventListener('click', loadYears);
calendarsRefreshButton?.addEventListener('click', loadCalendars);
ruleResetButton?.addEventListener('click', resetRuleForm);
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
timeSlotResetButton?.addEventListener('click', resetTimeSlotForm);
academicYearResetButton?.addEventListener('click', resetAcademicYearForm);
semesterResetButton?.addEventListener('click', resetSemesterForm);
calendarResetButton?.addEventListener('click', resetCalendarForm);
calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value));
editorLoadButton?.addEventListener('click', loadCalendarGrid);
editorSaveButton?.addEventListener('click', saveCalendarGrid);
fillApplyButton?.addEventListener('click', applyCalendarFill);
ruleSlotsContainer.addEventListener('click', (event) => {
const removeButton = event.target.closest('.schedule-slot-remove');
@@ -142,44 +83,27 @@ export async function initSchedule() {
renderRuleSlots(slots.length ? slots : [{}]);
});
calendarGrid.addEventListener('change', (event) => {
if (event.target.matches('.calendar-day-activity')) {
renderCalendarTotals();
}
});
ruleForm.addEventListener('submit', saveRule);
timeSlotForm.addEventListener('submit', saveTimeSlot);
academicYearForm.addEventListener('submit', saveAcademicYear);
semesterForm.addEventListener('submit', saveSemester);
calendarForm.addEventListener('submit', saveCalendar);
rulesTbody.addEventListener('click', handleRuleTableClick);
timeSlotsTbody.addEventListener('click', handleTimeSlotTableClick);
academicYearsTbody.addEventListener('click', handleAcademicYearTableClick);
calendarsTbody.addEventListener('click', handleCalendarTableClick);
}
async function loadBaseLists() {
[subjects, groups, teachers, classrooms, lessonTypes, specialties] = await Promise.all([
[subjects, groups, teachers, classrooms, lessonTypes] = await Promise.all([
api.get('/api/subjects'),
api.get('/api/groups'),
api.get('/api/users/teachers'),
api.get('/api/classrooms'),
api.get('/api/lesson-types'),
api.get('/api/specialties')
api.get('/api/lesson-types')
]);
await loadProfiles();
populateSubjectSelects();
populateSpecialtySelects();
}
async function loadProfiles() {
const pairs = await Promise.all(specialties.map(async (speciality) => {
const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`);
return [String(speciality.id), profiles];
}));
profilesBySpecialty = new Map(pairs);
async function loadYears() {
const academicYears = await api.get('/api/admin/calendar/years');
semesters = academicYears.flatMap(year => year.semesters || []);
populateSemesterSelects();
}
async function loadRules() {
@@ -418,6 +342,7 @@ export async function initSchedule() {
showAlert('time-slot-alert', 'Временной слот сохранён', 'success');
resetTimeSlotForm();
await loadTimeSlots();
renderRuleSlots(readRuleSlots(false).length ? readRuleSlots(false) : [{}]);
} catch (error) {
showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error');
}
@@ -426,6 +351,7 @@ export async function initSchedule() {
async function handleTimeSlotTableClick(event) {
const editButton = event.target.closest('.btn-edit-time-slot');
const deleteButton = event.target.closest('.btn-delete-time-slot');
if (editButton) {
const slot = timeSlots.find(item => item.id == editButton.dataset.id);
if (!slot) return;
@@ -439,12 +365,14 @@ export async function initSchedule() {
timeSlotForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteButton) {
if (!confirm('Удалить временной слот?')) return;
try {
await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id);
showAlert('time-slot-alert', 'Временной слот удалён', 'success');
await loadTimeSlots();
renderRuleSlots(readRuleSlots(false).length ? readRuleSlots(false) : [{}]);
} catch (error) {
showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error');
}
@@ -458,426 +386,10 @@ export async function initSchedule() {
hideAlert('time-slot-alert');
}
async function loadYears() {
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
try {
academicYears = await api.get('/api/admin/calendar/years');
semesters = academicYears.flatMap(year => year.semesters || []);
renderAcademicYears();
populateYearSelects();
populateSemesterSelects();
} catch (error) {
academicYearsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderAcademicYears() {
if (!academicYears.length) {
academicYearsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Учебные годы не созданы</td></tr>';
return;
}
academicYearsTbody.innerHTML = academicYears.map(year => `
<tr>
<td>${escapeHtml(year.title)}</td>
<td>${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)}</td>
<td>${renderSemesterList(year.semesters || [])}</td>
<td>
<button class="btn-edit-classroom btn-edit-year" data-id="${year.id}">Изменить</button>
<button class="btn-delete btn-delete-year" data-id="${year.id}">Удалить</button>
</td>
</tr>
`).join('');
}
function renderSemesterList(items) {
if (!items.length) return '<span class="muted">Нет семестров</span>';
return items.map(semester => `
<div class="schedule-semester-line">
${escapeHtml(semesterLabel(semester))}
<button class="btn-edit-classroom btn-edit-semester" data-id="${semester.id}">Изменить</button>
</div>
`).join('');
}
async function saveAcademicYear(event) {
event.preventDefault();
hideAlert('academic-year-alert');
const payload = {
title: academicYearTitleInput.value.trim(),
startDate: academicYearStartInput.value,
endDate: academicYearEndInput.value
};
try {
if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года');
const id = academicYearIdInput.value;
await (id
? api.put('/api/admin/calendar/years/' + id, payload)
: api.post('/api/admin/calendar/years', payload));
showAlert('academic-year-alert', 'Учебный год сохранён', 'success');
resetAcademicYearForm();
await loadYears();
await loadCalendars();
} catch (error) {
showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error');
}
}
async function handleAcademicYearTableClick(event) {
const editYearButton = event.target.closest('.btn-edit-year');
const deleteYearButton = event.target.closest('.btn-delete-year');
const editSemesterButton = event.target.closest('.btn-edit-semester');
if (editYearButton) {
const year = academicYears.find(item => item.id == editYearButton.dataset.id);
if (!year) return;
academicYearFormTitle.textContent = 'Редактирование учебного года';
academicYearIdInput.value = year.id;
academicYearTitleInput.value = year.title || '';
academicYearStartInput.value = year.startDate || '';
academicYearEndInput.value = year.endDate || '';
hideAlert('academic-year-alert');
academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (editSemesterButton) {
const semester = semesters.find(item => item.id == editSemesterButton.dataset.id);
if (!semester) return;
semesterFormTitle.textContent = 'Редактирование семестра';
semesterIdInput.value = semester.id;
semesterYearSelect.value = semester.academicYearId || '';
semesterTypeSelect.value = normalizeSemesterType(semester.semesterType);
semesterStartInput.value = semester.startDate || '';
semesterEndInput.value = semester.endDate || '';
syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert');
semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteYearButton) {
if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return;
try {
await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id);
showAlert('academic-year-alert', 'Учебный год удалён', 'success');
await loadYears();
await loadCalendars();
} catch (error) {
showAlert('academic-year-alert', error.message || 'Ошибка удаления учебного года', 'error');
}
}
}
function resetAcademicYearForm() {
academicYearForm.reset();
academicYearFormTitle.textContent = 'Учебный год';
academicYearIdInput.value = '';
hideAlert('academic-year-alert');
}
async function saveSemester(event) {
event.preventDefault();
hideAlert('semester-alert');
const yearId = semesterYearSelect.value;
const payload = {
semesterType: semesterTypeSelect.value,
startDate: semesterStartInput.value,
endDate: semesterEndInput.value
};
try {
if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) throw new Error('Заполните учебный год, тип и даты семестра');
const id = semesterIdInput.value;
await (id
? api.put('/api/admin/calendar/semesters/' + id, payload)
: api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload));
showAlert('semester-alert', 'Семестр сохранён', 'success');
resetSemesterForm();
await loadYears();
} catch (error) {
showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error');
}
}
function resetSemesterForm() {
semesterForm.reset();
semesterFormTitle.textContent = 'Семестр';
semesterIdInput.value = '';
syncSelects(semesterYearSelect, semesterTypeSelect);
hideAlert('semester-alert');
}
async function loadStudyForms() {
studyForms = await api.get('/api/admin/academic-calendars/study-forms');
populateStudyFormSelect();
}
async function loadActivityTypes() {
activityTypes = await api.get('/api/admin/calendar/activity-types');
populateActivitySelects();
}
async function loadCalendars() {
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
try {
calendars = await api.get('/api/admin/academic-calendars');
renderCalendars();
populateEditorCalendarSelect();
} catch (error) {
calendarsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
}
}
function renderCalendars() {
if (!calendars.length) {
calendarsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Календарные графики не созданы</td></tr>';
return;
}
calendarsTbody.innerHTML = calendars.map(calendar => `
<tr>
<td>${escapeHtml(calendar.title)}</td>
<td>${escapeHtml(calendar.academicYearTitle || '-')}</td>
<td>${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')}</td>
<td>${escapeHtml(calendar.specialtyProfileName || '-')}</td>
<td>${escapeHtml(calendar.studyFormName || '-')}</td>
<td>${escapeHtml(String(calendar.courseCount || '-'))}</td>
<td>
<button class="btn-edit-classroom btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
<button class="btn-edit-classroom btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
<button class="btn-delete btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
</td>
</tr>
`).join('');
}
async function saveCalendar(event) {
event.preventDefault();
hideAlert('academic-calendar-alert');
const payload = {
id: calendarIdInput.value ? Number(calendarIdInput.value) : null,
title: calendarTitleInput.value.trim(),
academicYearId: Number(calendarYearSelect.value),
specialtyId: Number(calendarSpecialtySelect.value),
specialtyProfileId: Number(calendarProfileSelect.value),
studyFormId: Number(calendarStudyFormSelect.value),
courseCount: Number(calendarCourseCountInput.value)
};
try {
if (!payload.title || !payload.academicYearId || !payload.specialtyId
|| !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) {
throw new Error('Заполните все поля календарного графика');
}
const id = calendarIdInput.value;
const saved = id
? await api.put('/api/admin/academic-calendars/' + id, payload)
: await api.post('/api/admin/academic-calendars', payload);
showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success');
resetCalendarForm();
await loadCalendars();
} catch (error) {
showAlert('academic-calendar-alert', error.message || 'Ошибка сохранения календарного графика', 'error');
}
}
async function handleCalendarTableClick(event) {
const editButton = event.target.closest('.btn-edit-calendar');
const gridButton = event.target.closest('.btn-open-calendar-grid');
const deleteButton = event.target.closest('.btn-delete-calendar');
if (editButton) {
const calendar = calendars.find(item => item.id == editButton.dataset.id);
if (!calendar) return;
calendarFormTitle.textContent = 'Редактирование календарного графика';
calendarIdInput.value = calendar.id;
calendarTitleInput.value = calendar.title || '';
calendarYearSelect.value = calendar.academicYearId || '';
calendarSpecialtySelect.value = calendar.specialtyId || '';
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId);
calendarStudyFormSelect.value = calendar.studyFormId || '';
calendarCourseCountInput.value = calendar.courseCount || 4;
syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect);
hideAlert('academic-calendar-alert');
calendarForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (gridButton) {
editorCalendarSelect.value = gridButton.dataset.id;
syncSelects(editorCalendarSelect);
await loadCalendarGrid();
calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (deleteButton) {
if (!confirm('Удалить календарный график?')) return;
try {
await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id);
showAlert('academic-calendar-alert', 'Календарный график удалён', 'success');
await loadCalendars();
} catch (error) {
showAlert('academic-calendar-alert', error.message || 'Ошибка удаления календарного графика', 'error');
}
}
}
function resetCalendarForm() {
calendarForm.reset();
calendarFormTitle.textContent = 'Календарный учебный график';
calendarIdInput.value = '';
calendarCourseCountInput.value = 4;
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value);
syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect);
hideAlert('academic-calendar-alert');
}
async function loadCalendarGrid() {
hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar();
if (!calendar) {
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
return;
}
try {
const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`);
currentGridRows = rows.length ? rows : buildDefaultGrid(calendar);
renderCalendarGrid(currentGridRows);
showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success');
} catch (error) {
showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error');
}
}
async function saveCalendarGrid() {
hideAlert('calendar-editor-alert');
const calendar = selectedEditorCalendar();
if (!calendar) {
showAlert('calendar-editor-alert', 'Выберите календарный график', 'error');
return;
}
const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({
calendarId: calendar.id,
courseNumber: Number(day.dataset.course),
date: day.dataset.date,
weekNumber: Number(day.dataset.week),
dayOfWeek: Number(day.dataset.day),
activityTypeId: Number(day.querySelector('.calendar-day-activity').value)
}));
if (!rows.length) {
showAlert('calendar-editor-alert', 'Сначала загрузите сетку графика', 'error');
return;
}
try {
await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows);
showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success');
await loadCalendarGrid();
} catch (error) {
showAlert('calendar-editor-alert', error.message || 'Ошибка сохранения сетки', 'error');
}
}
function applyCalendarFill() {
hideAlert('calendar-editor-alert');
const activityId = fillActivitySelect.value;
const start = fillStartInput.value;
const end = fillEndInput.value;
if (!activityId || !start || !end) {
showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error');
return;
}
if (end < start) {
showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error');
return;
}
const course = fillCourseSelect.value;
let changed = 0;
calendarGrid.querySelectorAll('.calendar-day').forEach(day => {
const sameCourse = !course || day.dataset.course === course;
const inRange = day.dataset.date >= start && day.dataset.date <= end;
if (sameCourse && inRange) {
day.querySelector('.calendar-day-activity').value = activityId;
changed += 1;
}
});
renderCalendarTotals();
showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success');
}
function renderCalendarGrid(rows) {
const grouped = groupBy(rows, row => row.courseNumber);
calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => `
<section class="calendar-course-block">
<div class="calendar-course-header">
<h3>${course} курс</h3>
<span>${courseRows.length} дней</span>
</div>
<div class="calendar-days-grid">
${courseRows.map(renderCalendarDay).join('')}
</div>
</section>
`).join('');
populateFillCourseSelect();
renderCalendarTotals();
}
function renderCalendarDay(row) {
const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId();
const dayName = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'][Number(row.dayOfWeek) - 1] || '';
return `
<div class="calendar-day"
data-course="${row.courseNumber}"
data-date="${row.date}"
data-week="${row.weekNumber}"
data-day="${row.dayOfWeek}">
<div class="calendar-day-meta">
<span>${formatShortDate(row.date)}</span>
<span>${dayName}</span>
</div>
<select class="calendar-day-activity">${options(activityTypes.map(toActivityOption), selected)}</select>
<span class="calendar-week-label">${row.weekNumber} нед.</span>
</div>
`;
}
function buildDefaultGrid(calendar) {
const rows = [];
const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate);
for (let course = 1; course <= calendar.courseCount; course += 1) {
dates.forEach((date, index) => {
rows.push({
id: null,
calendarId: calendar.id,
courseNumber: course,
date: date.iso,
weekNumber: Math.floor(index / 7) + 1,
dayOfWeek: date.dayOfWeek,
activityTypeId: theoryActivityId(),
activityCode: 'Т'
});
});
}
return rows;
}
function renderCalendarTotals() {
const counts = new Map();
calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => {
const activity = activityById(select.value);
const key = activity ? activity.code : '-';
counts.set(key, (counts.get(key) || 0) + 1);
});
calendarTotals.innerHTML = Array.from(counts.entries())
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
.map(([code, count]) => {
const activity = activityTypes.find(item => item.code === code);
return `<span class="calendar-total-chip" style="--chip-color:${escapeHtml(activity?.colorCode || '#64748b')}">${escapeHtml(code)}: ${count}</span>`;
}).join('');
}
function populateSubjectSelects() {
ruleSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
subjects.map(subject => `<option value="${subject.id}">${escapeHtml(subject.name)}</option>`).join('');
}
function populateYearSelects() {
const yearOptions = '<option value="">Выберите учебный год</option>' +
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
semesterYearSelect.innerHTML = yearOptions;
calendarYearSelect.innerHTML = yearOptions;
syncSelects(semesterYearSelect, calendarYearSelect);
syncSelects(ruleSubjectSelect);
}
function populateSemesterSelects() {
@@ -887,57 +399,6 @@ export async function initSchedule() {
syncSelects(ruleSemesterSelect);
}
function populateSpecialtySelects() {
calendarSpecialtySelect.innerHTML = '<option value="">Выберите специальность</option>' +
specialties.map(speciality => `<option value="${speciality.id}">${escapeHtml(specialityLabel(speciality))}</option>`).join('');
populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value);
syncSelects(calendarSpecialtySelect);
}
function populateProfileSelect(select, specialtyId, selectedProfileId = null) {
const profiles = profilesBySpecialty.get(String(specialtyId)) || [];
select.innerHTML = profiles.length
? '<option value="">Выберите профиль</option>' + profiles.map(profile =>
`<option value="${profile.id}">${escapeHtml(profile.name)}</option>`
).join('')
: '<option value="">Нет профилей</option>';
if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) {
select.value = selectedProfileId;
}
syncSelects(select);
}
function populateStudyFormSelect() {
calendarStudyFormSelect.innerHTML = '<option value="">Выберите форму</option>' +
studyForms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
syncSelects(calendarStudyFormSelect);
}
function populateActivitySelects() {
const activityOptions = '<option value="">Выберите код</option>' +
activityTypes.map(type => `<option value="${type.id}">${escapeHtml(type.code)} - ${escapeHtml(type.name)}</option>`).join('');
fillActivitySelect.innerHTML = activityOptions;
syncSelects(fillActivitySelect);
}
function populateEditorCalendarSelect() {
const current = editorCalendarSelect.value;
editorCalendarSelect.innerHTML = '<option value="">Выберите график</option>' +
calendars.map(calendar => `<option value="${calendar.id}">${escapeHtml(calendar.title)}</option>`).join('');
if (current && calendars.some(calendar => String(calendar.id) === String(current))) {
editorCalendarSelect.value = current;
}
syncSelects(editorCalendarSelect);
}
function populateFillCourseSelect() {
const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course));
fillCourseSelect.innerHTML = '<option value="">Все курсы</option>' +
Array.from(courses).sort((a, b) => Number(a) - Number(b))
.map(course => `<option value="${course}">${course} курс</option>`).join('');
syncSelects(fillCourseSelect);
}
function options(items, selectedValue) {
const selected = selectedValue == null ? '' : String(selectedValue);
return '<option value="">Выберите</option>' + items.map(item => {
@@ -946,10 +407,6 @@ export async function initSchedule() {
}).join('');
}
function toActivityOption(type) {
return { value: type.id, label: `${type.code} - ${type.name}` };
}
function toTimeSlotOption(slot) {
return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` };
}
@@ -967,22 +424,6 @@ export async function initSchedule() {
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
}
function selectedEditorCalendar() {
return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value));
}
function theoryActivityId() {
return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || '';
}
function activityIdByCode(code) {
return activityTypes.find(type => type.code === code)?.id || null;
}
function activityById(id) {
return activityTypes.find(type => String(type.id) === String(id));
}
function semesterLabel(semester) {
const type = normalizeSemesterType(semester.semesterType);
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
@@ -997,45 +438,11 @@ export async function initSchedule() {
return String(type || '').toLowerCase();
}
function specialityLabel(speciality) {
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
const name = speciality.specialityName || speciality.name || '';
return name ? `${code} - ${name}` : String(code);
}
function trimTime(value) {
if (!value) return '';
return String(value).slice(0, 5);
}
function formatShortDate(value) {
const [year, month, day] = String(value).split('-');
return `${day}.${month}`;
}
function enumerateDates(startIso, endIso) {
const result = [];
const current = new Date(`${startIso}T00:00:00Z`);
const end = new Date(`${endIso}T00:00:00Z`);
while (current <= end) {
const iso = current.toISOString().slice(0, 10);
const jsDay = current.getUTCDay();
result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay });
current.setUTCDate(current.getUTCDate() + 1);
}
return result;
}
function groupBy(items, keyFn) {
const grouped = new Map();
items.forEach(item => {
const key = keyFn(item);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(item);
});
return grouped;
}
function syncSelects(...selects) {
selects.filter(Boolean).forEach(select => {
select.dispatchEvent(new Event('change', { bubbles: true }));

View File

@@ -0,0 +1,225 @@
<div class="card create-card">
<h2 id="academic-year-form-title">Учебный год</h2>
<form id="academic-year-form" novalidate>
<input type="hidden" id="academic-year-id">
<div class="form-row">
<div class="form-group">
<label for="academic-year-title">Название</label>
<input type="text" id="academic-year-title" placeholder="2026-2027" required>
</div>
<div class="form-group">
<label for="academic-year-start">Начало</label>
<input type="date" id="academic-year-start" required>
</div>
<div class="form-group">
<label for="academic-year-end">Окончание</label>
<input type="date" id="academic-year-end" required>
</div>
<button type="submit" class="btn-primary">Сохранить год</button>
<button type="button" class="btn-edit-classroom" id="academic-year-reset">Очистить</button>
</div>
<div class="form-alert" id="academic-year-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Учебные годы</h2>
<button class="btn-primary" id="academic-years-refresh">Обновить</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Название</th>
<th>Период</th>
<th>Семестры</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="academic-years-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card create-card">
<h2 id="semester-form-title">Семестр</h2>
<form id="semester-form" novalidate>
<input type="hidden" id="semester-id">
<div class="form-row">
<div class="form-group">
<label for="semester-year">Учебный год</label>
<select id="semester-year" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="semester-type">Тип семестра</label>
<select id="semester-type" required>
<option value="autumn">Осенний</option>
<option value="spring">Весенний</option>
</select>
</div>
<div class="form-group">
<label for="semester-start">Начало</label>
<input type="date" id="semester-start" required>
</div>
<div class="form-group">
<label for="semester-end">Окончание</label>
<input type="date" id="semester-end" required>
</div>
<button type="submit" class="btn-primary">Сохранить семестр</button>
<button type="button" class="btn-edit-classroom" id="semester-reset">Очистить</button>
</div>
<div class="form-alert" id="semester-alert" role="alert"></div>
</form>
</div>
<div class="card create-card">
<div class="card-header-row">
<h2 id="academic-calendar-form-title">Календарный учебный график</h2>
<button type="button" class="btn-edit-classroom" id="academic-calendar-reset">Очистить</button>
</div>
<form id="academic-calendar-form" novalidate>
<input type="hidden" id="academic-calendar-id">
<div class="form-row">
<div class="form-group">
<label for="academic-calendar-title">Название</label>
<input type="text" id="academic-calendar-title" placeholder="09.03.04 очная форма 2025-2026" required>
</div>
<div class="form-group">
<label for="academic-calendar-year">Учебный год</label>
<select id="academic-calendar-year" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-specialty">Специальность</label>
<select id="academic-calendar-specialty" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-profile">Профиль</label>
<select id="academic-calendar-profile" required>
<option value="">Выберите специальность</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-study-form">Форма обучения</label>
<select id="academic-calendar-study-form" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-course-count">Курсов</label>
<input type="number" id="academic-calendar-course-count" min="1" max="8" value="4" required>
</div>
<button type="submit" class="btn-primary">Сохранить график</button>
</div>
<div class="form-alert" id="academic-calendar-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Календарные графики</h2>
<button class="btn-primary" id="academic-calendars-refresh">Обновить</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Название</th>
<th>Учебный год</th>
<th>Специальность</th>
<th>Профиль</th>
<th>Форма</th>
<th>Курсов</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="academic-calendars-tbody">
<tr>
<td colspan="7" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card create-card">
<h2>Редактор годового графика</h2>
<div class="form-row">
<div class="form-group">
<label for="calendar-editor-calendar">График</label>
<select id="calendar-editor-calendar">
<option value="">Загрузка...</option>
</select>
</div>
<button type="button" class="btn-primary" id="calendar-editor-load">Загрузить сетку</button>
<button type="button" class="btn-primary" id="calendar-editor-save">Сохранить сетку</button>
</div>
<div class="calendar-fill-panel">
<div class="form-group">
<label for="calendar-fill-course">Курс</label>
<select id="calendar-fill-course">
<option value="">Все курсы</option>
</select>
</div>
<div class="form-group">
<label for="calendar-fill-start">С даты</label>
<input type="date" id="calendar-fill-start">
</div>
<div class="form-group">
<label for="calendar-fill-end">По дату</label>
<input type="date" id="calendar-fill-end">
</div>
<div class="form-group">
<label for="calendar-fill-activity">Код</label>
<select id="calendar-fill-activity">
<option value="">Загрузка...</option>
</select>
</div>
<button type="button" class="btn-edit-classroom" id="calendar-fill-apply">Заполнить диапазон</button>
</div>
<div class="form-alert" id="calendar-editor-alert" role="alert"></div>
<div id="calendar-totals" class="calendar-totals"></div>
<div id="calendar-grid" class="academic-calendar-grid"></div>
</div>
<div class="modal-overlay" id="calendar-day-dialog">
<div class="modal-content card calendar-day-dialog-card">
<h2>Ячейка графика</h2>
<button class="modal-close" id="calendar-day-dialog-close">&times;</button>
<div class="calendar-day-dialog-grid">
<div>
<span>Дата</span>
<strong id="calendar-day-dialog-date">-</strong>
</div>
<div>
<span>Курс</span>
<strong id="calendar-day-dialog-course">-</strong>
</div>
<div>
<span>Неделя</span>
<strong id="calendar-day-dialog-week">-</strong>
</div>
<div>
<span>День</span>
<strong id="calendar-day-dialog-day">-</strong>
</div>
</div>
<div class="calendar-day-dialog-activity" id="calendar-day-dialog-activity-summary"></div>
<div class="form-group">
<label for="calendar-day-dialog-activity">Код активности</label>
<select id="calendar-day-dialog-activity">
<option value="">Выберите код</option>
</select>
</div>
</div>
</div>

View File

@@ -40,47 +40,6 @@
</div>
</div>
<div class="card create-card">
<h2>Профили обучения</h2>
<form id="create-profile-form">
<div class="form-row">
<div class="form-group">
<label for="profile-specialty">Специальность</label>
<select id="profile-specialty" required>
<option value="">Сначала создайте специальность</option>
</select>
</div>
<div class="form-group">
<label for="profile-name">Название профиля</label>
<input type="text" id="profile-name" placeholder="Например: Безопасность автоматизированных систем" required>
</div>
<div class="form-group">
<label for="profile-description">Описание</label>
<input type="text" id="profile-description" placeholder="Необязательно">
</div>
<button type="submit" class="btn-primary">Создать профиль</button>
</div>
<div class="form-alert" id="create-profile-alert" role="alert"></div>
</form>
<div class="table-wrap schedule-inline-table">
<table id="profiles-table">
<thead>
<tr>
<th>ID</th>
<th>Профиль</th>
<th>Описание</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="profiles-tbody">
<tr>
<td colspan="4" class="loading-row">Выберите специальность</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card create-card">
<h2>Создание специальности</h2>
<form id="create-specialty-form">
@@ -165,25 +124,3 @@
</form>
</div>
</div>
<div class="modal-overlay" id="modal-edit-profile">
<div class="modal-content card">
<h2>Редактирование профиля</h2>
<button class="modal-close" id="modal-edit-profile-close">&times;</button>
<form id="edit-profile-form">
<input type="hidden" id="edit-profile-id">
<div class="form-row">
<div class="form-group">
<label for="edit-profile-name">Название профиля</label>
<input type="text" id="edit-profile-name" required>
</div>
<div class="form-group">
<label for="edit-profile-description">Описание</label>
<input type="text" id="edit-profile-description">
</div>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
<div class="form-alert" id="edit-profile-alert" role="alert"></div>
</form>
</div>
</div>

View File

@@ -0,0 +1,72 @@
<div class="card create-card">
<div class="card-header-row">
<h2>Создание профиля обучения</h2>
</div>
<form id="create-profile-form">
<div class="form-row">
<div class="form-group">
<label for="profile-specialty">Специальность</label>
<select id="profile-specialty" required>
<option value="">Загрузка специальностей...</option>
</select>
</div>
<div class="form-group">
<label for="profile-name">Название профиля</label>
<input type="text" id="profile-name" placeholder="Безопасность автоматизированных систем" required>
</div>
<div class="form-group">
<label for="profile-description">Описание</label>
<input type="text" id="profile-description" placeholder="Необязательно">
</div>
<button type="submit" class="btn-primary">Создать профиль</button>
</div>
<div class="form-alert" id="create-profile-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Профили выбранной специальности</h2>
<button class="btn-primary" id="profiles-refresh" type="button">Обновить</button>
</div>
<div class="profiles-context" id="profiles-context"></div>
<div class="table-wrap">
<table id="profiles-table">
<thead>
<tr>
<th>ID</th>
<th>Профиль</th>
<th>Описание</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="profiles-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="modal-overlay" id="modal-edit-profile">
<div class="modal-content card">
<h2>Редактирование профиля</h2>
<button class="modal-close" id="modal-edit-profile-close">&times;</button>
<form id="edit-profile-form">
<input type="hidden" id="edit-profile-id">
<div class="form-row">
<div class="form-group">
<label for="edit-profile-name">Название профиля</label>
<input type="text" id="edit-profile-name" required>
</div>
<div class="form-group">
<label for="edit-profile-description">Описание</label>
<input type="text" id="edit-profile-description">
</div>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
<div class="form-alert" id="edit-profile-alert" role="alert"></div>
</form>
</div>
</div>

View File

@@ -130,197 +130,3 @@
</table>
</div>
</div>
<div class="card create-card">
<h2 id="academic-year-form-title">Учебный год</h2>
<form id="academic-year-form" novalidate>
<input type="hidden" id="academic-year-id">
<div class="form-row">
<div class="form-group">
<label for="academic-year-title">Название</label>
<input type="text" id="academic-year-title" placeholder="2026-2027" required>
</div>
<div class="form-group">
<label for="academic-year-start">Начало</label>
<input type="date" id="academic-year-start" required>
</div>
<div class="form-group">
<label for="academic-year-end">Окончание</label>
<input type="date" id="academic-year-end" required>
</div>
<button type="submit" class="btn-primary">Сохранить год</button>
<button type="button" class="btn-edit-classroom" id="academic-year-reset">Очистить</button>
</div>
<div class="form-alert" id="academic-year-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Учебные годы</h2>
<button class="btn-primary" id="academic-years-refresh">Обновить</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Название</th>
<th>Период</th>
<th>Семестры</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="academic-years-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card create-card">
<h2 id="semester-form-title">Семестр</h2>
<form id="semester-form" novalidate>
<input type="hidden" id="semester-id">
<div class="form-row">
<div class="form-group">
<label for="semester-year">Учебный год</label>
<select id="semester-year" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="semester-type">Тип семестра</label>
<select id="semester-type" required>
<option value="autumn">Осенний</option>
<option value="spring">Весенний</option>
</select>
</div>
<div class="form-group">
<label for="semester-start">Начало</label>
<input type="date" id="semester-start" required>
</div>
<div class="form-group">
<label for="semester-end">Окончание</label>
<input type="date" id="semester-end" required>
</div>
<button type="submit" class="btn-primary">Сохранить семестр</button>
<button type="button" class="btn-edit-classroom" id="semester-reset">Очистить</button>
</div>
<div class="form-alert" id="semester-alert" role="alert"></div>
</form>
</div>
<div class="card create-card">
<div class="card-header-row">
<h2 id="academic-calendar-form-title">Календарный учебный график</h2>
<button type="button" class="btn-edit-classroom" id="academic-calendar-reset">Очистить</button>
</div>
<form id="academic-calendar-form" novalidate>
<input type="hidden" id="academic-calendar-id">
<div class="form-row">
<div class="form-group">
<label for="academic-calendar-title">Название</label>
<input type="text" id="academic-calendar-title" placeholder="09.03.04 очная форма 2025-2026" required>
</div>
<div class="form-group">
<label for="academic-calendar-year">Учебный год</label>
<select id="academic-calendar-year" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-specialty">Специальность</label>
<select id="academic-calendar-specialty" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-profile">Профиль</label>
<select id="academic-calendar-profile" required>
<option value="">Выберите специальность</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-study-form">Форма обучения</label>
<select id="academic-calendar-study-form" required>
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="academic-calendar-course-count">Курсов</label>
<input type="number" id="academic-calendar-course-count" min="1" max="8" value="4" required>
</div>
<button type="submit" class="btn-primary">Сохранить график</button>
</div>
<div class="form-alert" id="academic-calendar-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Календарные графики</h2>
<button class="btn-primary" id="academic-calendars-refresh">Обновить</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Название</th>
<th>Учебный год</th>
<th>Специальность</th>
<th>Профиль</th>
<th>Форма</th>
<th>Курсов</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="academic-calendars-tbody">
<tr>
<td colspan="7" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card create-card">
<h2>Редактор годового графика</h2>
<div class="form-row">
<div class="form-group">
<label for="calendar-editor-calendar">График</label>
<select id="calendar-editor-calendar">
<option value="">Загрузка...</option>
</select>
</div>
<button type="button" class="btn-primary" id="calendar-editor-load">Загрузить сетку</button>
<button type="button" class="btn-primary" id="calendar-editor-save">Сохранить сетку</button>
</div>
<div class="calendar-fill-panel">
<div class="form-group">
<label for="calendar-fill-course">Курс</label>
<select id="calendar-fill-course">
<option value="">Все курсы</option>
</select>
</div>
<div class="form-group">
<label for="calendar-fill-start">С даты</label>
<input type="date" id="calendar-fill-start">
</div>
<div class="form-group">
<label for="calendar-fill-end">По дату</label>
<input type="date" id="calendar-fill-end">
</div>
<div class="form-group">
<label for="calendar-fill-activity">Код</label>
<select id="calendar-fill-activity">
<option value="">Загрузка...</option>
</select>
</div>
<button type="button" class="btn-edit-classroom" id="calendar-fill-apply">Заполнить диапазон</button>
</div>
<div class="form-alert" id="calendar-editor-alert" role="alert"></div>
<div id="calendar-totals" class="calendar-totals"></div>
<div id="calendar-grid" class="academic-calendar-grid"></div>
</div>