Merge branch 'dynamic_schedule' of https://gitea.zuev.company/Zuev/magistr into dynamic_schedule

This commit is contained in:
dipatrik10
2026-05-25 13:40:30 +03:00
93 changed files with 5291 additions and 699 deletions

View File

@@ -18,6 +18,11 @@
min-width: 240px;
}
.workload-target-control {
flex: 0 1 220px;
min-width: 200px;
}
.workload-display-select {
width: 100%;
min-height: 38px;
@@ -564,6 +569,297 @@
background: var(--bg-input);
}
.schedule-view-shell {
min-height: calc(100vh - 8.5rem);
display: grid;
grid-template-rows: auto auto minmax(420px, 1fr);
gap: 1rem;
}
.schedule-view-control-card,
.schedule-view-results-card,
.schedule-view-table-card {
animation-duration: 0.28s;
}
.schedule-view-control-header,
.schedule-view-results-header,
.schedule-view-table-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.schedule-view-control-header h2,
.schedule-view-results-header h2,
.schedule-view-table-header h2 {
margin-bottom: 0.3rem;
}
.schedule-view-control-grid,
.schedule-view-extra-grid {
display: grid;
gap: 1rem;
align-items: end;
}
.schedule-view-control-grid {
grid-template-columns: minmax(160px, 0.8fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr) minmax(210px, 0.9fr);
margin-top: 1rem;
}
.schedule-view-extra-grid {
grid-template-columns: repeat(3, minmax(180px, 1fr));
margin-top: 0.9rem;
}
.schedule-view-period-control {
min-width: 0;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 500;
}
.schedule-view-period-buttons,
.schedule-view-result-actions,
.schedule-view-day-tabs {
display: flex;
align-items: center;
gap: 0.45rem;
}
.schedule-view-shell .btn-secondary {
min-height: 42px;
padding: 0.65rem 0.9rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
}
.schedule-view-shell .btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent);
color: var(--accent-hover);
transform: translateY(-1px);
}
.schedule-view-shell .btn-secondary:disabled {
cursor: default;
opacity: 0.45;
}
.schedule-view-extra-filters {
margin-top: 0.9rem;
border-top: 1px solid var(--bg-card-border);
padding-top: 0.85rem;
}
.schedule-view-extra-filters summary {
width: fit-content;
color: var(--text-secondary);
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
}
.schedule-view-result-actions {
min-height: 42px;
justify-content: flex-end;
color: var(--text-secondary);
font-size: 0.88rem;
text-align: right;
}
.schedule-view-result-tabs {
display: flex;
gap: 0.55rem;
margin-top: 1rem;
padding-bottom: 0.1rem;
overflow-x: auto;
}
.schedule-view-result-tab {
flex: 0 0 auto;
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.18rem;
min-width: 150px;
max-width: 240px;
padding: 0.65rem 0.85rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font: inherit;
text-align: left;
cursor: pointer;
transition: background var(--transition), border-color var(--transition), transform var(--transition);
}
.schedule-view-result-tab span,
.schedule-view-result-tab small {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-view-result-tab small {
color: var(--text-secondary);
font-size: 0.72rem;
}
.schedule-view-result-tab:hover,
.schedule-view-result-tab.active {
background: color-mix(in srgb, var(--accent) 12%, var(--bg-input));
border-color: var(--accent);
transform: translateY(-1px);
}
.schedule-view-empty-result {
display: inline-flex;
align-items: center;
min-height: 38px;
color: var(--text-secondary);
font-size: 0.88rem;
}
.schedule-view-table-card {
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.schedule-view-table-header {
flex: 0 0 auto;
margin-bottom: 1rem;
}
.schedule-view-table-stage {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.schedule-view-week-panel {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.schedule-view-grid-container {
flex: 1 1 auto;
min-height: 0;
height: 100%;
max-height: none;
}
.schedule-view-matrix-table {
min-width: 980px;
}
.schedule-view-matrix-table th,
.schedule-view-matrix-table td {
min-width: 150px;
height: 172px;
}
.schedule-view-lesson-card .lesson-meta + .lesson-meta {
margin-top: 0.1rem;
}
.schedule-view-state {
min-height: 100%;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.schedule-view-day-tabs {
flex-wrap: wrap;
justify-content: flex-end;
}
.schedule-view-day-tab {
min-width: 42px;
min-height: 34px;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-secondary);
font: inherit;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
}
.schedule-view-day-tab.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 15%, var(--bg-input));
color: var(--text-primary);
}
.schedule-view-mobile-day {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.schedule-view-mobile-day-heading,
.schedule-view-mobile-slot {
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.schedule-view-mobile-day-heading {
padding: 0.8rem 0.9rem;
}
.schedule-view-mobile-day-heading strong,
.schedule-view-mobile-day-heading span,
.schedule-view-mobile-slot-time strong,
.schedule-view-mobile-slot-time span {
display: block;
}
.schedule-view-mobile-day-heading span,
.schedule-view-mobile-slot-time span {
margin-top: 0.25rem;
color: var(--text-secondary);
font-size: 0.78rem;
}
.schedule-view-mobile-slot {
overflow: hidden;
}
.schedule-view-mobile-slot-time {
padding: 0.7rem 0.85rem;
border-bottom: 1px solid var(--bg-card-border);
}
.schedule-view-mobile-slot-content .room-week-unified {
min-height: 124px;
}
/* Настройка scrollbar для контейнера сетки */
.workload-grid-container::-webkit-scrollbar {
width: 8px;
@@ -580,7 +876,14 @@
background: rgba(0, 0, 0, 0.15);
}
@media (max-width: 1180px) {
.schedule-view-control-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.workload-target-control,
.workload-display-control {
min-width: 100%;
}
@@ -596,4 +899,54 @@
.room-grid-container {
height: 520px;
}
.schedule-view-shell {
min-height: auto;
grid-template-rows: auto;
}
.schedule-view-control-header,
.schedule-view-results-header,
.schedule-view-table-header {
display: grid;
grid-template-columns: 1fr;
}
.schedule-view-control-grid,
.schedule-view-extra-grid {
grid-template-columns: 1fr;
}
.schedule-view-period-buttons,
.schedule-view-result-actions,
.schedule-view-day-tabs {
width: 100%;
}
.schedule-view-period-buttons .btn-secondary,
.schedule-view-result-actions .btn-secondary {
flex: 1;
}
.schedule-view-result-actions {
justify-content: stretch;
text-align: center;
}
.schedule-view-result-actions span {
flex: 2;
align-self: center;
}
.schedule-view-result-tab {
min-width: 72%;
}
.schedule-view-table-card {
min-height: 520px;
}
.schedule-view-day-tabs {
justify-content: flex-start;
}
}

View File

@@ -104,6 +104,111 @@
opacity: 0.5;
}
.form-group select,
.filter-row select,
.schedule-view-filter-grid select {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.95rem;
outline: none;
transition: all var(--transition);
}
.form-group select:focus,
.filter-row select:focus,
.schedule-view-filter-grid select:focus {
background: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--accent-glow);
}
.stack-form {
display: grid;
gap: 1rem;
}
.stack-form textarea {
width: 100%;
min-height: 150px;
resize: vertical;
padding: 0.75rem 1rem;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.95rem;
outline: none;
transition: all var(--transition);
}
.stack-form textarea:focus {
background: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--accent-glow);
}
.schedule-view-filter-grid {
display: grid;
grid-template-columns: repeat(4, minmax(170px, 1fr));
gap: 1rem;
align-items: end;
}
.schedule-view-metrics,
.department-workspace-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.metric-grid {
display: grid;
gap: 0.75rem;
}
.metric-card,
.department-comment-item {
padding: 0.85rem;
background: var(--bg-input);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
}
.metric-card strong,
.department-comment-item strong {
display: block;
margin-bottom: 0.35rem;
color: var(--text-primary);
}
.metric-card span,
.metric-card small,
.department-comment-item span {
display: block;
color: var(--text-secondary);
}
.department-workload-list {
margin-top: 1rem;
}
.department-comment-list {
display: grid;
gap: 0.75rem;
margin-top: 1rem;
}
.department-comment-item p {
margin-top: 0.5rem;
color: var(--text-primary);
}
.schedule-section-label {
display: block;
font-size: 0.78rem;
@@ -578,6 +683,12 @@
}
@media (max-width: 1180px) {
.schedule-view-filter-grid,
.schedule-view-metrics,
.department-workspace-grid {
grid-template-columns: 1fr;
}
.schedule-hours-grid {
grid-template-columns: 1fr;
}

View File

@@ -72,6 +72,11 @@
overflow: hidden;
}
.nav-item[hidden],
.settings-menu-item[hidden] {
display: none !important;
}
.nav-item::before {
content: '';
position: absolute;
@@ -411,4 +416,4 @@
visibility: visible;
pointer-events: auto;
}
}
}

View File

@@ -109,6 +109,25 @@
</svg>
<span>Дисциплины</span>
</a>
<a href="#" class="nav-item" data-tab="department-workspace">
<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 21h18" />
<path d="M5 21V7l7-4 7 4v14" />
<path d="M9 21v-6h6v6" />
<path d="M9 9h.01" />
<path d="M15 9h.01" />
</svg>
<span>Кабинет кафедры</span>
</a>
<a href="#" class="nav-item" data-tab="schedule-view">
<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="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
<span>Просмотр расписаний</span>
</a>
<a href="#" class="nav-item" data-tab="schedule">
<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="4" width="18" height="18" rx="2" ry="2"></rect>
@@ -134,7 +153,7 @@
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
<span>Загруженность аудиторий</span>
<span>Загруженность</span>
</a>
<a href="#" class="nav-item" data-tab="database">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">

View File

@@ -9,6 +9,16 @@ export function isAuthenticatedAsAdmin() {
return token && role === 'ADMIN';
}
export function isAuthenticatedAsRole(expectedRole) {
const role = localStorage.getItem('role');
return token && role === expectedRole;
}
export function isAuthenticatedAsAny(roles) {
const role = localStorage.getItem('role');
return token && roles.includes(role);
}
function getHeaders(contentType = 'application/json') {
const headers = {
'Authorization': `Bearer ${token}`
@@ -31,9 +41,6 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
const response = await fetch(endpoint, options);
// Si status is 401 or 403, we should probably redirect to login,
// but for now let's just throw an error or handle it in the view.
let data;
try {
data = await response.json();
@@ -48,7 +55,6 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
return data;
}
// Shortcut methods
export const api = {
get: (url) => apiFetch(url, 'GET'),
post: (url, body) => apiFetch(url, 'POST', body),

View File

@@ -3,12 +3,14 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
}
import { isAuthenticatedAsAdmin } from './api.js';
import { isAuthenticatedAsAny } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
// Auth check
if (!isAuthenticatedAsAdmin()) {
const AUTHORIZED_ROLES = ['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT', 'SCHEDULE_VIEWER'];
const currentRole = localStorage.getItem('role');
if (!isAuthenticatedAsAny(AUTHORIZED_ROLES)) {
window.location.href = '/';
}
@@ -30,21 +32,44 @@ 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";
import {initScheduleView} from "./views/schedule-view.js";
import {initDepartmentWorkspace} from "./views/department-workspace.js";
// Configuration
const ROUTES = {
users: { title: 'Управление пользователями', file: 'views/users.html', init: initUsers },
groups: { title: 'Управление группами', file: 'views/groups.html', init: initGroups },
'edu-forms': { title: 'Формы обучения', file: 'views/edu-forms.html', init: initEduForms },
equipments: { title: 'Оборудование', file: 'views/equipments.html', init: initEquipments },
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 },
users: { title: 'Управление пользователями', file: '/admin/views/users.html', init: initUsers },
groups: { title: 'Управление группами', file: '/admin/views/groups.html', init: initGroups },
'edu-forms': { title: 'Формы обучения', file: '/admin/views/edu-forms.html', init: initEduForms },
equipments: { title: 'Оборудование', file: '/admin/views/equipments.html', init: initEquipments },
classrooms: { title: 'Аудитории', file: '/admin/views/classrooms.html', init: initClassrooms },
subjects: { title: 'Дисциплины и преподаватели', file: '/admin/views/subjects.html', init: initSubjects },
'department-workspace': { title: 'Кабинет кафедры', file: '/admin/views/department-workspace.html', init: initDepartmentWorkspace },
'schedule-view': { title: 'Просмотр расписаний', file: '/admin/views/schedule-view.html', init: initScheduleView },
schedule: { title: 'Расписание занятий', file: '/admin/views/schedule.html', init: initSchedule },
'academic-calendar': { title: 'Календарный учебный график', file: '/admin/views/academic-calendar.html', init: initAcademicCalendar },
'auditorium-workload': { title: 'Загруженность', file: '/admin/views/auditorium-workload.html', init: initAuditoriumWorkload },
database: { title: 'База данных', file: '/admin/views/database.html', init: initDatabase },
'departments-data': { title: 'Создание кафедры/специальности', file: '/admin/views/departments-data.html', init: initDepartmentsData },
profiles: { title: 'Профили обучения', file: '/admin/views/profiles.html', init: initProfiles },
};
const ROLE_NAVIGATION = {
ADMIN: {
defaultTab: 'users',
tabs: Object.keys(ROUTES)
},
EDUCATION_OFFICE: {
defaultTab: 'schedule-view',
tabs: ['schedule-view', 'schedule', 'academic-calendar', 'auditorium-workload', 'classrooms', 'equipments']
},
DEPARTMENT: {
defaultTab: 'department-workspace',
tabs: ['department-workspace', 'schedule-view']
},
SCHEDULE_VIEWER: {
defaultTab: 'schedule-view',
tabs: ['schedule-view']
}
};
let currentTab = null;
@@ -59,15 +84,13 @@ const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main');
// Initial auth check
if (!isAuthenticatedAsAdmin()) {
window.location.href = '/';
}
const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER;
const allowedTabs = new Set(roleNavigation.tabs);
// Setup Global Effects
applyRippleEffect();
closeAllDropdownsOnOutsideClick();
configureRoleInterface();
// Init sidebar state from localStorage on load
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
@@ -136,7 +159,7 @@ navItems.forEach(item => {
});
async function switchTab(tab) {
if (currentTab === tab || !ROUTES[tab]) return;
if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
// UI Update
navItems.forEach(n => n.classList.remove('active'));
@@ -147,7 +170,7 @@ async function switchTab(tab) {
try {
appContent.innerHTML = '<div class="loading-row">Загрузка...</div>';
const response = await fetch(ROUTES[tab].file);
if (!response.ok) throw new Error('Failed to load view');
if (!response.ok) throw new Error('Не удалось загрузить вкладку');
const html = await response.text();
appContent.innerHTML = html;
@@ -168,5 +191,24 @@ async function switchTab(tab) {
sidebarOverlay.classList.remove('open');
}
// Load default tab
switchTab('users');
function configureRoleInterface() {
navItems.forEach(item => {
item.hidden = !allowedTabs.has(item.dataset.tab);
});
const settingsLink = document.querySelector('.settings-menu a[href="/admin/settings/"]');
if (settingsLink) {
settingsLink.hidden = !['ADMIN', 'EDUCATION_OFFICE'].includes(currentRole);
}
const firstAllowedTitle = {
ADMIN: 'Magistr',
EDUCATION_OFFICE: 'Учебный отдел',
DEPARTMENT: 'Кафедра',
SCHEDULE_VIEWER: 'Расписания'
}[currentRole] || 'Magistr';
document.querySelector('.logo span').textContent = firstAllowedTitle;
}
const hashTab = window.location.hash.replace('#', '');
switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab);

View File

@@ -3,9 +3,44 @@ import { escapeHtml, initMultiSelect, showAlert, hideAlert } from '../utils.js';
const NO_BUILDING = '__no_building__';
const DISPLAY_ALL = 'all';
const TARGET_CLASSROOMS = 'classrooms';
const TARGET_TEACHERS = 'teachers';
const TARGET_DEPARTMENTS = 'departments';
const ROOM_PREFIX = 'room:';
const TEACHER_PREFIX = 'teacher:';
const DEPARTMENT_PREFIX = 'department:';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const WORKLOAD_TARGETS = {
[TARGET_CLASSROOMS]: {
allLabel: 'Все аудитории',
emptyLabel: 'Нет аудиторий по выбранным фильтрам',
rowLabel: 'Аудитория',
summaryLabel: 'Аудиторий',
periodTitle: 'Нагрузка аудитории по неделям',
selectError: 'Выберите аудиторию',
prefix: ROOM_PREFIX
},
[TARGET_TEACHERS]: {
allLabel: 'Все преподаватели',
emptyLabel: 'Нет преподавателей',
rowLabel: 'Преподаватель',
summaryLabel: 'Преподавателей',
periodTitle: 'Нагрузка преподавателя по неделям',
selectError: 'Выберите преподавателя',
prefix: TEACHER_PREFIX
},
[TARGET_DEPARTMENTS]: {
allLabel: 'Все кафедры',
emptyLabel: 'Нет кафедр',
rowLabel: 'Кафедра',
summaryLabel: 'Кафедр',
periodTitle: 'Нагрузка кафедры по неделям',
selectError: 'Выберите кафедру',
prefix: DEPARTMENT_PREFIX
}
};
const DAY_SHORT_LABELS = {
1: 'Пн',
2: 'Вт',
@@ -43,6 +78,7 @@ const CAPACITY_BUCKETS = [
export async function initAuditoriumWorkload() {
const dateInput = document.getElementById('workload-date');
const refreshButton = document.getElementById('workload-refresh');
const targetSelect = document.getElementById('workload-target-select');
const displaySelect = document.getElementById('workload-display-select');
const headerRow = document.getElementById('workload-header-row');
const tbody = document.getElementById('workload-tbody');
@@ -55,14 +91,18 @@ export async function initAuditoriumWorkload() {
const dateLabel = dateInput?.closest('.form-group')?.querySelector('label');
let classrooms = [];
let teachers = [];
let departments = [];
let timeSlots = [];
let equipments = [];
let groups = [];
let semesters = [];
let lessonsByRoomSlot = new Map();
let lessonsByEntitySlot = new Map();
let failedGroupLoads = 0;
let currentTarget = TARGET_CLASSROOMS;
let currentView = DISPLAY_ALL;
let selectedRoomId = null;
let selectedEntityId = null;
setDefaultDate();
initFilters();
@@ -84,6 +124,16 @@ export async function initAuditoriumWorkload() {
function bindEvents() {
refreshButton?.addEventListener('click', loadCurrentView);
dateInput?.addEventListener('change', loadCurrentView);
targetSelect?.addEventListener('change', () => {
currentTarget = targetSelect.value || TARGET_CLASSROOMS;
currentView = DISPLAY_ALL;
selectedRoomId = null;
selectedEntityId = null;
populateDisplayOptions();
applyDisplaySelection();
syncDisplayMode();
loadCurrentView();
});
displaySelect?.addEventListener('change', () => {
applyDisplaySelection();
syncDisplayMode();
@@ -106,25 +156,30 @@ export async function initAuditoriumWorkload() {
}
async function loadInitialData() {
setTableLoading('Загрузка аудиторий и расписания...');
setTableLoading('Загрузка справочников и расписания...');
try {
const [loadedClassrooms, loadedEquipments, loadedGroups, academicYears] = await Promise.all([
const [loadedClassrooms, loadedEquipments, loadedGroups, academicYears, loadedTeachers, loadedDepartments] = await Promise.all([
api.get('/api/classrooms'),
api.get('/api/equipments'),
api.get('/api/groups'),
api.get('/api/admin/calendar/years').catch(() => [])
api.get('/api/admin/calendar/years').catch(() => []),
api.get('/api/users/teachers'),
api.get('/api/departments')
]);
classrooms = [...loadedClassrooms].sort((a, b) => naturalCompare(a.name, b.name));
teachers = [...loadedTeachers].sort((a, b) => naturalCompare(userName(a), userName(b)));
departments = [...loadedDepartments].sort((a, b) => naturalCompare(departmentName(a), departmentName(b)));
equipments = loadedEquipments;
groups = loadedGroups;
semesters = (academicYears || []).flatMap(year => year.semesters || []);
currentTarget = targetSelect?.value || TARGET_CLASSROOMS;
populateFilters();
populateDisplayOptions();
applyDisplaySelection();
syncDisplayMode();
await loadCurrentView();
} catch (error) {
const message = error.message || 'Ошибка загрузки данных загруженности аудиторий';
const message = error.message || 'Ошибка загрузки данных загруженности';
setTableError(message);
setRoomError(message);
}
@@ -137,10 +192,10 @@ export async function initAuditoriumWorkload() {
if (currentView === DISPLAY_ALL) {
await loadScheduleForSelectedDate();
} else {
await loadRoomWorkload();
await loadSelectedEntityWorkload();
}
} catch (error) {
const message = error.message || 'Ошибка загрузки загруженности аудиторий';
const message = error.message || 'Ошибка загрузки загруженности';
if (currentView === DISPLAY_ALL) {
setTableError(message);
} else {
@@ -162,9 +217,9 @@ export async function initAuditoriumWorkload() {
timeSlots = await api.get(`/api/admin/time-slots/effective?date=${encodeURIComponent(date)}`);
if (!groups.length) {
lessonsByRoomSlot = new Map();
lessonsByEntitySlot = new Map();
renderGrid();
showAlert('workload-alert', 'Группы не созданы, занятость аудиторий пуста', 'error');
showAlert('workload-alert', 'Группы не созданы, загруженность пуста', 'error');
return;
}
@@ -176,7 +231,7 @@ export async function initAuditoriumWorkload() {
.filter(response => response.status === 'fulfilled')
.flatMap(response => response.value || []);
lessonsByRoomSlot = groupLessonsByRoomSlot(lessons);
lessonsByEntitySlot = groupLessonsByEntitySlot(lessons);
renderGrid();
if (failedGroupLoads > 0) {
@@ -184,30 +239,31 @@ export async function initAuditoriumWorkload() {
}
}
async function loadRoomWorkload() {
async function loadSelectedEntityWorkload() {
const date = dateInput?.value;
const room = selectedRoom();
const entity = selectedEntity();
const target = targetConfig();
if (!date) {
showAlert('workload-alert', 'Выберите дату', 'error');
setRoomError('Выберите дату для расчёта двухнедельного периода');
return;
}
if (!room) {
showAlert('workload-alert', 'Выберите аудиторию', 'error');
setRoomError('Выберите аудиторию в поле «Отображение»');
if (!entity) {
showAlert('workload-alert', target.selectError, 'error');
setRoomError(`${target.selectError} в поле «Отображение»`);
return;
}
hideAlert('workload-alert');
setRoomLoading('Загрузка расписания аудитории на две недели...');
setRoomLoading('Загрузка расписания на две недели...');
const range = twoWeekRange(date);
const dates = datesBetween(range.startDate, range.endDate);
const [slotLoad, lessonLoad] = await Promise.all([
loadSlotsForDates(dates),
loadRoomLessons(range, room.id)
loadEntityLessons(range, entity.id)
]);
renderRoomWorkload(room, range, dates, slotLoad.slotsByDate, lessonLoad.lessons, lessonLoad.lessonsByDateSlot, {
renderEntityWorkload(entity, range, dates, slotLoad.slotsByDate, lessonLoad.lessons, lessonLoad.lessonsByDateSlot, {
failedSlotLoads: slotLoad.failedLoads,
failedGroupLoads: lessonLoad.failedLoads,
parityLessons: lessonLoad.parityLessons
@@ -236,7 +292,7 @@ export async function initAuditoriumWorkload() {
};
}
async function loadRoomLessons(range, roomId) {
async function loadEntityLessons(range, entityId) {
if (!groups.length) {
return {
lessons: [],
@@ -252,9 +308,8 @@ export async function initAuditoriumWorkload() {
const parityLessons = responses
.filter(response => response.status === 'fulfilled')
.flatMap(response => response.value || []);
const lessons = uniqueLessons(
parityLessons.filter(lesson => sameId(lesson.classroomId, roomId))
);
const lessons = uniqueLessons(parityLessons)
.filter(lesson => entityIdsForLesson(lesson).some(id => sameId(id, entityId)));
const lessonsByDateSlot = new Map();
lessons.forEach(lesson => {
const slotKey = lessonSlotKey(lesson);
@@ -311,12 +366,11 @@ export async function initAuditoriumWorkload() {
function populateDisplayOptions() {
if (!displaySelect) return;
const selectedValue = displaySelect.value;
const target = targetConfig();
displaySelect.innerHTML = '';
displaySelect.append(new Option('Все аудитории', DISPLAY_ALL));
classrooms.forEach(room => {
const details = [room.building, room.floor ? `${room.floor} этаж` : null].filter(Boolean).join(', ');
const suffix = details ? ` · ${details}` : '';
displaySelect.append(new Option(`${room.name}${suffix}`, `${ROOM_PREFIX}${room.id}`));
displaySelect.append(new Option(target.allLabel, DISPLAY_ALL));
allEntities().forEach(entity => {
displaySelect.append(new Option(entityOptionLabel(entity), `${target.prefix}${entity.id}`));
});
if (selectedValue && Array.from(displaySelect.options).some(option => option.value === selectedValue)) {
displaySelect.value = selectedValue;
@@ -325,19 +379,24 @@ export async function initAuditoriumWorkload() {
function applyDisplaySelection() {
const value = displaySelect?.value || DISPLAY_ALL;
if (value.startsWith(ROOM_PREFIX)) {
currentView = 'room';
selectedRoomId = value.slice(ROOM_PREFIX.length);
selectedRoomId = null;
selectedEntityId = null;
const target = targetConfig();
if (value.startsWith(target.prefix)) {
currentView = 'detail';
selectedEntityId = value.slice(target.prefix.length);
if (currentTarget === TARGET_CLASSROOMS) {
selectedRoomId = selectedEntityId;
}
return;
}
currentView = DISPLAY_ALL;
selectedRoomId = null;
}
function syncDisplayMode() {
const roomMode = currentView !== DISPLAY_ALL;
overviewFilters.forEach(filter => {
filter.hidden = roomMode;
filter.hidden = roomMode || currentTarget !== TARGET_CLASSROOMS;
});
if (overviewBlock) overviewBlock.hidden = roomMode;
if (roomBlock) roomBlock.hidden = !roomMode;
@@ -346,9 +405,39 @@ export async function initAuditoriumWorkload() {
}
}
function selectedRoom() {
if (!selectedRoomId) return null;
return classrooms.find(room => sameId(room.id, selectedRoomId)) || null;
function selectedEntity() {
const id = selectedEntityId || selectedRoomId;
if (!id) return null;
return allEntities().find(entity => sameId(entity.id, id)) || null;
}
function targetConfig() {
return WORKLOAD_TARGETS[currentTarget] || WORKLOAD_TARGETS[TARGET_CLASSROOMS];
}
function allEntities() {
if (currentTarget === TARGET_TEACHERS) return teachers;
if (currentTarget === TARGET_DEPARTMENTS) return departments;
return classrooms;
}
function visibleEntities() {
const source = currentTarget === TARGET_CLASSROOMS ? filteredClassrooms() : allEntities();
if (!selectedEntityId) return source;
return source.filter(entity => sameId(entity.id, selectedEntityId));
}
function entityOptionLabel(entity) {
if (currentTarget === TARGET_TEACHERS) {
const details = [entity.jobTitle, entity.departmentName].filter(Boolean).join(', ');
return details ? `${userName(entity)} · ${details}` : userName(entity);
}
if (currentTarget === TARGET_DEPARTMENTS) {
const code = entity.departmentCode ? `код ${entity.departmentCode}` : '';
return code ? `${departmentName(entity)} · ${code}` : departmentName(entity);
}
const details = [entity.building, entity.floor ? `${entity.floor} этаж` : null].filter(Boolean).join(', ');
return details ? `${entity.name} · ${details}` : entity.name;
}
function buildingOptions() {
@@ -394,7 +483,7 @@ export async function initAuditoriumWorkload() {
function uniqueLessons(lessons) {
const unique = new Map();
lessons.forEach(lesson => {
if (!lesson.classroomId || !lesson.timeSlotId) return;
if (!lesson.timeSlotId) return;
const key = [
lesson.date,
lesson.scheduleRuleSlotId || lesson.scheduleRuleId || '',
@@ -422,12 +511,14 @@ export async function initAuditoriumWorkload() {
return Array.from(unique.values());
}
function groupLessonsByRoomSlot(lessons) {
function groupLessonsByEntitySlot(lessons) {
const grouped = new Map();
uniqueLessons(lessons).forEach(lesson => {
const key = roomSlotKey(lesson.classroomId, lesson.timeSlotId);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
entityIdsForLesson(lesson).forEach(entityId => {
const key = entitySlotKey(entityId, lesson.timeSlotId);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(lesson);
});
});
grouped.forEach(items => {
items.sort((a, b) => naturalCompare(a.subjectName, b.subjectName));
@@ -435,31 +526,43 @@ export async function initAuditoriumWorkload() {
return grouped;
}
function entityIdsForLesson(lesson) {
if (currentTarget === TARGET_TEACHERS) {
return lesson.teacherId ? [lesson.teacherId] : [];
}
if (currentTarget === TARGET_DEPARTMENTS) {
return departmentIdsForLesson(lesson);
}
return lesson.classroomId ? [lesson.classroomId] : [];
}
function departmentIdsForLesson(lesson) {
const departmentId = teacherDepartmentId(lesson.teacherId);
return departmentId == null ? [] : [departmentId];
}
function renderGrid() {
const visibleClassrooms = filteredClassrooms();
const rows = visibleEntities();
const target = targetConfig();
renderHeader();
tbody.innerHTML = '';
if (!visibleClassrooms.length) {
tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">Нет аудиторий по выбранным фильтрам</td></tr>`;
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="${timeSlots.length + 1}" class="loading-row">${escapeHtml(target.emptyLabel)}</td></tr>`;
renderSummary(0, 0, 0);
return;
}
visibleClassrooms.forEach(room => {
rows.forEach(entity => {
const tr = document.createElement('tr');
const roomCell = document.createElement('td');
roomCell.className = `axis-cell${room.isAvailable === false ? ' room-unavailable' : ''}`;
roomCell.innerHTML = `
<strong>${escapeHtml(room.name)}</strong>
<span>${room.capacity ? `${escapeHtml(room.capacity)} мест` : 'Вместимость не указана'}</span>
${room.building ? `<small>${escapeHtml(room.building)}${room.floor ? `, ${escapeHtml(room.floor)} этаж` : ''}</small>` : ''}
`;
tr.appendChild(roomCell);
const entityCell = document.createElement('td');
entityCell.className = `axis-cell${currentTarget === TARGET_CLASSROOMS && entity.isAvailable === false ? ' room-unavailable' : ''}`;
entityCell.innerHTML = entityAxisHtml(entity);
tr.appendChild(entityCell);
timeSlots.forEach(slot => {
const td = document.createElement('td');
const lessons = lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || [];
const lessons = lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || [];
td.className = lessons.length ? 'workload-busy-cell' : 'workload-free-cell';
td.innerHTML = lessons.length
? lessons.map(renderLessonCard).join('')
@@ -470,15 +573,16 @@ export async function initAuditoriumWorkload() {
tbody.appendChild(tr);
});
const occupiedCells = countOccupiedCells(visibleClassrooms);
renderSummary(visibleClassrooms.length, occupiedCells, visibleClassrooms.length * timeSlots.length);
const occupiedCells = countOccupiedCells(rows);
renderSummary(rows.length, occupiedCells, rows.length * timeSlots.length);
}
function renderHeader() {
const target = targetConfig();
headerRow.innerHTML = `
<th class="top-left-cell">
<span class="top-label">Время</span>
<span class="bottom-label">Аудитория</span>
<span class="bottom-label">${escapeHtml(target.rowLabel)}</span>
</th>
`;
@@ -493,10 +597,51 @@ export async function initAuditoriumWorkload() {
});
}
function renderRoomWorkload(room, range, dates, slotsByDate, lessons, lessonsByDateSlot, failures) {
function entityAxisHtml(entity) {
if (currentTarget === TARGET_TEACHERS) {
return `
<strong>${escapeHtml(userName(entity))}</strong>
<span>${escapeHtml(entity.jobTitle || 'Должность не указана')}</span>
${entity.departmentName ? `<small>${escapeHtml(entity.departmentName)}</small>` : ''}
`;
}
if (currentTarget === TARGET_DEPARTMENTS) {
return `
<strong>${escapeHtml(departmentName(entity))}</strong>
<span>${entity.departmentCode ? `Код: ${escapeHtml(entity.departmentCode)}` : 'Код не указан'}</span>
`;
}
return `
<strong>${escapeHtml(entity.name)}</strong>
<span>${entity.capacity ? `${escapeHtml(entity.capacity)} мест` : 'Вместимость не указана'}</span>
${entity.building ? `<small>${escapeHtml(entity.building)}${entity.floor ? `, ${escapeHtml(entity.floor)} этаж` : ''}</small>` : ''}
`;
}
function entityName(entity) {
if (currentTarget === TARGET_TEACHERS) return userName(entity);
if (currentTarget === TARGET_DEPARTMENTS) return departmentName(entity);
return entity?.name || 'Аудитория без названия';
}
function entitySummaryDetails(entity) {
if (currentTarget === TARGET_TEACHERS) {
return [entity.jobTitle, entity.departmentName].filter(Boolean).join(', ');
}
if (currentTarget === TARGET_DEPARTMENTS) {
return entity.departmentCode ? `код ${entity.departmentCode}` : '';
}
return [
entity.capacity ? `${entity.capacity} мест` : null,
entity.building || null,
entity.floor ? `${entity.floor} этаж` : null
].filter(Boolean).join(', ');
}
function renderEntityWorkload(entity, range, dates, slotsByDate, lessons, lessonsByDateSlot, failures) {
const slotRows = buildSlotRows(dates, slotsByDate, lessons);
const dateGrid = buildDateGridByDay(dates, failures.parityLessons || lessons);
renderRoomSummary(room, range, dates, slotsByDate, lessonsByDateSlot, failures, dateGrid.unknownParityDates);
renderRoomSummary(entity, range, dates, slotsByDate, lessonsByDateSlot, failures, dateGrid.unknownParityDates);
if (!slotRows.length) {
roomTables.innerHTML = '<div class="loading-row room-workload-state">За выбранный период нет временных слотов</div>';
@@ -506,16 +651,12 @@ export async function initAuditoriumWorkload() {
roomTables.innerHTML = renderRoomCombinedWeekTable(dateGrid.datesByDay, slotRows, slotsByDate, lessonsByDateSlot);
}
function renderRoomSummary(room, range, dates, slotsByDate, lessonsByDateSlot, failures, unknownParityDates) {
function renderRoomSummary(entity, range, dates, slotsByDate, lessonsByDateSlot, failures, unknownParityDates) {
if (!roomSummary) return;
const totalCells = countAvailableRoomCells(dates, slotsByDate, lessonsByDateSlot);
const occupiedCells = countOccupiedRoomCells(lessonsByDateSlot);
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
const roomMeta = [
room.capacity ? `${room.capacity} мест` : null,
room.building || null,
room.floor ? `${room.floor} этаж` : null
].filter(Boolean).join(', ');
const details = entitySummaryDetails(entity);
const warnings = [];
if (failures.failedGroupLoads) warnings.push(`ошибок групп: ${failures.failedGroupLoads}`);
if (failures.failedSlotLoads) warnings.push(`ошибок сеток: ${failures.failedSlotLoads}`);
@@ -523,8 +664,8 @@ export async function initAuditoriumWorkload() {
roomSummary.innerHTML = `
<div class="room-summary-main">
<strong>${escapeHtml(room.name)}</strong>
${roomMeta ? `<span>${escapeHtml(roomMeta)}</span>` : ''}
<strong>${escapeHtml(entityName(entity))}</strong>
${details ? `<span>${escapeHtml(details)}</span>` : ''}
</div>
<span class="room-summary-chip">${escapeHtml(formatDate(range.startDate))} - ${escapeHtml(formatDate(range.endDate))}</span>
<span class="room-summary-chip">Занято: ${occupiedCells} из ${totalCells} (${percent}%)</span>
@@ -555,7 +696,7 @@ export async function initAuditoriumWorkload() {
return `
<section class="room-week-panel">
<div class="room-week-heading">
<h3>Нагрузка аудитории по неделям</h3>
<h3>${escapeHtml(targetConfig().periodTitle)}</h3>
<span>${escapeHtml(roomWeekCaption(datesByDay))}</span>
</div>
<div class="workload-grid-container room-grid-container">
@@ -852,18 +993,19 @@ export async function initAuditoriumWorkload() {
showAlert('workload-alert', message, 'error');
}
function renderSummary(roomCount, occupiedCells, totalCells) {
function renderSummary(entityCount, occupiedCells, totalCells) {
if (!summary) return;
const percent = totalCells ? Math.round((occupiedCells / totalCells) * 100) : 0;
const date = dateInput?.value ? formatDate(dateInput.value) : '-';
const day = dateInput?.value ? dayShort(selectedIsoDay()) : '-';
const failed = failedGroupLoads ? `, ошибок загрузки групп: ${failedGroupLoads}` : '';
summary.textContent = `Дата: ${date}, ${day}. Аудиторий: ${roomCount}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`;
const selected = selectedEntityId ? ', выбран один объект' : '';
summary.textContent = `Дата: ${date}, ${day}. ${targetConfig().summaryLabel}: ${entityCount}${selected}. Занятых слотов: ${occupiedCells} из ${totalCells} (${percent}%)${failed}.`;
}
function countOccupiedCells(rooms) {
return rooms.reduce((count, room) => count + timeSlots.filter(slot =>
(lessonsByRoomSlot.get(roomSlotKey(room.id, slot.id)) || []).length > 0
function countOccupiedCells(entities) {
return entities.reduce((count, entity) => count + timeSlots.filter(slot =>
(lessonsByEntitySlot.get(entitySlotKey(entity.id, slot.id)) || []).length > 0
).length, 0);
}
@@ -883,8 +1025,8 @@ export async function initAuditoriumWorkload() {
return Array.from(lessonsByDateSlot.values()).filter(items => items.length > 0).length;
}
function roomSlotKey(roomId, timeSlotId) {
return `${roomId}:${timeSlotId}`;
function entitySlotKey(entityId, timeSlotId) {
return `${entityId}:${timeSlotId}`;
}
function dateSlotKey(date, slotKey) {
@@ -905,6 +1047,22 @@ export async function initAuditoriumWorkload() {
return room.building ? String(room.building) : NO_BUILDING;
}
function teacherDepartmentId(teacherId) {
const teacher = teachers.find(item => sameId(item.id, teacherId));
if (!teacher) return null;
if (teacher.departmentId != null) return teacher.departmentId;
if (!teacher.departmentName) return null;
return departments.find(department => departmentName(department) === teacher.departmentName)?.id ?? null;
}
function userName(user) {
return user?.fullName || user?.username || 'Преподаватель без имени';
}
function departmentName(department) {
return department?.departmentName || department?.name || 'Кафедра без названия';
}
function mergeNames(first = [], second = []) {
return Array.from(new Set([...first, ...second].filter(Boolean))).sort((a, b) => naturalCompare(a, b));
}

View File

@@ -31,7 +31,7 @@ export async function initClassrooms() {
async function loadClassrooms() {
try {
const classrooms = await api.get('/api/classrooms');
const classrooms = await api.get('/api/classrooms?includeArchived=true');
renderClassrooms(classrooms);
} catch (e) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки</td></tr>';
@@ -57,7 +57,7 @@ export async function initClassrooms() {
<td>
<div class="status-cell">
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
${c.isAvailable ? 'Доступна' : 'Не доступна'}
${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')}
</span>
<button class="btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
@@ -66,7 +66,9 @@ export async function initClassrooms() {
</td>
<td style="text-align: right;">
<button class="btn-edit-classroom" data-id="${c.id}">Изменить</button>
<button class="btn-delete" data-id="${c.id}" style="margin-left: 0.5rem;">Удалить</button>
${c.status === 'ARCHIVED'
? `<button class="btn-restore-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Восстановить</button>`
: `<button class="btn-delete" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
</td>
</tr>`;
}).join('');
@@ -100,11 +102,19 @@ export async function initClassrooms() {
const btnEdit = e.target.closest('.btn-edit-classroom');
if (btnDelete) {
if (!confirm('Удалить аудиторию?')) return;
if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return;
try {
await api.delete('/api/classrooms/' + btnDelete.dataset.id);
loadClassrooms();
} catch (err) { alert('Ошибка удаления'); }
} catch (err) { alert('Ошибка архивирования'); }
}
const btnRestore = e.target.closest('.btn-restore-classroom');
if (btnRestore) {
try {
await api.post('/api/classrooms/' + btnRestore.dataset.id + '/restore', {});
loadClassrooms();
} catch (err) { alert('Ошибка восстановления аудитории'); }
}
if (btnToggleStatus) {
@@ -124,7 +134,7 @@ export async function initClassrooms() {
async function openEditClassroomModal(id) {
try {
// Can optimize by using already loaded classrooms, but fetch is safer to get fresh data
const classrooms = await api.get('/api/classrooms');
const classrooms = await api.get('/api/classrooms?includeArchived=true');
editingClassroomData = classrooms.find(c => c.id == id);
if (!editingClassroomData) return;

View File

@@ -0,0 +1,263 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
export async function initDepartmentWorkspace() {
const role = localStorage.getItem('role');
const userDepartmentId = localStorage.getItem('departmentId');
const departmentField = document.getElementById('department-workspace-department-field');
const departmentSelect = document.getElementById('department-workspace-department');
const startInput = document.getElementById('department-workspace-start');
const endInput = document.getElementById('department-workspace-end');
const refreshButton = document.getElementById('department-workspace-refresh');
const importForm = document.getElementById('department-subject-import-form');
const subjectsTbody = document.getElementById('department-subjects-tbody');
const subjectCount = document.getElementById('department-subject-count');
const commentsCard = document.getElementById('department-comments-card');
const commentsTitle = document.getElementById('department-comments-title');
const commentsList = document.getElementById('department-comment-list');
const commentsForm = document.getElementById('department-comment-form');
const commentsSubjectInput = document.getElementById('department-comment-subject-id');
const commentsTextInput = document.getElementById('department-comment-text');
const commentsCloseButton = document.getElementById('department-comments-close');
setDefaultDates(startInput, endInput);
refreshButton?.addEventListener('click', loadWorkspace);
departmentSelect?.addEventListener('change', loadWorkspace);
startInput?.addEventListener('change', loadWorkload);
endInput?.addEventListener('change', loadWorkload);
importForm?.addEventListener('submit', importSubjects);
commentsForm?.addEventListener('submit', saveComment);
commentsCloseButton?.addEventListener('click', () => {
commentsCard.hidden = true;
commentsSubjectInput.value = '';
});
subjectsTbody?.addEventListener('click', event => {
const button = event.target.closest('.department-comments-open');
if (button) {
openComments(button.dataset.subjectId, button.dataset.subjectName);
}
});
try {
await loadDepartments(role, userDepartmentId);
if (role === ROLE_DEPARTMENT) {
departmentField.hidden = true;
departmentSelect.value = userDepartmentId || '';
}
await loadWorkspace();
} catch (error) {
showAlert('department-workspace-alert', error.message || 'Ошибка загрузки кабинета кафедры', 'error');
}
async function loadWorkspace() {
hideAlert('department-workspace-alert');
await Promise.all([loadSubjects(), loadTeachers(), loadWorkload()]);
}
async function loadSubjects() {
const departmentId = selectedDepartmentId();
subjectsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
subjectCount.textContent = 'Загрузка...';
try {
const subjects = await api.get(`/api/department/subjects?departmentId=${encodeURIComponent(departmentId || '')}`);
subjectCount.textContent = subjectCountLabel(subjects.length);
if (!subjects.length) {
subjectsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Дисциплины не найдены</td></tr>';
return;
}
subjectsTbody.innerHTML = subjects.map(subject => `
<tr>
<td>${escapeHtml(subject.code || '-')}</td>
<td>${escapeHtml(subject.name || '-')}</td>
<td>${escapeHtml(subject.description || '-')}</td>
<td>
<button type="button" class="btn-edit-classroom department-comments-open"
data-subject-id="${escapeHtml(subject.id)}"
data-subject-name="${escapeHtml(subject.name || '')}">
Открыть
</button>
</td>
</tr>
`).join('');
} catch (error) {
subjectsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки дисциплин')}</td></tr>`;
subjectCount.textContent = '0 дисциплин';
}
}
async function importSubjects(event) {
event.preventDefault();
const alertId = 'department-subject-import-alert';
hideAlert(alertId);
const departmentId = selectedDepartmentId();
const rows = document.getElementById('department-subject-import-text').value
.split('\n')
.map(row => row.trim())
.filter(Boolean)
.map(row => {
const [code, ...nameParts] = row.split(';');
return {
code: (code || '').trim(),
name: nameParts.join(';').trim(),
departmentId: Number(departmentId)
};
})
.filter(item => item.name);
if (!departmentId) {
showAlert(alertId, 'Выберите кафедру', 'error');
return;
}
if (!rows.length) {
showAlert(alertId, 'Добавьте хотя бы одну строку в формате «код; название»', 'error');
return;
}
try {
const result = await api.post('/api/department/subjects/import', rows);
showAlert(alertId, result.message || 'Дисциплины загружены', 'success');
document.getElementById('department-subject-import-text').value = '';
await loadSubjects();
} catch (error) {
showAlert(alertId, error.message || 'Ошибка загрузки дисциплин', 'error');
}
}
async function openComments(subjectId, subjectName) {
commentsCard.hidden = false;
commentsTitle.textContent = `Комментарии: ${subjectName || 'дисциплина'}`;
commentsSubjectInput.value = subjectId;
commentsTextInput.value = '';
hideAlert('department-comment-alert');
commentsList.textContent = 'Загрузка...';
try {
const comments = await api.get(`/api/department/subjects/${subjectId}/comments`);
commentsList.innerHTML = comments.length
? comments.map(comment => `
<article class="department-comment-item">
<strong>${escapeHtml(comment.authorName || 'Автор не указан')}</strong>
<span>${escapeHtml(formatDateTime(comment.createdAt))}</span>
<p>${escapeHtml(comment.comment)}</p>
</article>
`).join('')
: '<div class="loading-row">Комментариев пока нет</div>';
} catch (error) {
commentsList.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки комментариев')}</div>`;
}
}
async function saveComment(event) {
event.preventDefault();
const subjectId = commentsSubjectInput.value;
const comment = commentsTextInput.value.trim();
if (!subjectId) {
showAlert('department-comment-alert', 'Выберите дисциплину', 'error');
return;
}
if (!comment) {
showAlert('department-comment-alert', 'Комментарий не должен быть пустым', 'error');
return;
}
try {
await api.post(`/api/department/subjects/${subjectId}/comments`, { comment });
showAlert('department-comment-alert', 'Комментарий сохранён', 'success');
await openComments(subjectId, commentsTitle.textContent.replace('Комментарии: ', ''));
} catch (error) {
showAlert('department-comment-alert', error.message || 'Ошибка сохранения комментария', 'error');
}
}
async function loadTeachers() {
const container = document.getElementById('department-teachers-list');
container.textContent = 'Загрузка...';
try {
const teachers = await api.get(`/api/department/teachers?departmentId=${encodeURIComponent(selectedDepartmentId() || '')}`);
container.innerHTML = teachers.length
? teachers.map(teacher => `
<article class="metric-card">
<strong>${escapeHtml(teacher.fullName || teacher.username || '-')}</strong>
<span>${escapeHtml(teacher.jobTitle || 'Должность не указана')}</span>
</article>
`).join('')
: '<div class="loading-row">Преподаватели не найдены</div>';
} catch (error) {
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки преподавателей')}</div>`;
}
}
async function loadWorkload() {
const container = document.getElementById('department-workload-list');
const params = new URLSearchParams({
startDate: startInput.value,
endDate: endInput.value
});
if (selectedDepartmentId()) {
params.set('departmentId', selectedDepartmentId());
}
container.textContent = 'Загрузка...';
try {
const workload = await api.get(`/api/workload/teachers?${params}`);
container.innerHTML = workload.length
? workload.map(item => `
<article class="metric-card">
<strong>${escapeHtml(item.name || '-')}</strong>
<span>${escapeHtml(String(item.academicHours ?? 0))} ак. ч.</span>
<small>${escapeHtml(String(item.lessonCount ?? 0))} занятий</small>
</article>
`).join('')
: '<div class="loading-row">Нагрузка не найдена</div>';
} catch (error) {
container.innerHTML = `<div class="loading-row">${escapeHtml(error.message || 'Ошибка расчёта нагрузки')}</div>`;
}
}
function selectedDepartmentId() {
return role === ROLE_DEPARTMENT ? userDepartmentId : departmentSelect.value;
}
}
async function loadDepartments(role, userDepartmentId) {
const departments = await api.get('/api/departments');
fillSelect('department-workspace-department', departments, item => item.id, item => item.departmentName, 'Выберите кафедру');
if (role === ROLE_DEPARTMENT && userDepartmentId) {
document.getElementById('department-workspace-department').value = userDepartmentId;
} else if (!document.getElementById('department-workspace-department').value && departments.length) {
document.getElementById('department-workspace-department').value = departments[0].id;
}
}
function fillSelect(id, items, valueFn, labelFn, emptyLabel) {
const select = document.getElementById(id);
if (!select) return;
select.innerHTML = `<option value="">${escapeHtml(emptyLabel)}</option>` +
(items || []).map(item => `<option value="${escapeHtml(valueFn(item))}">${escapeHtml(labelFn(item))}</option>`).join('');
}
function setDefaultDates(startInput, endInput) {
const today = new Date();
const nextWeek = new Date();
nextWeek.setDate(today.getDate() + 7);
startInput.value = toIsoDate(today);
endInput.value = toIsoDate(nextWeek);
}
function toIsoDate(date) {
return date.toISOString().slice(0, 10);
}
function formatDateTime(value) {
if (!value) return '-';
return String(value).replace('T', ' ').slice(0, 16);
}
function subjectCountLabel(count) {
const tail = count % 100;
if (tail >= 11 && tail <= 14) return `${count} дисциплин`;
if (count % 10 === 1) return `${count} дисциплина`;
if ([2, 3, 4].includes(count % 10)) return `${count} дисциплины`;
return `${count} дисциплин`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,22 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
const ROLE_LABELS = { ADMIN: 'Администратор', TEACHER: 'Преподаватель', STUDENT: 'Студент' };
const ROLE_BADGE = { ADMIN: 'badge-admin', TEACHER: 'badge-teacher', STUDENT: 'badge-student' };
const ROLE_LABELS = {
ADMIN: 'Администратор',
EDUCATION_OFFICE: 'Учебный отдел',
DEPARTMENT: 'Кафедра',
SCHEDULE_VIEWER: 'Просмотр расписаний',
TEACHER: 'Преподаватель',
STUDENT: 'Студент'
};
const ROLE_BADGE = {
ADMIN: 'badge-admin',
EDUCATION_OFFICE: 'badge-admin',
DEPARTMENT: 'badge-teacher',
SCHEDULE_VIEWER: 'badge-student',
TEACHER: 'badge-teacher',
STUDENT: 'badge-student'
};
export async function initUsers() {
const usersTbody = document.getElementById('users-tbody');
@@ -10,7 +24,7 @@ export async function initUsers() {
async function loadUsers() {
try {
const users = await api.get('/api/users');
const users = await api.get('/api/users?includeArchived=true');
renderUsers(users);
} catch (e) {
usersTbody.innerHTML =
@@ -34,7 +48,9 @@ export async function initUsers() {
<td>${escapeHtml(u.departmentName || '-')}</td>
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || escapeHtml(u.role)}</span></td>
<td>
<button class="btn-delete" data-id="${u.id}">Удалить</button>
${u.status === 'ARCHIVED'
? `<button class="btn-restore-user" data-id="${u.id}">Восстановить</button>`
: `<button class="btn-delete" data-id="${u.id}">Архивировать</button>`}
</td>
</tr>
`).join('');
@@ -77,12 +93,24 @@ export async function initUsers() {
const deleteBtn = e.target.closest('.btn-delete');
if (!deleteBtn) return;
if (!confirm('Удалить пользователя?')) return;
if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return;
try {
await api.delete('/api/users/' + deleteBtn.dataset.id);
loadUsers();
} catch (err) {
alert(err.message || 'Ошибка удаления');
alert(err.message || 'Ошибка архивирования');
}
});
usersTbody.addEventListener('click', async (e) => {
const restoreBtn = e.target.closest('.btn-restore-user');
if (!restoreBtn) return;
try {
await api.post('/api/users/' + restoreBtn.dataset.id + '/restore', {});
loadUsers();
} catch (err) {
alert(err.message || 'Ошибка восстановления');
}
});

View File

@@ -72,6 +72,10 @@
overflow: hidden;
}
.nav-item[hidden] {
display: none !important;
}
.nav-item::before {
content: '';
position: absolute;

View File

@@ -4,7 +4,7 @@ import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/drop
// Проверка авторизации
const token = localStorage.getItem('token');
const role = localStorage.getItem('role');
if (!token || role !== 'ADMIN') {
if (!token || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) {
window.location.href = '/';
}
@@ -27,6 +27,13 @@ const ROUTES = {
},
};
const ROLE_TABS = {
ADMIN: ['general', 'time-slots'],
EDUCATION_OFFICE: ['time-slots']
};
const allowedTabs = new Set(ROLE_TABS[role] || []);
const defaultTab = role === 'EDUCATION_OFFICE' ? 'time-slots' : 'general';
let currentTab = null;
// DOM-элементы
@@ -76,6 +83,7 @@ sidebarOverlay.addEventListener('click', () => {
// Навигация
navItems.forEach(item => {
item.hidden = !allowedTabs.has(item.dataset.tab);
item.addEventListener('click', (e) => {
e.preventDefault();
const tab = item.dataset.tab;
@@ -84,7 +92,7 @@ navItems.forEach(item => {
});
async function switchTab(tab) {
if (currentTab === tab || !ROUTES[tab]) return;
if (currentTab === tab || !ROUTES[tab] || !allowedTabs.has(tab)) return;
navItems.forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
@@ -114,4 +122,4 @@ async function switchTab(tab) {
}
// Вкладка по умолчанию
switchTab('general');
switchTab(defaultTab);

View File

@@ -1,10 +1,19 @@
<div class="card">
<div class="card-header-row" style="margin-bottom: 1.5rem;">
<h2>Загруженность аудиторий</h2>
<h2>Загруженность</h2>
<button type="button" class="btn-primary" id="workload-refresh">Обновить</button>
</div>
<div class="filter-row workload-filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;">
<div class="form-group workload-target-control">
<label for="workload-target-select">Что смотреть</label>
<select id="workload-target-select" class="workload-display-select" data-native-select="true">
<option value="classrooms">Аудитории</option>
<option value="teachers">Преподаватели</option>
<option value="departments">Кафедры</option>
</select>
</div>
<div class="form-group workload-display-control">
<label for="workload-display-select">Отображение</label>
<select id="workload-display-select" class="workload-display-select" data-native-select="true">
@@ -74,7 +83,7 @@
<tr id="workload-header-row">
<th class="top-left-cell">
<span class="top-label">Время</span>
<span class="bottom-label">Аудитория</span>
<span class="bottom-label">Сущность</span>
</th>
</tr>
</thead>

View File

@@ -0,0 +1,89 @@
<div class="card">
<div class="card-header-row">
<h2>Рабочая область кафедры</h2>
<button type="button" class="btn-primary" id="department-workspace-refresh">Обновить</button>
</div>
<div class="schedule-view-filter-grid">
<div class="form-group" id="department-workspace-department-field">
<label for="department-workspace-department">Кафедра</label>
<select id="department-workspace-department" data-native-select="true">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="department-workspace-start">Начало периода</label>
<input type="date" id="department-workspace-start">
</div>
<div class="form-group">
<label for="department-workspace-end">Окончание периода</label>
<input type="date" id="department-workspace-end">
</div>
</div>
<div class="form-alert" id="department-workspace-alert" role="alert"></div>
</div>
<div class="department-workspace-grid">
<div class="card create-card">
<div class="card-header-row">
<h2>Загрузка дисциплин</h2>
</div>
<form id="department-subject-import-form" class="stack-form">
<div class="form-group">
<label for="department-subject-import-text">Строки в формате «код; название»</label>
<textarea id="department-subject-import-text" rows="8" placeholder="DB201; Базы данных&#10;AI101; Искусственный интеллект"></textarea>
</div>
<button type="submit" class="btn-primary">Загрузить</button>
<div class="form-alert" id="department-subject-import-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Преподаватели и нагрузка</h2>
</div>
<div class="metric-grid" id="department-teachers-list">Загрузка...</div>
<div class="metric-grid department-workload-list" id="department-workload-list">Загрузка...</div>
</div>
</div>
<div class="card">
<div class="card-header-row">
<h2>Дисциплины кафедры</h2>
<span class="muted" id="department-subject-count">0 дисциплин</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Код</th>
<th>Название</th>
<th>Описание</th>
<th>Комментарии</th>
</tr>
</thead>
<tbody id="department-subjects-tbody">
<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card" id="department-comments-card" hidden>
<div class="card-header-row">
<h2 id="department-comments-title">Комментарии</h2>
<button type="button" class="btn-edit-classroom" id="department-comments-close">Скрыть</button>
</div>
<form id="department-comment-form" class="form-row">
<input type="hidden" id="department-comment-subject-id">
<div class="form-group">
<label for="department-comment-text">Новый комментарий</label>
<input type="text" id="department-comment-text" placeholder="Добавьте примечание по дисциплине">
</div>
<button type="submit" class="btn-primary">Сохранить</button>
</form>
<div class="form-alert" id="department-comment-alert" role="alert"></div>
<div class="department-comment-list" id="department-comment-list">Выберите дисциплину</div>
</div>

View File

@@ -0,0 +1,122 @@
<section class="schedule-view-shell">
<div class="card schedule-view-control-card">
<div class="schedule-view-control-header">
<div>
<h2>Расписания</h2>
<span class="muted" id="schedule-view-period-label">Период не выбран</span>
</div>
<button type="button" class="btn-primary" id="schedule-view-load">Показать</button>
</div>
<div class="schedule-view-control-grid">
<div class="form-group">
<label for="schedule-view-target">Что смотреть</label>
<select id="schedule-view-target" data-native-select="true">
<option value="groups">Группа</option>
<option value="teachers">Преподаватель</option>
<option value="classrooms">Аудитория</option>
<option value="departments">Кафедра</option>
</select>
</div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="groups">
<label for="schedule-view-group">Группа</label>
<select id="schedule-view-group" data-native-select="true">
<option value="">Все группы</option>
</select>
</div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="teachers" hidden>
<label for="schedule-view-teacher">Преподаватель</label>
<select id="schedule-view-teacher" data-native-select="true">
<option value="">Все преподаватели</option>
</select>
</div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="classrooms" hidden>
<label for="schedule-view-classroom">Аудитория</label>
<select id="schedule-view-classroom" data-native-select="true">
<option value="">Все аудитории</option>
</select>
</div>
<div class="form-group schedule-view-entity-field" data-schedule-view-target-field="departments" hidden>
<label for="schedule-view-department">Кафедра</label>
<select id="schedule-view-department" data-native-select="true">
<option value="">Все кафедры</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-date">Дата в периоде</label>
<input type="date" id="schedule-view-date">
</div>
<div class="form-group schedule-view-period-control">
<span class="form-label">Переход</span>
<div class="schedule-view-period-buttons">
<button type="button" class="btn-secondary" id="schedule-view-prev-period" aria-label="Предыдущий период"></button>
<button type="button" class="btn-secondary" id="schedule-view-today">Сегодня</button>
<button type="button" class="btn-secondary" id="schedule-view-next-period" aria-label="Следующий период"></button>
</div>
</div>
</div>
<details class="schedule-view-extra-filters">
<summary>Дополнительные фильтры</summary>
<div class="schedule-view-extra-grid">
<div class="form-group">
<label for="schedule-view-subject">Дисциплина</label>
<select id="schedule-view-subject" data-native-select="true">
<option value="">Все дисциплины</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-lesson-type">Тип занятия</label>
<select id="schedule-view-lesson-type" data-native-select="true">
<option value="">Все типы</option>
</select>
</div>
<div class="form-group">
<label for="schedule-view-parity">Неделя</label>
<select id="schedule-view-parity" data-native-select="true">
<option value="">Любая</option>
<option value="BOTH">Каждая</option>
<option value="ODD">Нечётная</option>
<option value="EVEN">Чётная</option>
</select>
</div>
</div>
</details>
<div class="form-alert" id="schedule-view-alert" role="alert"></div>
</div>
<div class="card schedule-view-results-card">
<div class="schedule-view-results-header">
<div>
<h2>Найденные расписания</h2>
<span class="muted" id="schedule-view-count">0 занятий</span>
</div>
<div class="schedule-view-result-actions">
<button type="button" class="btn-secondary" id="schedule-view-result-prev" aria-label="Предыдущее расписание"></button>
<span id="schedule-view-active-label">Нет выбранного расписания</span>
<button type="button" class="btn-secondary" id="schedule-view-result-next" aria-label="Следующее расписание"></button>
</div>
</div>
<div class="schedule-view-result-tabs" id="schedule-view-result-tabs"></div>
</div>
<div class="card schedule-view-table-card">
<div class="schedule-view-table-header">
<div>
<h2 id="schedule-view-table-title">Расписание</h2>
<span class="muted" id="schedule-view-table-caption">Выберите параметры и нажмите «Показать»</span>
</div>
<div class="schedule-view-day-tabs" id="schedule-view-day-tabs" hidden></div>
</div>
<div class="schedule-view-table-stage" id="schedule-view-tables">
<div class="loading-row schedule-view-state">Загрузка...</div>
</div>
</div>
</section>

View File

@@ -13,11 +13,14 @@
</div>
<div class="form-group">
<label for="new-role">Роль</label>
<select id="new-role">
<option value="STUDENT">Студент</option>
<option value="TEACHER">Преподаватель</option>
<option value="ADMIN">Администратор</option>
</select>
<select id="new-role">
<option value="STUDENT">Студент</option>
<option value="TEACHER">Преподаватель</option>
<option value="DEPARTMENT">Кафедра</option>
<option value="EDUCATION_OFFICE">Учебный отдел</option>
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
<option value="ADMIN">Администратор</option>
</select>
</div>
<div class="form-group">
<label for="new-fullname">ФИО пользователя</label>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Кабинет кафедры</title>
<meta http-equiv="refresh" content="0; url=/admin/#department-workspace">
</head>
<body>
<script>
window.location.replace('/admin/#department-workspace');
</script>
</body>
</html>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Учебный отдел</title>
<meta http-equiv="refresh" content="0; url=/admin/#schedule-view">
</head>
<body>
<script>
window.location.replace('/admin/#schedule-view');
</script>
</body>
</html>