feat: add subjects & teacher-subjects management tab in admin panel

This commit is contained in:
Zuev
2026-02-25 17:34:01 +03:00
parent 0ac81284fa
commit df6ddeecd5
14 changed files with 698 additions and 2 deletions

View File

@@ -0,0 +1,83 @@
---
description: Полное описание схемы базы данных проекта
---
# Описание базы данных: Система расписания университета
В этом правиле содержится полное описание структуры базы данных PostgreSQL, используемой в проекте.
## Основные сущности и таблицы
### 1. Пользователи и роли (`users`)
Хранит учетные записи пользователей системы.
- `id` (BIGSERIAL, РК)
- `username` (VARCHAR(50), UNIQUE, NOT NULL) - логин
- `password` (VARCHAR(255), NOT NULL) - хэш пароля (используется bcrypt через pgcrypto)
- `role` (VARCHAR(20), NOT NULL, DEFAULT 'STUDENT') - роль пользователя. Возможные значения: `ADMIN`, `TEACHER`, `STUDENT`.
### 2. Формы обучения (`education_forms`)
Справочник форм обучения.
- `id` (BIGSERIAL, РК)
- `name` (VARCHAR(100), UNIQUE, NOT NULL) - например: 'Бакалавриат', 'Магистратура', 'Специалитет'
### 3. Студенческие группы (`student_groups`)
Справочник учебных групп.
- `id` (BIGSERIAL, РК)
- `name` (VARCHAR(100), UNIQUE, NOT NULL) - название группы (например, 'ИВТ-21-1')
- `education_form_id` (BIGINT, FK -> education_forms.id) - привязка к форме обучения
### 4. Подгруппы (`subgroups`)
Разделение групп на подгруппы (для лабораторных и практик).
- `id` (BIGSERIAL, РК)
- `group_id` (BIGINT, FK -> student_groups.id)
- `name` (VARCHAR(100), NOT NULL) - название подгруппы (например, 'Подгруппа 1')
### 5. Дисциплины (`subjects`)
Справочник учебных предметов.
- `id` (BIGSERIAL, РК)
- `name` (VARCHAR(200), UNIQUE, NOT NULL) - название дисциплины (например, 'Высшая математика')
### 6. Типы занятий (`lesson_types`)
Справочник видов учебных занятий.
- `id` (BIGSERIAL, РК)
- `name` (VARCHAR(50), UNIQUE, NOT NULL) - вид занятия (Лекция, Практика, Лабораторная работа)
### 7. Оборудование (`equipments`)
Справочник доступного оборудования для аудиторий.
- `id` (BIGSERIAL, РК)
- `name` (VARCHAR(50), UNIQUE, NOT NULL) - например, 'Проектор', 'ПК', 'Лаборатория', 'Интерактивная доска'
### 8. Аудиторный фонд (`classrooms`)
Справочник аудиторий университета.
- `id` (BIGSERIAL, РК)
- `name` (VARCHAR(50), UNIQUE, NOT NULL) - номер/название аудитории
- `capacity` (INT, NOT NULL) - вместимость (количество посадочных мест)
- `is_available` (BOOLEAN, DEFAULT TRUE) - статус доступности аудитории для проведения пар
### 9. Привязка оборудования к аудиториям (`classroom_equipments`)
Связующая таблица (Many-to-Many) для указания, какое оборудование есть в аудитории.
- `classroom_id` (BIGINT, FK -> classrooms.id)
- `equipment_id` (BIGINT, FK -> equipments.id)
### 10. Привязка преподавателей к дисциплинам (`teacher_subjects`)
Связующая таблица (Many-to-Many). Определяет, какие предметы имеет право вести преподаватель.
- `user_id` (BIGINT, FK -> users.id)
- `subject_id` (BIGINT, FK -> subjects.id)
### 11. Расписание занятий (Пар) (`lessons`)
Главная таблица, хранящая сетку расписания. Связывает все основные сущности в рамках одного учебного занятия.
- `id` (BIGSERIAL, РК)
- `teacher_id` (BIGINT, FK -> users.id) - кто ведет пару
- `subject_id` (BIGINT, FK -> subjects.id) - какую дисциплину ведут
- `lesson_type_id` (BIGINT, FK -> lesson_types.id) - вид занятия (лекция/практика)
- `classroom_id` (BIGINT, FK -> classrooms.id) - где проходит
- `group_id` (BIGINT, FK -> student_groups.id) - у какой группы
- `subgroup_id` (BIGINT, FK -> subgroups.id, NULLABLE) - конкретная подгруппа (если пара не у всей группы)
- `day_of_week` (INT, NOT NULL) - день недели (1=Понедельник ... 7=Воскресенье)
- `is_even_week` (BOOLEAN, NOT NULL) - признак четности недели (TRUE = четная, FALSE = нечетная)
- `start_time` (TIME, NOT NULL) - время начала пары (например: '08:00:00')
- `end_time` (TIME, NOT NULL) - время окончания пары (например: '09:30:00')
## Особенности архитектуры БД
- Используется расширение `pgcrypto` для шифрования паролей пользователей (`bcrypt`).
- Все связи Many-to-Many и внешние ключи настроены с `ON DELETE CASCADE` там, где это необходимо, для поддержания целостности данных при удалении родительских сущностей.

View File

@@ -0,0 +1,51 @@
package com.magistr.app.controller;
import com.magistr.app.model.Subject;
import com.magistr.app.repository.SubjectRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/subjects")
public class SubjectController {
private final SubjectRepository subjectRepository;
public SubjectController(SubjectRepository subjectRepository) {
this.subjectRepository = subjectRepository;
}
@GetMapping
public List<Subject> getAllSubjects() {
return subjectRepository.findAll();
}
@PostMapping
public ResponseEntity<?> createSubject(@RequestBody Map<String, String> request) {
String name = request.get("name");
if (name == null || name.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно"));
}
if (subjectRepository.findByName(name.trim()).isPresent()) {
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина с таким названием уже существует"));
}
Subject subject = new Subject();
subject.setName(name.trim());
subjectRepository.save(subject);
return ResponseEntity.ok(subject);
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteSubject(@PathVariable Long id) {
if (!subjectRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
subjectRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Дисциплина удалена"));
}
}

View File

@@ -0,0 +1,85 @@
package com.magistr.app.controller;
import com.magistr.app.dto.TeacherSubjectResponse;
import com.magistr.app.model.TeacherSubject;
import com.magistr.app.model.TeacherSubjectId;
import com.magistr.app.repository.SubjectRepository;
import com.magistr.app.repository.TeacherSubjectRepository;
import com.magistr.app.repository.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/teacher-subjects")
public class TeacherSubjectController {
private final TeacherSubjectRepository teacherSubjectRepository;
private final UserRepository userRepository;
private final SubjectRepository subjectRepository;
public TeacherSubjectController(TeacherSubjectRepository teacherSubjectRepository,
UserRepository userRepository,
SubjectRepository subjectRepository) {
this.teacherSubjectRepository = teacherSubjectRepository;
this.userRepository = userRepository;
this.subjectRepository = subjectRepository;
}
@GetMapping
public List<TeacherSubjectResponse> getAll() {
return teacherSubjectRepository.findAll().stream()
.map(ts -> new TeacherSubjectResponse(
ts.getUserId(),
ts.getUser().getUsername(),
ts.getSubjectId(),
ts.getSubject().getName()
))
.toList();
}
@PostMapping
public ResponseEntity<?> create(@RequestBody Map<String, Long> request) {
Long userId = request.get("userId");
Long subjectId = request.get("subjectId");
if (userId == null || subjectId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны"));
}
if (!userRepository.existsById(userId)) {
return ResponseEntity.badRequest().body(Map.of("message", "Преподаватель не найден"));
}
if (!subjectRepository.existsById(subjectId)) {
return ResponseEntity.badRequest().body(Map.of("message", "Дисциплина не найдена"));
}
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
if (teacherSubjectRepository.existsById(id)) {
return ResponseEntity.badRequest().body(Map.of("message", "Привязка уже существует"));
}
TeacherSubject ts = new TeacherSubject(userId, subjectId);
teacherSubjectRepository.save(ts);
return ResponseEntity.ok(Map.of("message", "Привязка создана"));
}
@DeleteMapping
public ResponseEntity<?> delete(@RequestBody Map<String, Long> request) {
Long userId = request.get("userId");
Long subjectId = request.get("subjectId");
if (userId == null || subjectId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "userId и subjectId обязательны"));
}
TeacherSubjectId id = new TeacherSubjectId(userId, subjectId);
if (!teacherSubjectRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
teacherSubjectRepository.deleteById(id);
return ResponseEntity.ok(Map.of("message", "Привязка удалена"));
}
}

View File

@@ -31,6 +31,13 @@ public class UserController {
.toList(); .toList();
} }
@GetMapping("/teachers")
public List<UserResponse> getTeachers() {
return userRepository.findByRole(Role.TEACHER).stream()
.map(u -> new UserResponse(u.getId(), u.getUsername(), u.getRole().name()))
.toList();
}
@PostMapping @PostMapping
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) { public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
if (request.getUsername() == null || request.getUsername().isBlank()) { if (request.getUsername() == null || request.getUsername().isBlank()) {

View File

@@ -0,0 +1,32 @@
package com.magistr.app.dto;
public class TeacherSubjectResponse {
private Long userId;
private String username;
private Long subjectId;
private String subjectName;
public TeacherSubjectResponse(Long userId, String username, Long subjectId, String subjectName) {
this.userId = userId;
this.username = username;
this.subjectId = subjectId;
this.subjectName = subjectName;
}
public Long getUserId() {
return userId;
}
public String getUsername() {
return username;
}
public Long getSubjectId() {
return subjectId;
}
public String getSubjectName() {
return subjectName;
}
}

View File

@@ -0,0 +1,39 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name = "subjects")
public class Subject {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 200)
private String name;
public Subject() {
}
public Subject(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,57 @@
package com.magistr.app.model;
import jakarta.persistence.*;
@Entity
@Table(name = "teacher_subjects")
@IdClass(TeacherSubjectId.class)
public class TeacherSubject {
@Id
@Column(name = "user_id")
private Long userId;
@Id
@Column(name = "subject_id")
private Long subjectId;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", insertable = false, updatable = false)
private User user;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "subject_id", insertable = false, updatable = false)
private Subject subject;
public TeacherSubject() {
}
public TeacherSubject(Long userId, Long subjectId) {
this.userId = userId;
this.subjectId = subjectId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Long getSubjectId() {
return subjectId;
}
public void setSubjectId(Long subjectId) {
this.subjectId = subjectId;
}
public User getUser() {
return user;
}
public Subject getSubject() {
return subject;
}
}

View File

@@ -0,0 +1,46 @@
package com.magistr.app.model;
import java.io.Serializable;
import java.util.Objects;
public class TeacherSubjectId implements Serializable {
private Long userId;
private Long subjectId;
public TeacherSubjectId() {
}
public TeacherSubjectId(Long userId, Long subjectId) {
this.userId = userId;
this.subjectId = subjectId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Long getSubjectId() {
return subjectId;
}
public void setSubjectId(Long subjectId) {
this.subjectId = subjectId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof TeacherSubjectId that)) return false;
return Objects.equals(userId, that.userId) && Objects.equals(subjectId, that.subjectId);
}
@Override
public int hashCode() {
return Objects.hash(userId, subjectId);
}
}

View File

@@ -0,0 +1,10 @@
package com.magistr.app.repository;
import com.magistr.app.model.Subject;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SubjectRepository extends JpaRepository<Subject, Long> {
Optional<Subject> findByName(String name);
}

View File

@@ -0,0 +1,12 @@
package com.magistr.app.repository;
import com.magistr.app.model.TeacherSubject;
import com.magistr.app.model.TeacherSubjectId;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TeacherSubjectRepository extends JpaRepository<TeacherSubject, TeacherSubjectId> {
List<TeacherSubject> findByUserId(Long userId);
List<TeacherSubject> findBySubjectId(Long subjectId);
}

View File

@@ -1,11 +1,15 @@
package com.magistr.app.repository; package com.magistr.app.repository;
import com.magistr.app.model.Role;
import com.magistr.app.model.User; import com.magistr.app.model.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username); Optional<User> findByUsername(String username);
List<User> findByRole(Role role);
} }

View File

@@ -94,7 +94,7 @@ CREATE TABLE IF NOT EXISTS classroom_equipments (
INSERT INTO classroom_equipments (classroom_id, equipment_id) VALUES INSERT INTO classroom_equipments (classroom_id, equipment_id) VALUES
(1, 1), (1, 4), (1, 1), (1, 4),
-- '202 IT Lab' -> ПК (2), Проектор (1), Лаборатория (3) -- '202 IT Lab' -> ПК (2), Проектор (1), Лаборатория (3)
(2, 2), (2, 1), (2, 3), (2, 2), (2, 1), (2, 3)
-- '303 Обычная' -> ничего -- '303 Обычная' -> ничего
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;

View File

@@ -73,6 +73,16 @@
const createEquipmentAlert = document.getElementById('create-equipment-alert'); const createEquipmentAlert = document.getElementById('create-equipment-alert');
const equipmentCheckboxes = document.getElementById('equipment-checkboxes'); const equipmentCheckboxes = document.getElementById('equipment-checkboxes');
// Subjects
const subjectsTbody = document.getElementById('subjects-tbody');
const createSubjectForm = document.getElementById('create-subject-form');
const createSubjectAlert = document.getElementById('create-subject-alert');
const assignTeacherForm = document.getElementById('assign-teacher-form');
const assignTeacherAlert = document.getElementById('assign-teacher-alert');
const assignTeacherSelect = document.getElementById('assign-teacher-select');
const assignSubjectSelect = document.getElementById('assign-subject-select');
const teacherSubjectsTbody = document.getElementById('teacher-subjects-tbody');
// --- Multi-select logic --- // --- Multi-select logic ---
function updateSelectText(containerId, textId) { function updateSelectText(containerId, textId) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
@@ -130,6 +140,8 @@
let allGroups = []; let allGroups = [];
let allEducationForms = []; let allEducationForms = [];
let allEquipments = []; let allEquipments = [];
let allSubjects = [];
let allTeachers = [];
// ---- Tab Switching ---- // ---- Tab Switching ----
const TAB_TITLES = { const TAB_TITLES = {
@@ -137,7 +149,8 @@
groups: 'Управление группами', groups: 'Управление группами',
'edu-forms': 'Формы обучения', 'edu-forms': 'Формы обучения',
equipments: 'Оборудование', equipments: 'Оборудование',
classrooms: 'Аудитории' classrooms: 'Аудитории',
subjects: 'Дисциплины и преподаватели'
}; };
navItems.forEach(item => { navItems.forEach(item => {
@@ -162,6 +175,7 @@
if (tab === 'edu-forms') loadEducationForms(); if (tab === 'edu-forms') loadEducationForms();
if (tab === 'equipments') loadEquipments(); if (tab === 'equipments') loadEquipments();
if (tab === 'classrooms') { loadEquipments().then(() => loadClassrooms()); } if (tab === 'classrooms') { loadEquipments().then(() => loadClassrooms()); }
if (tab === 'subjects') { Promise.all([loadSubjects(), loadTeachers()]).then(() => loadTeacherSubjects()); }
sidebar.classList.remove('open'); sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open'); sidebarOverlay.classList.remove('open');
@@ -711,6 +725,178 @@
} catch (e) { showAlert(editClassroomAlert, 'Ошибка соединения', 'error'); } } catch (e) { showAlert(editClassroomAlert, 'Ошибка соединения', 'error'); }
}); });
// ============================================================
// SUBJECTS
// ============================================================
async function loadSubjects() {
try {
const res = await fetch('/api/subjects', {
headers: { 'Authorization': 'Bearer ' + token },
});
allSubjects = await res.json();
renderSubjects(allSubjects);
populateSubjectSelect(allSubjects);
} catch (e) {
if (subjectsTbody) subjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderSubjects(subjects) {
if (!subjects.length) {
subjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет дисциплин</td></tr>';
return;
}
subjectsTbody.innerHTML = subjects.map(s => `
<tr>
<td>${s.id}</td>
<td>${escapeHtml(s.name)}</td>
<td><button class="btn-delete" data-id="${s.id}">Удалить</button></td>
</tr>`).join('');
}
function populateSubjectSelect(subjects) {
if (!assignSubjectSelect) return;
const currentVal = assignSubjectSelect.value;
assignSubjectSelect.innerHTML = '<option value="">Выберите дисциплину</option>' +
subjects.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
if (currentVal && subjects.find(s => s.id == currentVal)) {
assignSubjectSelect.value = currentVal;
}
}
async function loadTeachers() {
try {
const res = await fetch('/api/users/teachers', {
headers: { 'Authorization': 'Bearer ' + token },
});
allTeachers = await res.json();
populateTeacherSelect(allTeachers);
} catch (e) {
if (assignTeacherSelect) assignTeacherSelect.innerHTML = '<option value="">Ошибка загрузки</option>';
}
}
function populateTeacherSelect(teachers) {
if (!assignTeacherSelect) return;
const currentVal = assignTeacherSelect.value;
if (!teachers.length) {
assignTeacherSelect.innerHTML = '<option value="">Нет преподавателей</option>';
return;
}
assignTeacherSelect.innerHTML = '<option value="">Выберите преподавателя</option>' +
teachers.map(t => `<option value="${t.id}">${escapeHtml(t.username)}</option>`).join('');
if (currentVal && teachers.find(t => t.id == currentVal)) {
assignTeacherSelect.value = currentVal;
}
}
async function loadTeacherSubjects() {
try {
const res = await fetch('/api/teacher-subjects', {
headers: { 'Authorization': 'Bearer ' + token },
});
const tsData = await res.json();
renderTeacherSubjects(tsData);
} catch (e) {
if (teacherSubjectsTbody) teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderTeacherSubjects(tsArray) {
if (!tsArray.length) {
teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет привязок</td></tr>';
return;
}
teacherSubjectsTbody.innerHTML = tsArray.map(ts => `
<tr>
<td>${escapeHtml(ts.username)}</td>
<td>${escapeHtml(ts.subjectName)}</td>
<td><button class="btn-delete" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button></td>
</tr>`).join('');
}
createSubjectForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert(createSubjectAlert);
const name = document.getElementById('new-subject-name').value.trim();
if (!name) { showAlert(createSubjectAlert, 'Введите название', 'error'); return; }
try {
const res = await fetch('/api/subjects', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (res.ok) {
showAlert(createSubjectAlert, `Дисциплина "${data.name}" добавлена`, 'success');
createSubjectForm.reset();
loadSubjects();
} else {
showAlert(createSubjectAlert, data.message || 'Ошибка создания', 'error');
}
} catch (e) { showAlert(createSubjectAlert, 'Ошибка соединения', 'error'); }
});
subjectsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить дисциплину?')) return;
try {
const res = await fetch('/api/subjects/' + btn.dataset.id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
if (res.ok) {
loadSubjects();
loadTeacherSubjects();
} else {
const data = await res.json();
alert(data.message || 'Ошибка удаления');
}
} catch (e) { alert('Ошибка соединения'); }
});
assignTeacherForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert(assignTeacherAlert);
const userId = assignTeacherSelect.value;
const subjectId = assignSubjectSelect.value;
if (!userId) { showAlert(assignTeacherAlert, 'Выберите преподавателя', 'error'); return; }
if (!subjectId) { showAlert(assignTeacherAlert, 'Выберите дисциплину', 'error'); return; }
try {
const res = await fetch('/api/teacher-subjects', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ userId: Number(userId), subjectId: Number(subjectId) }),
});
const data = await res.json();
if (res.ok) {
showAlert(assignTeacherAlert, 'Привязка создана', 'success');
loadTeacherSubjects();
} else {
showAlert(assignTeacherAlert, data.message || 'Ошибка привязки', 'error');
}
} catch (e) { showAlert(assignTeacherAlert, 'Ошибка соединения', 'error'); }
});
teacherSubjectsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить привязку?')) return;
try {
const res = await fetch('/api/teacher-subjects', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ userId: Number(btn.dataset.userId), subjectId: Number(btn.dataset.subjectId) }),
});
if (res.ok) loadTeacherSubjects();
else alert('Ошибка удаления');
} catch (e) { alert('Ошибка соединения'); }
});
// ============================================================ // ============================================================
// LOGOUT & INIT // LOGOUT & INIT
// ============================================================ // ============================================================

View File

@@ -74,6 +74,14 @@
</svg> </svg>
Аудитории Аудитории
</a> </a>
<a href="#" class="nav-item" data-tab="subjects">
<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="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
Дисциплины
</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="btn-logout" id="btn-logout"> <button class="btn-logout" id="btn-logout">
@@ -343,6 +351,82 @@
</div> </div>
</section> </section>
<!-- ===== Subjects Tab ===== -->
<section class="content tab-content" id="tab-subjects" style="display:none;">
<div class="card create-card">
<h2>Новая дисциплина</h2>
<form id="create-subject-form">
<div class="form-row">
<div class="form-group">
<label for="new-subject-name">Название дисциплины</label>
<input type="text" id="new-subject-name" placeholder="Высшая математика" required>
</div>
<button type="submit" class="btn-create">Добавить</button>
</div>
<div class="form-alert" id="create-subject-alert" role="alert"></div>
</form>
</div>
<div class="card create-card">
<h2>Привязка преподавателя к дисциплине</h2>
<form id="assign-teacher-form">
<div class="form-row">
<div class="form-group">
<label for="assign-teacher-select">Преподаватель</label>
<select id="assign-teacher-select">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="assign-subject-select">Дисциплина</label>
<select id="assign-subject-select">
<option value="">Загрузка...</option>
</select>
</div>
<button type="submit" class="btn-create">Привязать</button>
</div>
<div class="form-alert" id="assign-teacher-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Все дисциплины</h2>
<div class="table-wrap">
<table id="subjects-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th></th>
</tr>
</thead>
<tbody id="subjects-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>Привязки преподавателей</h2>
<div class="table-wrap">
<table id="teacher-subjects-table">
<thead>
<tr>
<th>Преподаватель</th>
<th>Дисциплина</th>
<th></th>
</tr>
</thead>
<tbody id="teacher-subjects-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ===== Edit Classroom Modal ===== --> <!-- ===== Edit Classroom Modal ===== -->
<div class="modal-overlay" id="modal-edit-classroom"> <div class="modal-overlay" id="modal-edit-classroom">
<div class="modal-content"> <div class="modal-content">