Refactor admin frontend into modular SPA

This commit is contained in:
Zuev
2026-02-26 00:27:10 +03:00
parent 18f47c6d3d
commit 772b110762
21 changed files with 2204 additions and 2351 deletions

57
frontend/admin/js/api.js Normal file
View File

@@ -0,0 +1,57 @@
const token = localStorage.getItem('token');
export function getToken() {
return token;
}
export function isAuthenticatedAsAdmin() {
const role = localStorage.getItem('role');
return token && role === 'ADMIN';
}
function getHeaders(contentType = 'application/json') {
const headers = {
'Authorization': `Bearer ${token}`
};
if (contentType) {
headers['Content-Type'] = contentType;
}
return headers;
}
export async function apiFetch(endpoint, method = 'GET', body = null) {
const options = {
method,
headers: getHeaders(body ? 'application/json' : null)
};
if (body) {
options.body = JSON.stringify(body);
}
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();
} catch (e) {
data = null;
}
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
}
return data;
}
// Shortcut methods
export const api = {
get: (url) => apiFetch(url, 'GET'),
post: (url, body) => apiFetch(url, 'POST', body),
put: (url, body) => apiFetch(url, 'PUT', body),
delete: (url, body = null) => apiFetch(url, 'DELETE', body)
};

101
frontend/admin/js/main.js Normal file
View File

@@ -0,0 +1,101 @@
import { isAuthenticatedAsAdmin } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { initUsers } from './views/users.js';
import { initGroups } from './views/groups.js';
import { initEduForms } from './views/edu-forms.js';
import { initEquipments } from './views/equipments.js';
import { initClassrooms } from './views/classrooms.js';
import { initSubjects } from './views/subjects.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 },
};
let currentTab = null;
// DOM Elements
const appContent = document.getElementById('app-content');
const pageTitle = document.getElementById('page-title');
const navItems = document.querySelectorAll('.nav-item[data-tab]');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const menuToggle = document.getElementById('menu-toggle');
const btnLogout = document.getElementById('btn-logout');
// Initial auth check
if (!isAuthenticatedAsAdmin()) {
window.location.href = '/';
}
// Setup Global Effects
applyRippleEffect();
closeAllDropdownsOnOutsideClick();
// Menu Toggle
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('open');
sidebarOverlay.classList.toggle('open');
});
sidebarOverlay.addEventListener('click', () => {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
});
// Logout
btnLogout.addEventListener('click', () => {
localStorage.removeItem('token');
localStorage.removeItem('role');
window.location.href = '/';
});
// Navigation
navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const tab = item.dataset.tab;
switchTab(tab);
});
});
async function switchTab(tab) {
if (currentTab === tab || !ROUTES[tab]) return;
// UI Update
navItems.forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
pageTitle.textContent = ROUTES[tab].title;
// Load template
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');
const html = await response.text();
appContent.innerHTML = html;
// Initialize logic for the tab
if (ROUTES[tab].init) {
ROUTES[tab].init();
}
currentTab = tab;
} catch (e) {
appContent.innerHTML = `<div class="form-alert error">Ошибка загрузки вкладки: ${e.message}</div>`;
console.error(e);
}
// Close mobile menu if open
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
}
// Load default tab
switchTab('users');

102
frontend/admin/js/utils.js Normal file
View File

@@ -0,0 +1,102 @@
export const ESCAPE_MAP = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
};
export function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/[&<>"']/g, m => ESCAPE_MAP[m]);
}
export function showAlert(elementId, msg, type) {
const el = document.getElementById(elementId);
if (!el) return;
el.className = 'form-alert ' + type;
el.textContent = msg;
}
export function hideAlert(elementId) {
const el = document.getElementById(elementId);
if (!el) return;
el.className = 'form-alert';
el.textContent = '';
}
export function applyRippleEffect() {
document.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-primary, .btn-delete, .btn-logout');
if (!btn) return;
const rect = btn.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const ripple = document.createElement('span');
ripple.classList.add('ripple');
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
if (getComputedStyle(btn).position === 'static') {
btn.style.position = 'relative';
}
btn.style.overflow = 'hidden';
btn.appendChild(ripple);
setTimeout(() => ripple.remove(), 600);
});
}
export function initMultiSelect(boxId, menuId, textId, checkboxContainerId) {
const box = document.getElementById(boxId);
const menu = document.getElementById(menuId);
const container = document.getElementById(checkboxContainerId);
if (!box || !menu || !container) return;
// Remove old listeners to prevent duplication if re-initialized
const newBox = box.cloneNode(true);
box.parentNode.replaceChild(newBox, box);
newBox.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = menu.classList.contains('open');
document.querySelectorAll('.dropdown-menu').forEach(m => m.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(b => b.classList.remove('active'));
if (!isOpen) {
menu.classList.add('open');
newBox.classList.add('active');
}
});
menu.addEventListener('click', (e) => {
e.stopPropagation();
});
container.addEventListener('change', () => {
updateSelectText(checkboxContainerId, textId);
});
}
export function updateSelectText(containerId, textId) {
const container = document.getElementById(containerId);
const textEl = document.getElementById(textId);
if (!container || !textEl) return;
const checked = Array.from(container.querySelectorAll('input:checked'));
if (checked.length === 0) {
textEl.textContent = 'Выберите оборудование...';
} else if (checked.length === 1) {
textEl.textContent = checked[0].parentElement.textContent.trim();
} else {
textEl.textContent = `Выбрано: ${checked.length}`;
}
}
export function closeAllDropdownsOnOutsideClick() {
document.addEventListener('click', () => {
document.querySelectorAll('.dropdown-menu').forEach(m => m.classList.remove('open'));
document.querySelectorAll('.select-box').forEach(b => b.classList.remove('active'));
});
}

View File

@@ -0,0 +1,185 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert, initMultiSelect, updateSelectText } from '../utils.js';
import { fetchEquipments, renderEquipmentCheckboxes } from './equipments.js';
export async function initClassrooms() {
const classroomsTbody = document.getElementById('classrooms-tbody');
const createClassroomForm = document.getElementById('create-classroom-form');
const equipmentCheckboxes = document.getElementById('equipment-checkboxes');
const editEquipmentCheckboxes = document.getElementById('edit-equipment-checkboxes');
const modalEditClassroom = document.getElementById('modal-edit-classroom');
const modalEditClassroomClose = document.getElementById('modal-edit-classroom-close');
const editClassroomForm = document.getElementById('edit-classroom-form');
let allEquipments = [];
let editingClassroomData = null;
initMultiSelect('equipment-select-box', 'equipment-dropdown-menu', 'equipment-select-text', 'equipment-checkboxes');
initMultiSelect('edit-equipment-select-box', 'edit-equipment-dropdown-menu', 'edit-equipment-select-text', 'edit-equipment-checkboxes');
async function loadInitialData() {
try {
allEquipments = await fetchEquipments();
renderEquipmentCheckboxes(allEquipments, 'equipment-checkboxes', 'equipment-select-text');
await loadClassrooms();
} catch (e) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки данных</td></tr>';
}
}
async function loadClassrooms() {
try {
const classrooms = await api.get('/api/classrooms');
renderClassrooms(classrooms);
} catch (e) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderClassrooms(classrooms) {
if (!classrooms || !classrooms.length) {
classroomsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет аудиторий</td></tr>';
return;
}
classroomsTbody.innerHTML = classrooms.map(c => {
const equipHtml = c.equipments && c.equipments.length
? c.equipments.map(eq => escapeHtml(eq.name)).join(', ')
: '—';
return `
<tr>
<td>${c.id}</td>
<td><strong>${escapeHtml(c.name)}</strong></td>
<td>${c.capacity} чел.</td>
<td><small>${equipHtml}</small></td>
<td>
<div class="status-cell">
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
${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>
</button>
</div>
</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>
</td>
</tr>`;
}).join('');
}
createClassroomForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-classroom-alert');
const name = document.getElementById('new-classroom-name').value.trim();
const capacity = parseInt(document.getElementById('new-classroom-capacity').value, 10);
const checkedBoxes = Array.from(equipmentCheckboxes.querySelectorAll('input:checked'));
const equipmentIds = checkedBoxes.map(chk => parseInt(chk.value, 10));
if (!name || isNaN(capacity)) { showAlert('create-classroom-alert', 'Заполните обязательные поля', 'error'); return; }
try {
const data = await api.post('/api/classrooms', { name, capacity, equipmentIds, isAvailable: true });
showAlert('create-classroom-alert', `Аудитория "${escapeHtml(data.name)}" добавлена`, 'success');
createClassroomForm.reset();
updateSelectText('equipment-checkboxes', 'equipment-select-text');
loadClassrooms();
} catch (e) {
showAlert('create-classroom-alert', e.message || 'Ошибка создания', 'error');
}
});
classroomsTbody.addEventListener('click', async (e) => {
const btnDelete = e.target.closest('.btn-delete');
const btnToggleStatus = e.target.closest('.btn-icon-toggle');
const btnEdit = e.target.closest('.btn-edit-classroom');
if (btnDelete) {
if (!confirm('Удалить аудиторию?')) return;
try {
await api.delete('/api/classrooms/' + btnDelete.dataset.id);
loadClassrooms();
} catch (err) { alert('Ошибка удаления'); }
}
if (btnToggleStatus) {
const id = btnToggleStatus.dataset.id;
const currentStatus = btnToggleStatus.dataset.currentStatus === 'true';
try {
await api.put('/api/classrooms/' + id, { isAvailable: !currentStatus });
loadClassrooms();
} catch (err) { alert('Ошибка изменения статуса'); }
}
if (btnEdit) {
openEditClassroomModal(btnEdit.dataset.id);
}
});
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');
editingClassroomData = classrooms.find(c => c.id == id);
if (!editingClassroomData) return;
document.getElementById('edit-classroom-id').value = editingClassroomData.id;
document.getElementById('edit-classroom-name').value = editingClassroomData.name;
document.getElementById('edit-classroom-capacity').value = editingClassroomData.capacity;
const existingEquipIds = editingClassroomData.equipments.map(e => e.id);
renderEquipmentCheckboxes(allEquipments, 'edit-equipment-checkboxes', 'edit-equipment-select-text', existingEquipIds);
hideAlert('edit-classroom-alert');
modalEditClassroom.classList.add('open');
} catch (e) {
alert('Ошибка загрузки данных аудитории');
}
}
modalEditClassroomClose.addEventListener('click', () => {
modalEditClassroom.classList.remove('open');
});
modalEditClassroom.addEventListener('click', (e) => {
if (e.target === modalEditClassroom) {
modalEditClassroom.classList.remove('open');
}
});
editClassroomForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('edit-classroom-alert');
const id = document.getElementById('edit-classroom-id').value;
const name = document.getElementById('edit-classroom-name').value.trim();
const capacity = parseInt(document.getElementById('edit-classroom-capacity').value, 10);
const checkedBoxes = Array.from(editEquipmentCheckboxes.querySelectorAll('input:checked'));
const equipmentIds = checkedBoxes.map(chk => parseInt(chk.value, 10));
if (!name || isNaN(capacity)) { showAlert('edit-classroom-alert', 'Заполните обязательные поля', 'error'); return; }
try {
const data = await api.put('/api/classrooms/' + id, {
name,
capacity,
equipmentIds,
isAvailable: editingClassroomData.isAvailable
});
modalEditClassroom.classList.remove('open');
// We show alert on the main create form area or we could use toast
showAlert('create-classroom-alert', `Аудитория "${escapeHtml(data.name)}" обновлена`, 'success');
loadClassrooms();
} catch (e) {
showAlert('edit-classroom-alert', e.message || 'Ошибка обновления', 'error');
}
});
loadInitialData();
}

View File

@@ -0,0 +1,71 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export let allEducationForms = [];
export async function fetchEducationForms() {
try {
allEducationForms = await api.get('/api/education-forms');
return allEducationForms;
} catch (e) {
console.error("Failed to fetch education forms", e);
return [];
}
}
export async function initEduForms() {
const efTbody = document.getElementById('ef-tbody');
const createEfForm = document.getElementById('create-ef-form');
async function loadEF() {
try {
const forms = await fetchEducationForms();
renderEfTable(forms);
} catch (e) {
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderEfTable(forms) {
if (!forms || !forms.length) {
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет форм обучения</td></tr>';
return;
}
efTbody.innerHTML = forms.map(ef => `
<tr>
<td>${ef.id}</td>
<td>${escapeHtml(ef.name)}</td>
<td><button class="btn-delete" data-id="${ef.id}">Удалить</button></td>
</tr>`).join('');
}
createEfForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-ef-alert');
const name = document.getElementById('new-ef-name').value.trim();
if (!name) { showAlert('create-ef-alert', 'Введите название', 'error'); return; }
try {
const data = await api.post('/api/education-forms', { name });
showAlert('create-ef-alert', `Форма "${escapeHtml(data.name)}" создана`, 'success');
createEfForm.reset();
loadEF();
} catch (e) {
showAlert('create-ef-alert', e.message || 'Ошибка создания', 'error');
}
});
efTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить форму обучения?')) return;
try {
await api.delete('/api/education-forms/' + btn.dataset.id);
loadEF();
} catch (e) {
alert(e.message || 'Ошибка удаления');
}
});
loadEF();
}

View File

@@ -0,0 +1,88 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert, updateSelectText } from '../utils.js';
export let allEquipments = [];
export async function fetchEquipments() {
try {
allEquipments = await api.get('/api/equipments');
return allEquipments;
} catch (e) {
console.error("Failed to fetch equipments", e);
return [];
}
}
export function renderEquipmentCheckboxes(equipments, containerId, textId, checkedIds = []) {
const container = document.getElementById(containerId);
if (!container) return;
if (!equipments.length) {
container.innerHTML = '<p class="text-muted"><small>Нет доступного оборудования</small></p>';
return;
}
container.innerHTML = equipments.map(eq => {
const isChecked = checkedIds.includes(eq.id) ? 'checked' : '';
return `
<label class="checkbox-item">
<input type="checkbox" value="${eq.id}" ${isChecked}> ${escapeHtml(eq.name)}
</label>
`}).join('');
updateSelectText(containerId, textId);
}
export async function initEquipments() {
const equipmentsTbody = document.getElementById('equipments-tbody');
const createEquipmentForm = document.getElementById('create-equipment-form');
async function loadEquipments() {
try {
const equipments = await fetchEquipments();
renderEquipments(equipments);
} catch (e) {
if (equipmentsTbody) equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderEquipments(equipments) {
if (!equipments || !equipments.length) {
equipmentsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет оборудования</td></tr>';
return;
}
equipmentsTbody.innerHTML = equipments.map(eq => `
<tr>
<td>${eq.id}</td>
<td>${escapeHtml(eq.name)}</td>
<td><button class="btn-delete" data-id="${eq.id}">Удалить</button></td>
</tr>`).join('');
}
createEquipmentForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-equipment-alert');
const name = document.getElementById('new-equipment-name').value.trim();
if (!name) { showAlert('create-equipment-alert', 'Введите название', 'error'); return; }
try {
const data = await api.post('/api/equipments', { name });
showAlert('create-equipment-alert', `Оборудование "${escapeHtml(data.name)}" добавлено`, 'success');
createEquipmentForm.reset();
loadEquipments();
} catch (e) {
showAlert('create-equipment-alert', e.message || 'Ошибка создания', 'error');
}
});
equipmentsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить оборудование?')) return;
try {
await api.delete('/api/equipments/' + btn.dataset.id);
loadEquipments();
} catch (e) {
alert(e.message || 'Ошибка удаления');
}
});
loadEquipments();
}

View File

@@ -0,0 +1,108 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { fetchEducationForms } from './edu-forms.js';
export async function initGroups() {
const groupsTbody = document.getElementById('groups-tbody');
const createGroupForm = document.getElementById('create-group-form');
const newGroupEfSelect = document.getElementById('new-group-ef');
const filterEfSelect = document.getElementById('filter-ef');
let allGroups = [];
let educationForms = [];
async function loadInitialData() {
try {
educationForms = await fetchEducationForms();
populateEfSelects(educationForms);
await loadGroups();
} catch (e) {
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки данных</td></tr>';
}
}
async function loadGroups() {
try {
allGroups = await api.get('/api/groups');
applyGroupFilter();
} catch (e) {
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function applyGroupFilter() {
const filterId = filterEfSelect.value;
const filtered = filterId
? allGroups.filter(g => g.educationFormId == filterId)
: allGroups;
renderGroups(filtered);
}
filterEfSelect.addEventListener('change', applyGroupFilter);
function populateEfSelects(forms) {
// Group creation select
const currentVal = newGroupEfSelect.value;
newGroupEfSelect.innerHTML = forms.map(ef =>
`<option value="${ef.id}">${escapeHtml(ef.name)}</option>`
).join('');
if (currentVal && forms.find(f => f.id == currentVal)) {
newGroupEfSelect.value = currentVal;
}
// Filter select
const currentFilter = filterEfSelect.value;
filterEfSelect.innerHTML = '<option value="">Все формы</option>' +
forms.map(ef =>
`<option value="${ef.id}">${escapeHtml(ef.name)}</option>`
).join('');
if (currentFilter) filterEfSelect.value = currentFilter;
}
function renderGroups(groups) {
if (!groups || !groups.length) {
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет групп</td></tr>';
return;
}
groupsTbody.innerHTML = groups.map(g => `
<tr>
<td>${g.id}</td>
<td>${escapeHtml(g.name)}</td>
<td><span class="badge badge-ef">${escapeHtml(g.educationFormName)}</span></td>
<td><button class="btn-delete" data-id="${g.id}">Удалить</button></td>
</tr>`).join('');
}
createGroupForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-group-alert');
const name = document.getElementById('new-group-name').value.trim();
const educationFormId = newGroupEfSelect.value;
if (!name) { showAlert('create-group-alert', 'Введите название группы', 'error'); return; }
if (!educationFormId) { showAlert('create-group-alert', 'Выберите форму обучения', 'error'); return; }
try {
const data = await api.post('/api/groups', { name, educationFormId: Number(educationFormId) });
showAlert('create-group-alert', `Группа "${escapeHtml(data.name)}" создана`, 'success');
createGroupForm.reset();
loadGroups();
} catch (e) {
showAlert('create-group-alert', e.message || 'Ошибка создания', 'error');
}
});
groupsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить группу?')) return;
try {
await api.delete('/api/groups/' + btn.dataset.id);
loadGroups();
} catch (e) {
alert(e.message || 'Ошибка удаления');
}
});
loadInitialData();
}

View File

@@ -0,0 +1,153 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initSubjects() {
const subjectsTbody = document.getElementById('subjects-tbody');
const createSubjectForm = document.getElementById('create-subject-form');
const assignTeacherForm = document.getElementById('assign-teacher-form');
const assignTeacherSelect = document.getElementById('assign-teacher-select');
const assignSubjectSelect = document.getElementById('assign-subject-select');
const teacherSubjectsTbody = document.getElementById('teacher-subjects-tbody');
let allSubjects = [];
let allTeachers = [];
async function loadInitialData() {
await Promise.all([loadSubjects(), loadTeachers()]);
await loadTeacherSubjects();
}
async function loadSubjects() {
try {
allSubjects = await api.get('/api/subjects');
renderSubjects(allSubjects);
populateSubjectSelect(allSubjects);
} catch (e) {
if (subjectsTbody) subjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderSubjects(subjects) {
if (!subjects || !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 {
allTeachers = await api.get('/api/users/teachers');
populateTeacherSelect(allTeachers);
} catch (e) {
if (assignTeacherSelect) assignTeacherSelect.innerHTML = '<option value="">Ошибка загрузки</option>';
}
}
function populateTeacherSelect(teachers) {
if (!assignTeacherSelect) return;
const currentVal = assignTeacherSelect.value;
if (!teachers || !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 tsData = await api.get('/api/teacher-subjects');
renderTeacherSubjects(tsData);
} catch (e) {
if (teacherSubjectsTbody) teacherSubjectsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderTeacherSubjects(tsArray) {
if (!tsArray || !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('create-subject-alert');
const name = document.getElementById('new-subject-name').value.trim();
if (!name) { showAlert('create-subject-alert', 'Введите название', 'error'); return; }
try {
const data = await api.post('/api/subjects', { name });
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name)}" добавлена`, 'success');
createSubjectForm.reset();
loadSubjects();
} catch (e) { showAlert('create-subject-alert', e.message || 'Ошибка создания', 'error'); }
});
subjectsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить дисциплину?')) return;
try {
await api.delete('/api/subjects/' + btn.dataset.id);
loadSubjects();
loadTeacherSubjects(); // May have deleted a subject that was assigned
} catch (e) { alert(e.message || 'Ошибка удаления'); }
});
assignTeacherForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('assign-teacher-alert');
const userId = assignTeacherSelect.value;
const subjectId = assignSubjectSelect.value;
if (!userId) { showAlert('assign-teacher-alert', 'Выберите преподавателя', 'error'); return; }
if (!subjectId) { showAlert('assign-teacher-alert', 'Выберите дисциплину', 'error'); return; }
try {
await api.post('/api/teacher-subjects', { userId: Number(userId), subjectId: Number(subjectId) });
showAlert('assign-teacher-alert', 'Привязка создана', 'success');
loadTeacherSubjects();
} catch (e) { showAlert('assign-teacher-alert', e.message || 'Ошибка привязки', 'error'); }
});
teacherSubjectsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить привязку?')) return;
try {
await api.delete('/api/teacher-subjects', {
userId: Number(btn.dataset.userId),
subjectId: Number(btn.dataset.subjectId)
});
loadTeacherSubjects();
} catch (e) { alert(e.message || 'Ошибка удаления'); }
});
loadInitialData();
}

View File

@@ -0,0 +1,67 @@
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' };
export async function initUsers() {
const usersTbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form');
const createAlert = document.getElementById('create-alert');
async function loadUsers() {
try {
const users = await api.get('/api/users');
renderUsers(users);
} catch (e) {
usersTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки: ' + escapeHtml(e.message) + '</td></tr>';
}
}
function renderUsers(users) {
if (!users || !users.length) {
usersTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет пользователей</td></tr>';
return;
}
usersTbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>${escapeHtml(u.username)}</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></td>
</tr>`).join('');
}
createForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('create-alert');
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
if (!username || !password) { showAlert('create-alert', 'Заполните все поля', 'error'); return; }
try {
const data = await api.post('/api/users', { username, password, role });
showAlert('create-alert', `Пользователь "${escapeHtml(data.username)}" создан`, 'success');
createForm.reset();
loadUsers();
} catch (e) {
showAlert('create-alert', e.message || 'Ошибка соединения', 'error');
}
});
usersTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить пользователя?')) return;
try {
await api.delete('/api/users/' + btn.dataset.id);
loadUsers();
} catch (e) {
alert(e.message || 'Ошибка удаления');
}
});
// Initial load
loadUsers();
}