357 lines
16 KiB
JavaScript
Executable File
357 lines
16 KiB
JavaScript
Executable File
import { api } from '../api.js';
|
||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||
import { confirmAction } from '../dialog.js';
|
||
import { renderPagination } from '../pagination.js';
|
||
import { debounce, readUrlState, writeUrlState } from '../url-state.js';
|
||
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
|
||
import { trackUxEvent } from '../../../telemetry.js';
|
||
import { AsyncCombobox } from '../async-combobox.js';
|
||
|
||
export async function initSubjects() {
|
||
const subjectsTbody = document.getElementById('subjects-tbody');
|
||
const createSubjectForm = document.getElementById('create-subject-form');
|
||
const newSubjectDeptSelect = document.getElementById('new-subject-department');
|
||
const departmentFilter = document.getElementById('subjects-department-filter');
|
||
const searchInput = document.getElementById('subjects-search');
|
||
const sortSelect = document.getElementById('subjects-sort');
|
||
const pagination = document.getElementById('subjects-pagination');
|
||
|
||
// Модалка управления преподавателями
|
||
const modalManageTeachers = document.getElementById('modal-manage-teachers');
|
||
const modalManageTeachersClose = document.getElementById('modal-manage-teachers-close');
|
||
const subjectTeachersContext = document.getElementById('subject-teachers-context');
|
||
const manageTeacherSubjectForm = document.getElementById('manage-teacher-subject-form');
|
||
const manageTsSubjectIdInput = document.getElementById('manage-ts-subject-id');
|
||
const manageTsTeacherSelect = document.getElementById('manage-ts-teacher-select');
|
||
const manageTsTbody = document.getElementById('manage-ts-tbody');
|
||
|
||
let allSubjects = [];
|
||
let allDepartments = [];
|
||
let teacherSubjects = [];
|
||
let teacherCombobox = null;
|
||
const restored = readUrlState({ subjectQuery: '', subjectDepartment: '', subjectPage: '0', subjectSize: '25', subjectSort: 'name,asc' });
|
||
const pageState = {
|
||
query: restored.subjectQuery,
|
||
departmentId: restored.subjectDepartment,
|
||
page: Math.max(Number(restored.subjectPage) || 0, 0),
|
||
size: [25, 50, 100].includes(Number(restored.subjectSize)) ? Number(restored.subjectSize) : 25,
|
||
sort: restored.subjectSort
|
||
};
|
||
searchInput.value = pageState.query;
|
||
sortSelect.value = pageState.sort;
|
||
|
||
bindEvents();
|
||
await loadInitialData();
|
||
|
||
function bindEvents() {
|
||
createSubjectForm.addEventListener('submit', createSubject);
|
||
subjectsTbody.addEventListener('click', handleSubjectsTableClick);
|
||
|
||
manageTeacherSubjectForm.addEventListener('submit', saveTeacherAssignment);
|
||
manageTsTbody.addEventListener('click', handleTeacherAssignmentTableClick);
|
||
|
||
modalManageTeachersClose.addEventListener('click', () => modalManageTeachers.classList.remove('open'));
|
||
modalManageTeachers.addEventListener('click', (e) => {
|
||
if (e.target === modalManageTeachers) modalManageTeachers.classList.remove('open');
|
||
});
|
||
searchInput.addEventListener('input', debounce(() => {
|
||
pageState.query = searchInput.value.trim();
|
||
pageState.page = 0;
|
||
loadSubjects();
|
||
}));
|
||
departmentFilter.addEventListener('change', () => {
|
||
pageState.departmentId = departmentFilter.value;
|
||
pageState.page = 0;
|
||
loadSubjects();
|
||
});
|
||
sortSelect.addEventListener('change', () => {
|
||
pageState.sort = sortSelect.value;
|
||
pageState.page = 0;
|
||
loadSubjects();
|
||
});
|
||
}
|
||
|
||
async function loadInitialData() {
|
||
try {
|
||
allDepartments = await api.get('/api/departments');
|
||
teacherCombobox = new AsyncCombobox(manageTsTeacherSelect, {
|
||
placeholder: 'Найдите преподавателя по ФИО',
|
||
optionLabel: teacher => teacher.fullName || teacher.username,
|
||
optionDescription: teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · '),
|
||
load: async (query, selectedId) => {
|
||
const params = new URLSearchParams({ query, limit: '30' });
|
||
if (selectedId) params.set('selectedId', selectedId);
|
||
const teachers = await api.get(`/api/users/teachers/options?${params}`);
|
||
const subjectId = manageTsSubjectIdInput.value;
|
||
const assigned = new Set(teacherSubjects
|
||
.filter(item => String(item.subjectId) === String(subjectId))
|
||
.map(item => String(item.userId)));
|
||
return teachers.filter(teacher => !assigned.has(String(teacher.id)) || String(teacher.id) === String(selectedId));
|
||
}
|
||
});
|
||
populateDepartmentsSelect();
|
||
populateDepartmentFilter();
|
||
await loadSubjects();
|
||
} catch (e) {
|
||
subjectsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки справочников: ${escapeHtml(e.message)}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
function populateDepartmentsSelect() {
|
||
newSubjectDeptSelect.innerHTML = '<option value="">Выберите кафедру</option>' +
|
||
allDepartments.map(d => {
|
||
const name = d.departmentName || d.name || 'Без названия';
|
||
const code = d.departmentCode || d.code || d.id;
|
||
return `<option value="${d.id}">${escapeHtml(name)} (${escapeHtml(String(code))})</option>`;
|
||
}).join('');
|
||
}
|
||
|
||
function populateDepartmentFilter() {
|
||
departmentFilter.innerHTML = '<option value="">Все кафедры</option>' + allDepartments.map(department =>
|
||
`<option value="${department.id}">${escapeHtml(department.departmentName || department.name || department.id)}</option>`
|
||
).join('');
|
||
departmentFilter.value = pageState.departmentId;
|
||
}
|
||
|
||
async function loadSubjects() {
|
||
renderTableLoading(subjectsTbody, 6, 'Загрузка дисциплин...');
|
||
hideAlert('subjects-alert');
|
||
try {
|
||
const [sort, direction] = pageState.sort.split(',');
|
||
const params = new URLSearchParams({
|
||
query: pageState.query,
|
||
page: String(pageState.page),
|
||
size: String(pageState.size),
|
||
sort,
|
||
direction
|
||
});
|
||
if (pageState.departmentId) params.set('departmentId', pageState.departmentId);
|
||
const page = await api.get(`/api/subjects/page?${params}`);
|
||
if (!(page.items || []).length && page.totalItems > 0 && pageState.page > 0) {
|
||
pageState.page = Math.max(0, page.totalPages - 1);
|
||
return loadSubjects();
|
||
}
|
||
allSubjects = page.items || [];
|
||
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
|
||
renderSubjects();
|
||
renderPagination(pagination, page, {
|
||
label: 'дисциплин',
|
||
onPage: nextPage => { pageState.page = nextPage; loadSubjects(); },
|
||
onSize: size => { pageState.size = size; pageState.page = 0; loadSubjects(); }
|
||
});
|
||
persistState();
|
||
trackUxEvent('реестр_дисциплин_загружен', { total: Number(page.totalItems || 0), page: pageState.page });
|
||
} catch (e) {
|
||
renderTableError(subjectsTbody, 6, e.message || 'Не удалось загрузить дисциплины', loadSubjects);
|
||
}
|
||
}
|
||
|
||
async function loadTeacherAssignments(subjectIds) {
|
||
if (!subjectIds.length) return [];
|
||
const params = new URLSearchParams();
|
||
subjectIds.forEach(subjectId => params.append('subjectId', subjectId));
|
||
return api.get(`/api/teacher-subjects?${params}`);
|
||
}
|
||
|
||
function renderSubjects() {
|
||
if (!allSubjects || !allSubjects.length) {
|
||
renderTableEmpty(subjectsTbody, 6, pageState.query || pageState.departmentId
|
||
? 'Дисциплины не найдены'
|
||
: 'Дисциплин пока нет');
|
||
return;
|
||
}
|
||
|
||
// Группируем преподавателей по дисциплинам
|
||
const teachersBySubject = new Map();
|
||
teacherSubjects.forEach(ts => {
|
||
if (!teachersBySubject.has(ts.subjectId)) {
|
||
teachersBySubject.set(ts.subjectId, []);
|
||
}
|
||
teachersBySubject.get(ts.subjectId).push(ts);
|
||
});
|
||
|
||
subjectsTbody.innerHTML = allSubjects.map(s => {
|
||
const teachers = teachersBySubject.get(s.id) || [];
|
||
const teachersHtml = teachers.length
|
||
? teachers.map(t => `<span class="badge badge-ef" style="margin: 0.1rem;">${escapeHtml(t.fullName || t.username)}</span>`).join('')
|
||
: '<span class="text-muted"><small>Нет преподавателей</small></span>';
|
||
|
||
return `
|
||
<tr>
|
||
<td>${s.id}</td>
|
||
<td><strong>${escapeHtml(s.name)}</strong></td>
|
||
<td>${escapeHtml(s.code || '-')}</td>
|
||
<td>${escapeHtml(departmentLabel(s.departmentId))}</td>
|
||
<td><div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">${teachersHtml}</div></td>
|
||
<td style="text-align: right; white-space: nowrap;">
|
||
<button class="btn btn-sm btn-secondary btn-manage-teachers" data-id="${s.id}">Преподаватели</button>
|
||
<button class="btn btn-sm btn-danger btn-delete-subject" data-id="${s.id}">Удалить</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
markTableReady(subjectsTbody);
|
||
}
|
||
|
||
async function createSubject(e) {
|
||
e.preventDefault();
|
||
hideAlert('create-subject-alert');
|
||
const name = document.getElementById('new-subject-name').value.trim();
|
||
const code = document.getElementById('new-subject-code').value.trim();
|
||
const departmentId = newSubjectDeptSelect.value;
|
||
|
||
if (!name || !code || !departmentId) {
|
||
showAlert('create-subject-alert', 'Заполните все поля', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const data = await api.post('/api/subjects', {
|
||
name,
|
||
code,
|
||
departmentId: Number(departmentId)
|
||
});
|
||
showAlert('create-subject-alert', `Дисциплина "${escapeHtml(data.name || name)}" создана`, 'success');
|
||
createSubjectForm.reset();
|
||
await loadSubjects();
|
||
} catch (error) {
|
||
showAlert('create-subject-alert', error.message || 'Ошибка создания', 'error');
|
||
}
|
||
}
|
||
|
||
async function handleSubjectsTableClick(e) {
|
||
const btnDelete = e.target.closest('.btn-delete-subject');
|
||
const btnTeachers = e.target.closest('.btn-manage-teachers');
|
||
|
||
if (btnDelete) {
|
||
if (!await confirmAction({
|
||
title: 'Архивировать дисциплину?',
|
||
message: 'Дисциплина станет недоступна для новых назначений. Исторические данные сохранятся.',
|
||
confirmLabel: 'Архивировать',
|
||
danger: true
|
||
})) return;
|
||
try {
|
||
await api.delete('/api/subjects/' + btnDelete.dataset.id);
|
||
await loadSubjects();
|
||
} catch (error) {
|
||
showAlert('subjects-alert', error.message || 'Ошибка архивирования', 'error');
|
||
}
|
||
}
|
||
|
||
if (btnTeachers) {
|
||
const subjectId = btnTeachers.dataset.id;
|
||
const subject = allSubjects.find(s => s.id == subjectId);
|
||
if (subject) openTeachersModal(subject);
|
||
}
|
||
}
|
||
|
||
// --- Управление привязками преподавателей к дисциплине ---
|
||
function openTeachersModal(subject) {
|
||
subjectTeachersContext.textContent = `${subject.code ? `${subject.code} · ` : ''}${subject.name}`;
|
||
manageTsSubjectIdInput.value = subject.id;
|
||
hideAlert('manage-ts-alert');
|
||
modalManageTeachers.classList.add('open');
|
||
renderSubjectTeachers(subject.id);
|
||
populateTeachersSelect(subject.id);
|
||
}
|
||
|
||
function renderSubjectTeachers(subjectId) {
|
||
const list = teacherSubjects.filter(ts => String(ts.subjectId) === String(subjectId));
|
||
if (!list.length) {
|
||
manageTsTbody.innerHTML = '<tr><td colspan="2" class="loading-row">Преподаватели не привязаны</td></tr>';
|
||
return;
|
||
}
|
||
manageTsTbody.innerHTML = list.map(ts => `
|
||
<tr>
|
||
<td><strong>${escapeHtml(ts.fullName || ts.username)}</strong> <span class="text-muted">(${escapeHtml(ts.username)})</span></td>
|
||
<td>
|
||
<button class="btn btn-sm btn-danger btn-delete-ts" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function populateTeachersSelect(subjectId) {
|
||
manageTsTeacherSelect.innerHTML = '<option value=""></option>';
|
||
manageTsTeacherSelect.value = '';
|
||
teacherCombobox?.syncFromSelect();
|
||
teacherCombobox?.search('');
|
||
}
|
||
|
||
async function saveTeacherAssignment(e) {
|
||
e.preventDefault();
|
||
hideAlert('manage-ts-alert');
|
||
const subjectId = manageTsSubjectIdInput.value;
|
||
const userId = manageTsTeacherSelect.value;
|
||
|
||
if (!userId) {
|
||
showAlert('manage-ts-alert', 'Выберите преподавателя', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await api.post('/api/teacher-subjects', {
|
||
userId: Number(userId),
|
||
subjectId: Number(subjectId)
|
||
});
|
||
showAlert('manage-ts-alert', 'Преподаватель успешно привязан', 'success');
|
||
|
||
// Перезагрузим привязки и обновим интерфейс
|
||
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
|
||
renderSubjectTeachers(subjectId);
|
||
populateTeachersSelect(subjectId);
|
||
|
||
// Также обновим главную таблицу
|
||
renderSubjects();
|
||
} catch (error) {
|
||
showAlert('manage-ts-alert', error.message || 'Ошибка привязки', 'error');
|
||
}
|
||
}
|
||
|
||
async function handleTeacherAssignmentTableClick(e) {
|
||
const btnDelete = e.target.closest('.btn-delete-ts');
|
||
if (!btnDelete) return;
|
||
if (!await confirmAction({
|
||
title: 'Удалить привязку преподавателя?',
|
||
message: 'Преподаватель больше не будет доступен для этой дисциплины в новых правилах.',
|
||
confirmLabel: 'Удалить привязку',
|
||
danger: true
|
||
})) return;
|
||
const subjectId = manageTsSubjectIdInput.value;
|
||
|
||
try {
|
||
await api.delete('/api/teacher-subjects', {
|
||
userId: Number(btnDelete.dataset.userId),
|
||
subjectId: Number(btnDelete.dataset.subjectId)
|
||
});
|
||
|
||
// Перезагрузим привязки
|
||
teacherSubjects = await loadTeacherAssignments(allSubjects.map(subject => subject.id));
|
||
renderSubjectTeachers(subjectId);
|
||
populateTeachersSelect(subjectId);
|
||
|
||
// Обновим главную таблицу
|
||
renderSubjects();
|
||
} catch (error) {
|
||
showAlert('manage-ts-alert', error.message || 'Ошибка удаления привязки', 'error');
|
||
}
|
||
}
|
||
|
||
// --- Вспомогательные функции ---
|
||
function departmentLabel(departmentId) {
|
||
const department = allDepartments.find(item => item.id == departmentId);
|
||
if (!department) return departmentId || '-';
|
||
return department.departmentName || department.name || departmentId;
|
||
}
|
||
|
||
function persistState() {
|
||
writeUrlState({
|
||
subjectQuery: pageState.query,
|
||
subjectDepartment: pageState.departmentId,
|
||
subjectPage: pageState.page || '',
|
||
subjectSize: pageState.size === 25 ? '' : pageState.size,
|
||
subjectSort: pageState.sort === 'name,asc' ? '' : pageState.sort
|
||
});
|
||
}
|
||
}
|