Merge branch 'dynamic_schedule' of https://gitea.zuev.company/Zuev/magistr into dynamic_schedule
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
263
frontend/admin/js/views/department-workspace.js
Normal file
263
frontend/admin/js/views/department-workspace.js
Normal 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} дисциплин`;
|
||||
}
|
||||
1025
frontend/admin/js/views/schedule-view.js
Normal file
1025
frontend/admin/js/views/schedule-view.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 || 'Ошибка восстановления');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-item[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.nav-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
89
frontend/admin/views/department-workspace.html
Normal file
89
frontend/admin/views/department-workspace.html
Normal 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; Базы данных 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>
|
||||
122
frontend/admin/views/schedule-view.html
Normal file
122
frontend/admin/views/schedule-view.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
14
frontend/department/index.html
Normal file
14
frontend/department/index.html
Normal 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>
|
||||
14
frontend/edu-office/index.html
Normal file
14
frontend/edu-office/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user