991 lines
46 KiB
JavaScript
991 lines
46 KiB
JavaScript
import { api } from '../api.js';
|
||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||
import { buildAcademicCalendarTitle } from './academic-calendar-title.js';
|
||
import { AsyncCombobox } from '../async-combobox.js';
|
||
import { alertAction, confirmAction } from '../dialog.js';
|
||
import { renderPagination } from '../pagination.js';
|
||
import { debounce, readUrlState, readUrlValues, writeUrlState } from '../url-state.js';
|
||
import { markTableReady, renderTableEmpty, renderTableError, renderTableLoading } from '../view-state.js';
|
||
import { trackUxEvent } from '../../../telemetry.js';
|
||
|
||
async function fetchEducationForms() {
|
||
try {
|
||
return await api.get('/api/education-forms');
|
||
} catch (e) {
|
||
console.error('Не удалось загрузить формы обучения');
|
||
return [];
|
||
}
|
||
}
|
||
|
||
const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения';
|
||
|
||
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 newGroupDepartmentSelect = document.getElementById('new-group-department');
|
||
const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code');
|
||
const newGroupProfileSelect = document.getElementById('new-group-profile');
|
||
const filterEfBox = document.getElementById('filter-ef-box');
|
||
const filterEfMenu = document.getElementById('filter-ef-menu');
|
||
const filterEfText = document.getElementById('filter-ef-text');
|
||
const filterEfCheckboxes = document.getElementById('filter-ef-checkboxes');
|
||
const searchInput = document.getElementById('groups-search');
|
||
const sortSelect = document.getElementById('groups-sort');
|
||
const filterResetButton = document.getElementById('groups-filter-reset');
|
||
const pagination = document.getElementById('groups-pagination');
|
||
|
||
// Модалка группы
|
||
const modalEditGroup = document.getElementById('modal-edit-group');
|
||
const modalEditGroupClose = document.getElementById('modal-edit-group-close');
|
||
const editGroupForm = document.getElementById('edit-group-form');
|
||
const editGroupEfSelect = document.getElementById('edit-group-ef');
|
||
const editGroupDepartmentSelect = document.getElementById('edit-group-department');
|
||
const editGroupSpecialitySelect = document.getElementById('edit-group-speciality-code');
|
||
const editGroupProfileSelect = document.getElementById('edit-group-profile');
|
||
|
||
// Модалка подгрупп
|
||
const modalManageSubgroups = document.getElementById('modal-manage-subgroups');
|
||
const modalManageSubgroupsClose = document.getElementById('modal-manage-subgroups-close');
|
||
const subgroupsGroupContext = document.getElementById('subgroups-group-context');
|
||
const subgroupFormTitle = document.getElementById('subgroup-form-title');
|
||
const manageSubgroupForm = document.getElementById('manage-subgroup-form');
|
||
const manageSubgroupIdInput = document.getElementById('manage-subgroup-id');
|
||
const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id');
|
||
const manageSubgroupCountSelect = document.getElementById('manage-subgroup-count');
|
||
const manageSubgroupCapacities = document.getElementById('manage-subgroup-capacities');
|
||
const btnSaveSubgroup = document.getElementById('btn-save-subgroup');
|
||
const btnCancelSubgroupEdit = document.getElementById('btn-cancel-subgroup-edit');
|
||
const manageSubgroupsTbody = document.getElementById('manage-subgroups-tbody');
|
||
|
||
// Модалка календарей
|
||
const modalManageCalendar = document.getElementById('modal-manage-calendar');
|
||
const modalManageCalendarClose = document.getElementById('modal-manage-calendar-close');
|
||
const calendarGroupContext = document.getElementById('calendar-group-context');
|
||
const manageCalendarForm = document.getElementById('manage-calendar-form');
|
||
const manageCalendarGroupIdInput = document.getElementById('manage-calendar-group-id');
|
||
const manageCalendarYearSelect = document.getElementById('manage-calendar-year');
|
||
const manageCalendarSelect = document.getElementById('manage-calendar-id');
|
||
const manageCalendarTbody = document.getElementById('manage-calendar-tbody');
|
||
|
||
let allGroups = [];
|
||
let subgroups = [];
|
||
let educationForms = [];
|
||
let departments = [];
|
||
let specialties = [];
|
||
let profilesBySpecialty = new Map();
|
||
let academicYears = [];
|
||
let currentAssignments = [];
|
||
let activeSubgroupGroup = null;
|
||
let calendarCombobox = null;
|
||
const restored = readUrlState({ groupQuery: '', groupPage: '0', groupSize: '25', groupSort: 'name,asc' });
|
||
const pageState = {
|
||
query: restored.groupQuery,
|
||
page: Math.max(Number(restored.groupPage) || 0, 0),
|
||
size: [25, 50, 100].includes(Number(restored.groupSize)) ? Number(restored.groupSize) : 25,
|
||
sort: restored.groupSort,
|
||
educationFormIds: readUrlValues('groupEducationForm')
|
||
};
|
||
searchInput.value = pageState.query;
|
||
sortSelect.value = pageState.sort;
|
||
|
||
bindEvents();
|
||
await loadInitialData();
|
||
|
||
function bindEvents() {
|
||
initFilterEfDropdown();
|
||
filterEfCheckboxes.addEventListener('change', handleFilterEfChange);
|
||
newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value));
|
||
editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value));
|
||
|
||
createGroupForm.addEventListener('submit', createGroup);
|
||
editGroupForm.addEventListener('submit', updateGroup);
|
||
|
||
// Модалка подгрупп
|
||
manageSubgroupForm.addEventListener('submit', saveSubgroup);
|
||
manageSubgroupCountSelect.addEventListener('change', () => renderSubgroupCapacityFields(Number(manageSubgroupCountSelect.value)));
|
||
btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm);
|
||
manageSubgroupsTbody.addEventListener('click', handleSubgroupTableClick);
|
||
|
||
// Модалка календарей
|
||
manageCalendarForm.addEventListener('submit', saveAssignment);
|
||
manageCalendarYearSelect.addEventListener('change', () => {
|
||
calendarCombobox?.clear();
|
||
populateCalendarSelect();
|
||
});
|
||
searchInput.addEventListener('input', debounce(() => {
|
||
pageState.query = searchInput.value.trim();
|
||
pageState.page = 0;
|
||
loadGroups();
|
||
}));
|
||
sortSelect.addEventListener('change', () => {
|
||
pageState.sort = sortSelect.value;
|
||
pageState.page = 0;
|
||
loadGroups();
|
||
});
|
||
filterResetButton.addEventListener('click', () => {
|
||
pageState.query = '';
|
||
pageState.page = 0;
|
||
pageState.sort = 'name,asc';
|
||
pageState.educationFormIds = [];
|
||
searchInput.value = '';
|
||
sortSelect.value = pageState.sort;
|
||
renderFilterEfCheckboxes(educationForms);
|
||
loadGroups();
|
||
});
|
||
|
||
groupsTbody.addEventListener('click', handleGroupTableClick);
|
||
|
||
// Закрытие модалок
|
||
modalEditGroupClose.addEventListener('click', () => modalEditGroup.classList.remove('open'));
|
||
modalEditGroup.addEventListener('click', (event) => {
|
||
if (event.target === modalEditGroup) modalEditGroup.classList.remove('open');
|
||
});
|
||
|
||
modalManageSubgroupsClose.addEventListener('click', () => modalManageSubgroups.classList.remove('open'));
|
||
modalManageSubgroups.addEventListener('click', (event) => {
|
||
if (event.target === modalManageSubgroups) modalManageSubgroups.classList.remove('open');
|
||
});
|
||
|
||
modalManageCalendarClose.addEventListener('click', () => modalManageCalendar.classList.remove('open'));
|
||
modalManageCalendar.addEventListener('click', (event) => {
|
||
if (event.target === modalManageCalendar) modalManageCalendar.classList.remove('open');
|
||
});
|
||
}
|
||
|
||
function initFilterEfDropdown() {
|
||
if (!filterEfBox || !filterEfMenu) return;
|
||
|
||
const toggleDropdown = (event) => {
|
||
event.stopPropagation();
|
||
const isOpen = filterEfMenu.classList.contains('open');
|
||
document.querySelectorAll('.dropdown-menu').forEach(menu => menu.classList.remove('open'));
|
||
document.querySelectorAll('.select-box').forEach(box => box.classList.remove('active'));
|
||
|
||
if (!isOpen) {
|
||
filterEfMenu.classList.add('open');
|
||
filterEfBox.classList.add('active');
|
||
filterEfBox.setAttribute('aria-expanded', 'true');
|
||
} else {
|
||
filterEfBox.setAttribute('aria-expanded', 'false');
|
||
}
|
||
};
|
||
|
||
filterEfBox.addEventListener('click', toggleDropdown);
|
||
filterEfBox.addEventListener('keydown', (event) => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault();
|
||
toggleDropdown(event);
|
||
}
|
||
});
|
||
|
||
filterEfMenu.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
});
|
||
}
|
||
|
||
async function loadInitialData() {
|
||
try {
|
||
[educationForms, departments, specialties, academicYears] = await Promise.all([
|
||
fetchEducationForms(),
|
||
api.get('/api/departments'),
|
||
api.get('/api/specialties'),
|
||
api.get('/api/admin/calendar/years')
|
||
]);
|
||
await loadProfiles();
|
||
populateEfSelects(educationForms);
|
||
populateDepartmentSelects(departments);
|
||
populateSpecialitySelects(specialties);
|
||
populateYearSelect();
|
||
initCalendarCombobox();
|
||
await loadGroups();
|
||
} catch (error) {
|
||
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
async function loadProfiles() {
|
||
const pairs = await Promise.all(specialties.map(async (speciality) => {
|
||
const profiles = await api.get(`/api/specialties/${speciality.id}/profiles`);
|
||
return [String(speciality.id), profiles];
|
||
}));
|
||
profilesBySpecialty = new Map(pairs);
|
||
}
|
||
|
||
async function loadGroups() {
|
||
renderTableLoading(groupsTbody, 10, 'Загрузка групп...');
|
||
try {
|
||
const [sort, direction] = pageState.sort.split(',');
|
||
const params = new URLSearchParams({
|
||
query: pageState.query,
|
||
page: String(pageState.page),
|
||
size: String(pageState.size),
|
||
sort,
|
||
direction,
|
||
includeArchived: 'true'
|
||
});
|
||
pageState.educationFormIds.forEach(id => params.append('educationFormId', id));
|
||
const result = await api.get(`/api/groups/page?${params}`);
|
||
allGroups = result.items || [];
|
||
if (!allGroups.length && result.totalItems > 0 && pageState.page > 0) {
|
||
pageState.page = Math.max(0, result.totalPages - 1);
|
||
return loadGroups();
|
||
}
|
||
renderGroups(allGroups);
|
||
renderPagination(pagination, result, {
|
||
label: 'групп',
|
||
onPage: page => { pageState.page = page; loadGroups(); },
|
||
onSize: size => { pageState.size = size; pageState.page = 0; loadGroups(); }
|
||
});
|
||
persistPageState();
|
||
trackUxEvent('реестр_групп_загружен', { total: Number(result.totalItems || 0), page: pageState.page });
|
||
} catch (error) {
|
||
renderTableError(groupsTbody, 10, error.message || 'Не удалось загрузить группы', loadGroups);
|
||
}
|
||
}
|
||
|
||
async function loadSubgroups(groupId) {
|
||
try {
|
||
subgroups = groupId ? await api.get(`/api/groups/${groupId}/subgroups`) : [];
|
||
} catch (error) {
|
||
subgroups = [];
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function applyGroupFilter() {
|
||
pageState.educationFormIds = selectedFilterEducationFormIds();
|
||
pageState.page = 0;
|
||
loadGroups();
|
||
}
|
||
|
||
function handleFilterEfChange(event) {
|
||
const changedCheckbox = event.target.closest('input[type="checkbox"]');
|
||
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
|
||
|
||
if (changedCheckbox?.value === 'all' && changedCheckbox.checked) {
|
||
filterEfCheckboxes.querySelectorAll('input[type="checkbox"]:not([value="all"])').forEach(checkbox => {
|
||
checkbox.checked = false;
|
||
});
|
||
} else if (changedCheckbox?.value !== 'all' && changedCheckbox?.checked) {
|
||
allCheckbox.checked = false;
|
||
}
|
||
|
||
if (!selectedFilterEducationFormIds().length) {
|
||
allCheckbox.checked = true;
|
||
}
|
||
|
||
updateFilterEfText();
|
||
applyGroupFilter();
|
||
}
|
||
|
||
function selectedFilterEducationFormIds() {
|
||
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
|
||
if (allCheckbox?.checked) return [];
|
||
|
||
return Array.from(filterEfCheckboxes.querySelectorAll('input[type="checkbox"]:checked:not([value="all"])'))
|
||
.map(checkbox => String(checkbox.value));
|
||
}
|
||
|
||
function updateFilterEfText() {
|
||
const allCheckbox = filterEfCheckboxes.querySelector('input[value="all"]');
|
||
const selectedIds = selectedFilterEducationFormIds();
|
||
if (allCheckbox?.checked) {
|
||
filterEfText.textContent = 'Все';
|
||
return;
|
||
}
|
||
|
||
if (!selectedIds.length) {
|
||
filterEfText.textContent = FILTER_EF_EMPTY_TEXT;
|
||
return;
|
||
}
|
||
|
||
if (selectedIds.length === 1) {
|
||
const form = educationForms.find(item => String(item.id) === selectedIds[0]);
|
||
filterEfText.textContent = form?.name || FILTER_EF_EMPTY_TEXT;
|
||
return;
|
||
}
|
||
|
||
filterEfText.textContent = `Выбрано: ${selectedIds.length}`;
|
||
}
|
||
|
||
function renderGroups(groups) {
|
||
if (!groups || !groups.length) {
|
||
renderTableEmpty(groupsTbody, 10, pageState.query || pageState.educationFormIds.length
|
||
? 'По заданным условиям группы не найдены'
|
||
: 'Групп пока нет');
|
||
return;
|
||
}
|
||
groupsTbody.innerHTML = groups.map(group => `
|
||
<tr>
|
||
<td>${group.id}</td>
|
||
<td><strong>${escapeHtml(group.name)}</strong></td>
|
||
<td>${escapeHtml(String(group.groupSize))}</td>
|
||
<td><span class="badge badge-ef">${escapeHtml(group.educationFormName)}</span></td>
|
||
<td>${escapeHtml(departmentLabel(group.departmentId))}</td>
|
||
<td>${group.course || '-'}</td>
|
||
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
|
||
<td>${escapeHtml(group.specialtyProfileName || '-')}</td>
|
||
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
|
||
<td class="group-actions-cell">
|
||
<div class="group-actions">
|
||
<button class="btn btn-sm btn-secondary btn-edit-group group-action-btn" data-id="${group.id}">Изменить</button>
|
||
<button class="btn btn-sm btn-secondary btn-manage-subgroups group-action-btn" data-id="${group.id}">Подгруппы</button>
|
||
<button class="btn btn-sm btn-secondary btn-manage-calendar group-action-btn" data-id="${group.id}">Календарь</button>
|
||
${group.status === 'ARCHIVED'
|
||
? `<button class="btn-restore-group group-action-btn" data-id="${group.id}">Восстановить</button>`
|
||
: `<button class="btn btn-sm btn-danger-subtle btn-archive-group group-action-btn" data-id="${group.id}">Архив</button>`}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
markTableReady(groupsTbody);
|
||
}
|
||
|
||
function populateEfSelects(forms) {
|
||
populateEfSelect(newGroupEfSelect, forms);
|
||
populateEfSelect(editGroupEfSelect, forms);
|
||
renderFilterEfCheckboxes(forms);
|
||
}
|
||
|
||
function renderFilterEfCheckboxes(forms) {
|
||
const selectedIds = new Set(pageState.educationFormIds);
|
||
const allChecked = !selectedIds.size;
|
||
filterEfCheckboxes.innerHTML = `
|
||
<label class="checkbox-item">
|
||
<input type="checkbox" value="all" ${allChecked ? 'checked' : ''}>
|
||
<span class="checkmark"></span>
|
||
<span>Все</span>
|
||
</label>
|
||
${forms.map(form => {
|
||
const id = String(form.id);
|
||
return `
|
||
<label class="checkbox-item">
|
||
<input type="checkbox" value="${escapeHtml(id)}" ${selectedIds.has(id) ? 'checked' : ''}>
|
||
<span class="checkmark"></span>
|
||
<span>${escapeHtml(form.name)}</span>
|
||
</label>
|
||
`;
|
||
}).join('')}
|
||
`;
|
||
updateFilterEfText();
|
||
}
|
||
|
||
function populateEfSelect(select, forms) {
|
||
const currentVal = select.value;
|
||
select.innerHTML = forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
|
||
if (currentVal && forms.find(form => form.id == currentVal)) {
|
||
select.value = currentVal;
|
||
}
|
||
syncSelects(select);
|
||
}
|
||
|
||
function populateDepartmentSelects(items) {
|
||
populateDepartmentSelect(newGroupDepartmentSelect, items);
|
||
populateDepartmentSelect(editGroupDepartmentSelect, items);
|
||
}
|
||
|
||
function populateDepartmentSelect(select, items) {
|
||
const currentVal = select.value;
|
||
select.innerHTML = '<option value="">Выберите кафедру</option>' +
|
||
items.map(department => {
|
||
const name = department.departmentName || department.name || 'Без названия';
|
||
const code = department.departmentCode || department.code || department.id;
|
||
return `<option value="${department.id}">${escapeHtml(name)} (${escapeHtml(String(code))})</option>`;
|
||
}).join('');
|
||
if (currentVal && items.find(department => department.id == currentVal)) {
|
||
select.value = currentVal;
|
||
}
|
||
syncSelects(select);
|
||
}
|
||
|
||
function populateSpecialitySelects(items) {
|
||
populateSpecialitySelect(newGroupSpecialitySelect, items);
|
||
populateSpecialitySelect(editGroupSpecialitySelect, items);
|
||
populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value);
|
||
populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value);
|
||
}
|
||
|
||
function populateSpecialitySelect(select, items) {
|
||
const currentVal = select.value;
|
||
select.innerHTML = '<option value="">Выберите специальность</option>' +
|
||
items.map(speciality => `<option value="${speciality.id}">${escapeHtml(specialityLabel(speciality.id))}</option>`).join('');
|
||
if (currentVal && items.find(speciality => speciality.id == currentVal)) {
|
||
select.value = currentVal;
|
||
}
|
||
syncSelects(select);
|
||
}
|
||
|
||
function populateProfileSelect(select, specialtyId, selectedProfileId = null) {
|
||
const profiles = profilesBySpecialty.get(String(specialtyId)) || [];
|
||
select.innerHTML = profiles.length
|
||
? '<option value="">Выберите профиль</option>' + profiles.map(profile =>
|
||
`<option value="${profile.id}">${escapeHtml(profile.name)}</option>`
|
||
).join('')
|
||
: '<option value="">Нет профилей</option>';
|
||
if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) {
|
||
select.value = selectedProfileId;
|
||
}
|
||
syncSelects(select);
|
||
}
|
||
|
||
function populateYearSelect() {
|
||
manageCalendarYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
|
||
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
|
||
if (academicYears.length) {
|
||
manageCalendarYearSelect.value = String(academicYears[0].id);
|
||
}
|
||
syncSelects(manageCalendarYearSelect);
|
||
}
|
||
|
||
function populateCalendarSelect() {
|
||
calendarCombobox?.search('');
|
||
}
|
||
|
||
function initCalendarCombobox() {
|
||
calendarCombobox = new AsyncCombobox(manageCalendarSelect, {
|
||
placeholder: 'Найдите совместимый график',
|
||
emptyLabel: 'Совместимых графиков не найдено',
|
||
optionLabel: calendar => buildAcademicCalendarTitle(calendar) || calendar.title || `График ${calendar.id}`,
|
||
optionDescription: calendar => `${calendar.academicYearTitle || ''} · ${calendar.studyFormName || ''}`,
|
||
load: async (query, selectedId) => {
|
||
const group = allGroups.find(item => String(item.id) === String(manageCalendarGroupIdInput.value));
|
||
if (!group || !manageCalendarYearSelect.value) return [];
|
||
const params = new URLSearchParams({
|
||
query,
|
||
academicYearId: manageCalendarYearSelect.value,
|
||
specialtyId: String(group.specialtyId || group.specialityCode),
|
||
profileId: String(group.specialtyProfileId),
|
||
studyFormId: String(group.educationFormId),
|
||
limit: '30'
|
||
});
|
||
if (selectedId) params.set('selectedId', selectedId);
|
||
return api.get(`/api/admin/academic-calendars/options?${params}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
function persistPageState() {
|
||
writeUrlState({
|
||
groupQuery: pageState.query,
|
||
groupPage: pageState.page || '',
|
||
groupSize: pageState.size === 25 ? '' : pageState.size,
|
||
groupSort: pageState.sort === 'name,asc' ? '' : pageState.sort,
|
||
groupEducationForm: pageState.educationFormIds
|
||
});
|
||
}
|
||
|
||
async function createGroup(event) {
|
||
event.preventDefault();
|
||
hideAlert('create-group-alert');
|
||
const payload = readGroupPayload('new');
|
||
if (!payload) return;
|
||
|
||
try {
|
||
const data = await api.post('/api/groups', payload);
|
||
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" создана`, 'success');
|
||
createGroupForm.reset();
|
||
syncSelects(newGroupEfSelect, newGroupDepartmentSelect, newGroupSpecialitySelect, newGroupProfileSelect);
|
||
await loadGroups();
|
||
} catch (error) {
|
||
showAlert('create-group-alert', error.message || 'Ошибка создания', 'error');
|
||
}
|
||
}
|
||
|
||
async function updateGroup(event) {
|
||
event.preventDefault();
|
||
hideAlert('edit-group-alert');
|
||
const id = document.getElementById('edit-group-id').value;
|
||
const payload = readGroupPayload('edit');
|
||
if (!payload) return;
|
||
|
||
try {
|
||
const data = await api.put('/api/groups/' + id, payload);
|
||
modalEditGroup.classList.remove('open');
|
||
showAlert('create-group-alert', `Группа "${escapeHtml(data.name || payload.name)}" обновлена`, 'success');
|
||
await loadGroups();
|
||
} catch (error) {
|
||
showAlert('edit-group-alert', error.message || 'Ошибка обновления', 'error');
|
||
}
|
||
}
|
||
|
||
function readGroupPayload(prefix) {
|
||
const isEdit = prefix === 'edit';
|
||
const alertId = isEdit ? 'edit-group-alert' : 'create-group-alert';
|
||
const name = document.getElementById(`${prefix}-group-name`).value.trim();
|
||
const groupSize = document.getElementById(`${prefix}-group-size`).value;
|
||
const educationFormId = (isEdit ? editGroupEfSelect : newGroupEfSelect).value;
|
||
const departmentId = (isEdit ? editGroupDepartmentSelect : newGroupDepartmentSelect).value;
|
||
const yearStartStudy = document.getElementById(`${prefix}-group-yearStartStudy`).value;
|
||
const specialtyId = (isEdit ? editGroupSpecialitySelect : newGroupSpecialitySelect).value;
|
||
const specialtyProfileId = (isEdit ? editGroupProfileSelect : newGroupProfileSelect).value;
|
||
|
||
if (!name) { showAlert(alertId, 'Введите название группы', 'error'); return null; }
|
||
if (!groupSize) { showAlert(alertId, 'Введите размер группы', 'error'); return null; }
|
||
if (!educationFormId) { showAlert(alertId, 'Выберите форму обучения', 'error'); return null; }
|
||
if (!departmentId) { showAlert(alertId, 'Выберите кафедру', 'error'); return null; }
|
||
if (!yearStartStudy) { showAlert(alertId, 'Введите год начала обучения', 'error'); return null; }
|
||
if (!specialtyId) { showAlert(alertId, 'Выберите специальность', 'error'); return null; }
|
||
if (!specialtyProfileId) { showAlert(alertId, 'Выберите профиль обучения', 'error'); return null; }
|
||
|
||
return {
|
||
name,
|
||
groupSize: Number(groupSize),
|
||
educationFormId: Number(educationFormId),
|
||
departmentId: Number(departmentId),
|
||
yearStartStudy: Number(yearStartStudy),
|
||
specialtyId: Number(specialtyId),
|
||
specialtyProfileId: Number(specialtyProfileId)
|
||
};
|
||
}
|
||
|
||
async function handleGroupTableClick(event) {
|
||
const btnDelete = event.target.closest('.btn-archive-group');
|
||
const btnEdit = event.target.closest('.btn-edit-group');
|
||
const btnRestore = event.target.closest('.btn-restore-group');
|
||
const btnSubgroups = event.target.closest('.btn-manage-subgroups');
|
||
const btnCalendar = event.target.closest('.btn-manage-calendar');
|
||
|
||
if (btnDelete) {
|
||
if (!await confirmAction({
|
||
title: 'Архивировать группу?',
|
||
message: 'Группа исчезнет из рабочих списков, но история расписания сохранится.',
|
||
confirmLabel: 'Архивировать',
|
||
danger: true
|
||
})) return;
|
||
try {
|
||
await api.delete('/api/groups/' + btnDelete.dataset.id);
|
||
await loadGroups();
|
||
} catch (error) {
|
||
await alertAction({ title: 'Не удалось архивировать группу', message: error.message || 'Ошибка архивирования' });
|
||
}
|
||
}
|
||
|
||
if (btnRestore) {
|
||
try {
|
||
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
|
||
await loadGroups();
|
||
} catch (error) {
|
||
await alertAction({ title: 'Не удалось восстановить группу', message: error.message || 'Ошибка восстановления' });
|
||
}
|
||
}
|
||
|
||
if (btnEdit) {
|
||
openEditGroupModal(btnEdit.dataset.id);
|
||
}
|
||
|
||
if (btnSubgroups) {
|
||
const groupId = btnSubgroups.dataset.id;
|
||
const group = allGroups.find(g => g.id == groupId);
|
||
if (group) await openSubgroupsModal(group);
|
||
}
|
||
|
||
if (btnCalendar) {
|
||
const groupId = btnCalendar.dataset.id;
|
||
const group = allGroups.find(g => g.id == groupId);
|
||
if (group) openCalendarModal(group);
|
||
}
|
||
}
|
||
|
||
async function openEditGroupModal(id) {
|
||
const group = allGroups.find(item => item.id == id);
|
||
if (!group) {
|
||
await alertAction({ title: 'Группа не найдена', message: 'Обновите список и повторите действие.' });
|
||
return;
|
||
}
|
||
|
||
document.getElementById('edit-group-id').value = group.id;
|
||
document.getElementById('edit-group-name').value = group.name || '';
|
||
document.getElementById('edit-group-size').value = group.groupSize || '';
|
||
editGroupEfSelect.value = group.educationFormId || '';
|
||
editGroupDepartmentSelect.value = group.departmentId || '';
|
||
document.getElementById('edit-group-yearStartStudy').value = group.yearStartStudy || '';
|
||
editGroupSpecialitySelect.value = group.specialtyId || group.specialityCode || '';
|
||
populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value, group.specialtyProfileId);
|
||
syncSelects(editGroupEfSelect, editGroupDepartmentSelect, editGroupSpecialitySelect, editGroupProfileSelect);
|
||
hideAlert('edit-group-alert');
|
||
modalEditGroup.classList.add('open');
|
||
}
|
||
|
||
// --- Управление подгруппами ---
|
||
async function openSubgroupsModal(group) {
|
||
activeSubgroupGroup = group;
|
||
subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`;
|
||
manageSubgroupGroupIdInput.value = group.id;
|
||
modalManageSubgroups.classList.add('open');
|
||
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Загрузка подгрупп...</td></tr>';
|
||
try {
|
||
await loadSubgroups(group.id);
|
||
resetSubgroupForm();
|
||
renderSubgroupsList(group.id);
|
||
} catch (error) {
|
||
showAlert('manage-subgroup-alert', error.message || 'Не удалось загрузить подгруппы', 'error');
|
||
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Не удалось загрузить подгруппы</td></tr>';
|
||
}
|
||
}
|
||
|
||
function renderSubgroupsList(groupId) {
|
||
const list = subgroups.filter(sub => String(sub.groupId) === String(groupId));
|
||
if (!list.length) {
|
||
manageSubgroupsTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Подгруппы не созданы</td></tr>';
|
||
return;
|
||
}
|
||
const canDeleteSingleSubgroup = list.length === 1;
|
||
const deleteTitle = canDeleteSingleSubgroup
|
||
? 'Удалить подгруппу'
|
||
: 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»';
|
||
manageSubgroupsTbody.innerHTML = list.map(sub => `
|
||
<tr>
|
||
<td>${escapeHtml(sub.name)}</td>
|
||
<td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td>
|
||
<td>
|
||
<button class="btn btn-sm btn-secondary btn-edit-subgroup" data-id="${sub.id}">Изменить</button>
|
||
<button class="btn btn-sm btn-danger btn-delete-subgroup" data-id="${sub.id}"
|
||
${canDeleteSingleSubgroup ? '' : 'disabled'}
|
||
title="${escapeHtml(deleteTitle)}">${canDeleteSingleSubgroup ? 'Удалить' : 'Изменить выше'}</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function resetSubgroupForm() {
|
||
manageSubgroupIdInput.value = '';
|
||
const current = currentGroupSubgroups(manageSubgroupGroupIdInput.value);
|
||
const count = [2, 3].includes(current.length) ? current.length : 0;
|
||
manageSubgroupCountSelect.value = String(count);
|
||
syncSelects(manageSubgroupCountSelect);
|
||
renderSubgroupCapacityFields(count, current.map(item => item.studentCapacity));
|
||
subgroupFormTitle.textContent = 'Настройка подгрупп';
|
||
btnSaveSubgroup.textContent = 'Применить';
|
||
btnCancelSubgroupEdit.hidden = true;
|
||
hideAlert('manage-subgroup-alert');
|
||
}
|
||
|
||
function currentGroupSubgroups(groupId) {
|
||
return subgroups
|
||
.filter(sub => String(sub.groupId) === String(groupId))
|
||
.sort((left, right) => naturalSubgroupOrder(left.name).localeCompare(naturalSubgroupOrder(right.name), 'ru'));
|
||
}
|
||
|
||
function naturalSubgroupOrder(name) {
|
||
const number = String(name || '').match(/\d+/)?.[0];
|
||
return number ? String(number).padStart(3, '0') : String(name || '');
|
||
}
|
||
|
||
function renderSubgroupCapacityFields(count, capacities = []) {
|
||
manageSubgroupCapacities.className = `subgroup-capacity-fields subgroup-capacity-fields-${count}`;
|
||
if (!count) {
|
||
manageSubgroupCapacities.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
const groupSize = Number(activeSubgroupGroup?.groupSize || 0);
|
||
const suggested = suggestedSubgroupCapacities(groupSize, count);
|
||
manageSubgroupCapacities.innerHTML = Array.from({ length: count }, (_, index) => {
|
||
const value = capacities[index] ?? suggested[index] ?? '';
|
||
return `
|
||
<div class="form-group subgroup-capacity-field">
|
||
<label for="manage-subgroup-capacity-${index + 1}">Численность ${index + 1}</label>
|
||
<input type="number" id="manage-subgroup-capacity-${index + 1}" class="manage-subgroup-capacity"
|
||
min="1" max="${groupSize || ''}" value="${escapeHtml(String(value))}" required>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function suggestedSubgroupCapacities(groupSize, count) {
|
||
if (!groupSize || !count) return [];
|
||
const base = Math.floor(groupSize / count);
|
||
const remainder = groupSize % count;
|
||
return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0));
|
||
}
|
||
|
||
async function saveSubgroup(event) {
|
||
event.preventDefault();
|
||
hideAlert('manage-subgroup-alert');
|
||
const groupId = manageSubgroupGroupIdInput.value;
|
||
const group = activeSubgroupGroup || allGroups.find(item => String(item.id) === String(groupId));
|
||
const count = Number(manageSubgroupCountSelect.value);
|
||
const existingSubgroups = currentGroupSubgroups(groupId);
|
||
|
||
if (!group) {
|
||
showAlert('manage-subgroup-alert', 'Группа не найдена', 'error');
|
||
return;
|
||
}
|
||
|
||
if (count === 0) {
|
||
await applyNoSubgroups(groupId, existingSubgroups);
|
||
return;
|
||
}
|
||
|
||
const capacities = Array.from(manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity'))
|
||
.map(input => Number(input.value));
|
||
const validationError = validateSubgroupCapacities(capacities, Number(group.groupSize), count);
|
||
if (validationError) {
|
||
showAlert('manage-subgroup-alert', validationError, 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await syncSubgroups(groupId, existingSubgroups, capacities);
|
||
showAlert('manage-subgroup-alert', 'Подгруппы сохранены', 'success');
|
||
await loadSubgroups(groupId);
|
||
resetSubgroupForm();
|
||
renderSubgroupsList(groupId);
|
||
} catch (error) {
|
||
showAlert('manage-subgroup-alert', error.message || 'Ошибка сохранения подгруппы', 'error');
|
||
}
|
||
}
|
||
|
||
function validateSubgroupCapacities(capacities, groupSize, count) {
|
||
if (capacities.length !== count || capacities.some(value => !Number.isInteger(value) || value <= 0)) {
|
||
return 'Укажите положительную численность для каждой подгруппы';
|
||
}
|
||
|
||
const total = capacities.reduce((sum, value) => sum + value, 0);
|
||
if (total !== groupSize) {
|
||
return `Сумма численностей подгрупп должна быть равна численности группы (${groupSize} чел.)`;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
async function applyNoSubgroups(groupId, existingSubgroups) {
|
||
try {
|
||
for (const subgroup of existingSubgroups) {
|
||
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
|
||
}
|
||
showAlert('manage-subgroup-alert', 'Деление на подгруппы отключено', 'success');
|
||
await loadSubgroups(groupId);
|
||
resetSubgroupForm();
|
||
renderSubgroupsList(groupId);
|
||
} catch (error) {
|
||
showAlert('manage-subgroup-alert', error.message || 'Ошибка отключения подгрупп', 'error');
|
||
}
|
||
}
|
||
|
||
async function syncSubgroups(groupId, existingSubgroups, capacities) {
|
||
const targetCount = capacities.length;
|
||
const extraSubgroups = existingSubgroups.slice(targetCount);
|
||
for (const subgroup of extraSubgroups) {
|
||
await api.delete(`/api/groups/${groupId}/subgroups/${subgroup.id}`);
|
||
}
|
||
|
||
const updates = [];
|
||
const creates = [];
|
||
for (let index = 0; index < targetCount; index += 1) {
|
||
const payload = {
|
||
name: `Подгруппа ${index + 1}`,
|
||
studentCapacity: capacities[index]
|
||
};
|
||
const existing = existingSubgroups[index];
|
||
if (existing) {
|
||
updates.push({ existing, payload });
|
||
} else {
|
||
creates.push(payload);
|
||
}
|
||
}
|
||
|
||
updates.sort((left, right) => {
|
||
const leftIncreases = left.payload.studentCapacity > Number(left.existing.studentCapacity || 0);
|
||
const rightIncreases = right.payload.studentCapacity > Number(right.existing.studentCapacity || 0);
|
||
return Number(leftIncreases) - Number(rightIncreases);
|
||
});
|
||
|
||
for (const update of updates) {
|
||
await api.put(`/api/groups/${groupId}/subgroups/${update.existing.id}`, update.payload);
|
||
}
|
||
|
||
for (const payload of creates) {
|
||
await api.post(`/api/groups/${groupId}/subgroups`, payload);
|
||
}
|
||
}
|
||
|
||
async function handleSubgroupTableClick(event) {
|
||
const editButton = event.target.closest('.btn-edit-subgroup');
|
||
const deleteButton = event.target.closest('.btn-delete-subgroup');
|
||
const groupId = manageSubgroupGroupIdInput.value;
|
||
|
||
if (editButton) {
|
||
const subgroup = subgroups.find(item => item.id == editButton.dataset.id);
|
||
if (!subgroup) return;
|
||
|
||
resetSubgroupForm();
|
||
const inputIndex = currentGroupSubgroups(groupId).findIndex(item => item.id === subgroup.id);
|
||
const input = manageSubgroupCapacities.querySelectorAll('.manage-subgroup-capacity')[inputIndex];
|
||
input?.focus();
|
||
hideAlert('manage-subgroup-alert');
|
||
return;
|
||
}
|
||
|
||
if (deleteButton) {
|
||
if (deleteButton.disabled) {
|
||
showAlert('manage-subgroup-alert', 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»', 'error');
|
||
return;
|
||
}
|
||
if (!await confirmAction({
|
||
title: 'Удалить подгруппу?',
|
||
message: 'Удаление возможно только если подгруппа не используется в расписании.',
|
||
confirmLabel: 'Удалить',
|
||
danger: true
|
||
})) return;
|
||
try {
|
||
await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`);
|
||
showAlert('manage-subgroup-alert', 'Подгруппа удалена', 'success');
|
||
resetSubgroupForm();
|
||
await loadSubgroups(groupId);
|
||
renderSubgroupsList(groupId);
|
||
} catch (error) {
|
||
showAlert('manage-subgroup-alert', error.message || 'Ошибка удаления подгруппы', 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Управление календарными графиками ---
|
||
async function openCalendarModal(group) {
|
||
calendarGroupContext.textContent = `Группа: ${group.name} (${specialityLabel(group.specialtyId || group.specialityCode)})`;
|
||
manageCalendarGroupIdInput.value = group.id;
|
||
hideAlert('manage-calendar-alert');
|
||
populateCalendarSelect();
|
||
modalManageCalendar.classList.add('open');
|
||
await loadAssignmentsList(group.id);
|
||
}
|
||
|
||
async function loadAssignmentsList(groupId) {
|
||
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Загрузка...</td></tr>';
|
||
try {
|
||
currentAssignments = await api.get(`/api/groups/${groupId}/calendar-assignments`);
|
||
renderAssignmentsList();
|
||
} catch (error) {
|
||
manageCalendarTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка: ${escapeHtml(error.message)}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
function renderAssignmentsList() {
|
||
if (!currentAssignments.length) {
|
||
manageCalendarTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Календарные графики не назначены</td></tr>';
|
||
return;
|
||
}
|
||
manageCalendarTbody.innerHTML = currentAssignments.map(assignment => `
|
||
<tr>
|
||
<td>${escapeHtml(assignment.academicYearTitle)}</td>
|
||
<td>${escapeHtml(assignmentCalendarTitle(assignment))}</td>
|
||
<td>${renderAssignmentSubjects(assignment.subjects || [])}</td>
|
||
<td>
|
||
<button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function assignmentCalendarTitle(assignment) {
|
||
return assignment.calendarTitle || '-';
|
||
}
|
||
|
||
function renderAssignmentSubjects(subjects) {
|
||
if (!subjects.length) {
|
||
return '<span class="muted">Не привязаны</span>';
|
||
}
|
||
const grouped = subjects.reduce((acc, subject) => {
|
||
const key = String(subject.semesterNumber || '-');
|
||
if (!acc.has(key)) acc.set(key, []);
|
||
acc.get(key).push(subject);
|
||
return acc;
|
||
}, new Map());
|
||
return `<div class="assignment-subjects">${Array.from(grouped.entries())
|
||
.sort(([a], [b]) => Number(a) - Number(b))
|
||
.map(([semesterNumber, items]) => `
|
||
<div class="assignment-subject-semester">
|
||
<strong>${escapeHtml(semesterNumber)} семестр</strong>
|
||
<span>${items.map(subject => escapeHtml(subjectLabel(subject))).join(', ')}</span>
|
||
</div>
|
||
`).join('')}</div>`;
|
||
}
|
||
|
||
async function saveAssignment(event) {
|
||
event.preventDefault();
|
||
hideAlert('manage-calendar-alert');
|
||
const groupId = manageCalendarGroupIdInput.value;
|
||
const academicYearId = manageCalendarYearSelect.value;
|
||
const calendarId = manageCalendarSelect.value;
|
||
|
||
if (!academicYearId || !calendarId) {
|
||
showAlert('manage-calendar-alert', 'Выберите учебный год и график', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await api.put(`/api/groups/${groupId}/calendar-assignments`, {
|
||
academicYearId: Number(academicYearId),
|
||
calendarId: Number(calendarId)
|
||
});
|
||
showAlert('manage-calendar-alert', 'Календарный график назначен', 'success');
|
||
await loadAssignmentsList(groupId);
|
||
} catch (error) {
|
||
showAlert('manage-calendar-alert', error.message || 'Ошибка назначения графика', 'error');
|
||
}
|
||
}
|
||
|
||
manageCalendarTbody.addEventListener('click', async (event) => {
|
||
const deleteButton = event.target.closest('.btn-delete-assignment');
|
||
if (!deleteButton) return;
|
||
if (!await confirmAction({
|
||
title: 'Удалить назначение графика?',
|
||
message: 'Группа потеряет связь с этим графиком на выбранный учебный год.',
|
||
confirmLabel: 'Удалить назначение',
|
||
danger: true
|
||
})) return;
|
||
const groupId = manageCalendarGroupIdInput.value;
|
||
|
||
try {
|
||
await api.delete(`/api/groups/${groupId}/calendar-assignments/${deleteButton.dataset.id}`);
|
||
showAlert('manage-calendar-alert', 'Назначение удалено', 'success');
|
||
await loadAssignmentsList(groupId);
|
||
} catch (error) {
|
||
showAlert('manage-calendar-alert', error.message || 'Ошибка удаления назначения', 'error');
|
||
}
|
||
});
|
||
|
||
|
||
// --- Вспомогательные функции ---
|
||
function departmentLabel(departmentId) {
|
||
const department = departments.find(item => item.id == departmentId);
|
||
if (!department) return departmentId || '-';
|
||
return department.departmentName || department.name || departmentId;
|
||
}
|
||
|
||
function specialityLabel(specialityId) {
|
||
const speciality = specialties.find(item => item.id == specialityId);
|
||
if (!speciality) return specialityId || '-';
|
||
const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id;
|
||
const name = speciality.specialityName || speciality.name || '';
|
||
return name ? `${code} — ${name}` : code;
|
||
}
|
||
|
||
function subjectLabel(subject) {
|
||
const code = subject.subjectCode ? `${subject.subjectCode} · ` : '';
|
||
return `${code}${subject.subjectName || subject.subjectId || 'Без названия'}`;
|
||
}
|
||
|
||
function statusLabel(group) {
|
||
if (group.status === 'ARCHIVED') return 'Архив';
|
||
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
|
||
}
|
||
|
||
function statusBadgeClass(group) {
|
||
if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') {
|
||
return 'badge-unavailable';
|
||
}
|
||
if (group.studyState === 'NOT_STARTED') {
|
||
return 'badge-ef';
|
||
}
|
||
return 'badge-available';
|
||
}
|
||
|
||
function syncSelects(...selects) {
|
||
selects.filter(Boolean).forEach(select => {
|
||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||
});
|
||
}
|
||
}
|