From 6d9f6b7a42d67364255776f1bf28c2ded1c331c0 Mon Sep 17 00:00:00 2001 From: alekan Date: Tue, 2 Jun 2026 11:20:35 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20=D0=B2=20"=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=BF=D0=BF=D1=8B=20=D0=BE=D0=B1=D1=83=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F",=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=B5=D1=85=D0=B0=D0=BD=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controller/SubgroupController.java | 47 ++- .../app/repository/SubgroupRepository.java | 28 ++ .../V2__subgroups_active_unique_name.sql | 6 + docs/API.md | 4 + docs/BUSINESS_LOGIC.md | 2 +- docs/DATABASE.md | 2 +- docs/FRONTEND.md | 2 +- frontend/admin/css/components.css | 83 ++++- frontend/admin/js/views/groups.js | 304 +++++++++++++++--- frontend/admin/views/groups.html | 45 ++- 10 files changed, 465 insertions(+), 58 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V2__subgroups_active_unique_name.sql diff --git a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java index 498821b..66e75e1 100644 --- a/backend/src/main/java/com/magistr/app/controller/SubgroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/SubgroupController.java @@ -66,6 +66,10 @@ public class SubgroupController { if (subgroupRepository.existsByStudentGroupIdAndNameIgnoreCase(groupId, request.name().trim())) { return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе")); } + String capacityError = validateGroupCapacity(group, null, request.studentCapacity()); + if (capacityError != null) { + return ResponseEntity.badRequest().body(Map.of("message", capacityError)); + } Subgroup subgroup = new Subgroup(); subgroup.setStudentGroup(group); @@ -95,6 +99,10 @@ public class SubgroupController { if (duplicateName) { return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа с таким названием уже есть в группе")); } + String capacityError = validateGroupCapacity(subgroup.getStudentGroup(), id, request.studentCapacity()); + if (capacityError != null) { + return ResponseEntity.badRequest().body(Map.of("message", capacityError)); + } subgroup.setName(newName); subgroup.setStudentCapacity(request.studentCapacity()); @@ -113,6 +121,10 @@ public class SubgroupController { if (scheduleRuleSlotRepository.existsBySubgroupId(id)) { return ResponseEntity.badRequest().body(Map.of("message", "Подгруппа используется в расписании")); } + String deleteValidationError = validateDeleteKeepsCompleteSplit(subgroup); + if (deleteValidationError != null) { + return ResponseEntity.badRequest().body(Map.of("message", deleteValidationError)); + } try { subgroup.archive("Подгруппа архивирована"); subgroupRepository.save(subgroup); @@ -127,8 +139,39 @@ public class SubgroupController { if (request == null || request.name() == null || request.name().isBlank()) { return "Название подгруппы обязательно"; } - if (request.studentCapacity() != null && request.studentCapacity() < 0) { - return "Численность подгруппы не может быть отрицательной"; + if (request.studentCapacity() == null) { + return "Численность подгруппы обязательна"; + } + if (request.studentCapacity() <= 0) { + return "Численность подгруппы должна быть больше 0"; + } + return null; + } + + private String validateGroupCapacity(StudentGroup group, Long excludedSubgroupId, Integer requestedCapacity) { + Long currentCapacity = excludedSubgroupId == null + ? subgroupRepository.sumActiveStudentCapacityByGroupId(group.getId()) + : subgroupRepository.sumActiveStudentCapacityByGroupIdExcludingId(group.getId(), excludedSubgroupId); + long totalCapacity = currentCapacity + requestedCapacity.longValue(); + if (totalCapacity > group.getGroupSize()) { + return "Сумма численностей подгрупп не может превышать численность группы (" + group.getGroupSize() + " чел.)"; + } + return null; + } + + private String validateDeleteKeepsCompleteSplit(Subgroup subgroup) { + StudentGroup group = subgroup.getStudentGroup(); + long remainingCount = subgroupRepository.countActiveByGroupIdExcludingId(group.getId(), subgroup.getId()); + if (remainingCount == 0) { + return null; + } + + Long remainingCapacity = subgroupRepository.sumActiveStudentCapacityByGroupIdExcludingId( + group.getId(), + subgroup.getId() + ); + if (remainingCapacity < group.getGroupSize()) { + return "Нельзя удалить одну подгруппу из деления: измените количество подгрупп в форме настройки или отключите деление"; } return null; } diff --git a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java index 49715e1..ec2a34e 100644 --- a/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/SubgroupRepository.java @@ -46,4 +46,32 @@ public interface SubgroupRepository extends JpaRepository { and subgroup.status <> 'ARCHIVED' """) boolean existsByStudentGroupIdAndNameIgnoreCase(@Param("groupId") Long groupId, @Param("name") String name); + + @Query(""" + select coalesce(sum(subgroup.studentCapacity), 0) + from Subgroup subgroup + where subgroup.studentGroup.id = :groupId + and subgroup.status <> 'ARCHIVED' + """) + Long sumActiveStudentCapacityByGroupId(@Param("groupId") Long groupId); + + @Query(""" + select coalesce(sum(subgroup.studentCapacity), 0) + from Subgroup subgroup + where subgroup.studentGroup.id = :groupId + and subgroup.status <> 'ARCHIVED' + and subgroup.id <> :excludedId + """) + Long sumActiveStudentCapacityByGroupIdExcludingId(@Param("groupId") Long groupId, + @Param("excludedId") Long excludedId); + + @Query(""" + select count(subgroup) + from Subgroup subgroup + where subgroup.studentGroup.id = :groupId + and subgroup.status <> 'ARCHIVED' + and subgroup.id <> :excludedId + """) + long countActiveByGroupIdExcludingId(@Param("groupId") Long groupId, + @Param("excludedId") Long excludedId); } diff --git a/backend/src/main/resources/db/migration/V2__subgroups_active_unique_name.sql b/backend/src/main/resources/db/migration/V2__subgroups_active_unique_name.sql new file mode 100644 index 0000000..9f5959d --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__subgroups_active_unique_name.sql @@ -0,0 +1,6 @@ +ALTER TABLE subgroups + DROP CONSTRAINT IF EXISTS subgroups_group_id_name_key; + +CREATE UNIQUE INDEX IF NOT EXISTS ux_subgroups_active_group_name + ON subgroups (group_id, lower(name)) + WHERE status <> 'ARCHIVED'; diff --git a/docs/API.md b/docs/API.md index 088039a..5feedf9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -758,6 +758,10 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 Подгруппы используются только для деления лабораторных занятий. Лекции и практики не принимают `subgroupId` и `subgroupIds`. +Для одной учебной группы сумма численностей активных подгрупп не может превышать численность самой группы. Frontend на вкладке `groups` настраивает деление как один из режимов: без подгрупп, две подгруппы или три подгруппы. +Имена подгрупп уникальны только среди активных подгрупп одной группы, поэтому после архивирования можно создать новую `Подгруппа 1`. +Частичное удаление подгруппы из активного деления запрещено, если после удаления оставшиеся подгруппы не покрывают всю численность группы. Количество подгрупп меняется через настройку режима деления. + | Метод | URL | Назначение | |-------|-----|------------| | `GET` | `/api/subgroups` | Список всех подгрупп | diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 8dcd193..4ddc311 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -60,7 +60,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие - **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения - **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число -- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`) +- **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Сумма численностей активных подгрупп не может превышать численность группы. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе. - **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график - **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика - **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным. diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 82c923c..4fd4706 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -470,7 +470,7 @@ erDiagram | `name` | VARCHAR(100) | Название подгруппы | | `student_capacity` | INT | Количество студентов | -Уникальность задаётся парой `(group_id, name)`: в разных группах могут быть подгруппы с одинаковым названием. Подгруппы применяются только для лабораторных занятий. +Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий. #### `subjects` — Дисциплины | Колонка | Тип | Описание | diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 0ae50ff..5128e4a 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -130,7 +130,7 @@ frontend/ | Tab | Описание | API | |-----|----------|-----| | `users` | CRUD пользователей и очередь заявок кафедр на создание преподавателей | `/api/users`, `/api/teacher-requests` | -| `groups` | CRUD групп, подгруппы лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` | +| `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` | | `edu-forms` | Формы обучения | `/api/education-forms` | | `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` | | `equipments` | Оборудование | `/api/equipments` | diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index b42e24e..e8c9512 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -2255,6 +2255,86 @@ input[type="number"] { box-shadow: 0 0 0 3px var(--accent-glow); } +.filter-row .custom-multi-select { + width: min(240px, 100%); + min-width: 220px; +} + +.filter-row .select-box { + min-height: 38px; + padding: 0.45rem 0.75rem; + font-size: 0.85rem; +} + +.filter-row .select-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.subgroup-config-grid { + display: grid; + grid-template-columns: minmax(190px, 240px) minmax(0, 1fr); + grid-template-areas: + "count capacities" + "actions actions"; + gap: 1rem 0.75rem; + align-items: end; +} + +.subgroup-count-field { + grid-area: count; + min-width: 0; + margin-bottom: 0; +} + +.subgroup-capacity-fields { + grid-area: capacities; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + min-width: 0; +} + +.subgroup-capacity-fields:empty { + display: none; +} + +.subgroup-capacity-fields-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.subgroup-capacity-fields-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.subgroup-capacity-field { + min-width: 0; + margin-bottom: 0; +} + +.subgroup-actions { + grid-area: actions; + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0; +} + +@media (max-width: 720px) { + .subgroup-config-grid { + grid-template-columns: 1fr; + grid-template-areas: + "count" + "capacities" + "actions"; + } + + .subgroup-capacity-fields { + grid-template-columns: 1fr; + } +} + /* ===== Custom Multi-Select ===== */ .custom-multi-select { @@ -2380,7 +2460,8 @@ input[type="number"] { transition: background 0.2s ease; } -.form-group .checkbox-group-vertical .checkbox-item { +.form-group .checkbox-group-vertical .checkbox-item, +.filter-row .checkbox-group-vertical .checkbox-item { display: flex; align-items: center; margin-bottom: 0; diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index 121f358..c5048b5 100644 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -10,6 +10,8 @@ async function fetchEducationForms() { } } +const FILTER_EF_EMPTY_TEXT = 'Выберите форму обучения'; + export async function initGroups() { const groupsTbody = document.getElementById('groups-tbody'); const createGroupForm = document.getElementById('create-group-form'); @@ -17,7 +19,10 @@ export async function initGroups() { const newGroupDepartmentSelect = document.getElementById('new-group-department'); const newGroupSpecialitySelect = document.getElementById('new-group-speciality-code'); const newGroupProfileSelect = document.getElementById('new-group-profile'); - const filterEfSelect = document.getElementById('filter-ef'); + 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 modalEditGroup = document.getElementById('modal-edit-group'); @@ -36,8 +41,8 @@ export async function initGroups() { const manageSubgroupForm = document.getElementById('manage-subgroup-form'); const manageSubgroupIdInput = document.getElementById('manage-subgroup-id'); const manageSubgroupGroupIdInput = document.getElementById('manage-subgroup-group-id'); - const manageSubgroupNameInput = document.getElementById('manage-subgroup-name'); - const manageSubgroupCapacityInput = document.getElementById('manage-subgroup-capacity'); + 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'); @@ -61,12 +66,14 @@ export async function initGroups() { let academicYears = []; let calendars = []; let currentAssignments = []; + let activeSubgroupGroup = null; bindEvents(); await loadInitialData(); function bindEvents() { - filterEfSelect.addEventListener('change', applyGroupFilter); + initFilterEfDropdown(); + filterEfCheckboxes.addEventListener('change', handleFilterEfChange); newGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(newGroupProfileSelect, newGroupSpecialitySelect.value)); editGroupSpecialitySelect.addEventListener('change', () => populateProfileSelect(editGroupProfileSelect, editGroupSpecialitySelect.value)); @@ -75,6 +82,7 @@ export async function initGroups() { // Модалка подгрупп manageSubgroupForm.addEventListener('submit', saveSubgroup); + manageSubgroupCountSelect.addEventListener('change', () => renderSubgroupCapacityFields(Number(manageSubgroupCountSelect.value))); btnCancelSubgroupEdit.addEventListener('click', resetSubgroupForm); manageSubgroupsTbody.addEventListener('click', handleSubgroupTableClick); @@ -101,6 +109,37 @@ export async function initGroups() { }); } + 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, calendars] = await Promise.all([ @@ -148,13 +187,63 @@ export async function initGroups() { } function applyGroupFilter() { - const filterId = filterEfSelect.value; - const filtered = filterId - ? allGroups.filter(group => group.educationFormId == filterId) + const selectedIds = selectedFilterEducationFormIds(); + const filtered = selectedIds.length + ? allGroups.filter(group => selectedIds.includes(String(group.educationFormId))) : allGroups; renderGroups(filtered); } + 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) { groupsTbody.innerHTML = 'Нет групп'; @@ -188,11 +277,30 @@ export async function initGroups() { function populateEfSelects(forms) { populateEfSelect(newGroupEfSelect, forms); populateEfSelect(editGroupEfSelect, forms); - const currentFilter = filterEfSelect.value; - filterEfSelect.innerHTML = '' + - forms.map(form => ``).join(''); - if (currentFilter) filterEfSelect.value = currentFilter; - syncSelects(filterEfSelect); + renderFilterEfCheckboxes(forms); + } + + function renderFilterEfCheckboxes(forms) { + const selectedIds = new Set(selectedFilterEducationFormIds()); + const allChecked = !selectedIds.size; + filterEfCheckboxes.innerHTML = ` + + ${forms.map(form => { + const id = String(form.id); + return ` + + `; + }).join('')} + `; + updateFilterEfText(); } function populateEfSelect(select, forms) { @@ -411,6 +519,7 @@ export async function initGroups() { // --- Управление подгруппами --- function openSubgroupsModal(group) { + activeSubgroupGroup = group; subgroupsGroupContext.textContent = `Группа: ${group.name} (${group.groupSize} чел.)`; manageSubgroupGroupIdInput.value = group.id; resetSubgroupForm(); @@ -424,13 +533,19 @@ export async function initGroups() { manageSubgroupsTbody.innerHTML = 'Подгруппы не созданы'; return; } + const canDeleteSingleSubgroup = list.length === 1; + const deleteTitle = canDeleteSingleSubgroup + ? 'Удалить подгруппу' + : 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»'; manageSubgroupsTbody.innerHTML = list.map(sub => ` ${escapeHtml(sub.name)} ${escapeHtml(String(sub.studentCapacity ?? '-'))} чел. - + `).join(''); @@ -438,46 +553,157 @@ export async function initGroups() { function resetSubgroupForm() { manageSubgroupIdInput.value = ''; - manageSubgroupNameInput.value = ''; - manageSubgroupCapacityInput.value = ''; - subgroupFormTitle.textContent = 'Создание подгруппы'; - btnSaveSubgroup.textContent = 'Создать'; + 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.style.display = 'none'; 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 ` +
+ + +
+ `; + }).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 subgroupId = manageSubgroupIdInput.value; - const name = manageSubgroupNameInput.value.trim(); - const capacity = manageSubgroupCapacityInput.value; + const group = activeSubgroupGroup || allGroups.find(item => String(item.id) === String(groupId)); + const count = Number(manageSubgroupCountSelect.value); + const existingSubgroups = currentGroupSubgroups(groupId); - if (!name || !capacity) { - showAlert('manage-subgroup-alert', 'Заполните все поля', 'error'); + if (!group) { + showAlert('manage-subgroup-alert', 'Группа не найдена', 'error'); return; } - const payload = { - name, - studentCapacity: Number(capacity) - }; + 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 { - const endpoint = `/api/groups/${groupId}/subgroups${subgroupId ? `/${subgroupId}` : ''}`; - const saved = subgroupId - ? await api.put(endpoint, payload) - : await api.post(endpoint, payload); - showAlert('manage-subgroup-alert', `Подгруппа "${escapeHtml(saved.name)}" сохранена`, 'success'); - resetSubgroupForm(); + await syncSubgroups(groupId, existingSubgroups, capacities); + showAlert('manage-subgroup-alert', 'Подгруппы сохранены', 'success'); await loadSubgroups(); + 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(); + 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'); @@ -487,17 +713,19 @@ export async function initGroups() { const subgroup = subgroups.find(item => item.id == editButton.dataset.id); if (!subgroup) return; - manageSubgroupIdInput.value = subgroup.id; - manageSubgroupNameInput.value = subgroup.name || ''; - manageSubgroupCapacityInput.value = subgroup.studentCapacity ?? ''; - subgroupFormTitle.textContent = 'Редактирование подгруппы'; - btnSaveSubgroup.textContent = 'Сохранить'; - btnCancelSubgroupEdit.style.display = 'inline-block'; + 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 (!confirm('Удалить подгруппу?')) return; try { await api.delete(`/api/groups/${groupId}/subgroups/${deleteButton.dataset.id}`); diff --git a/frontend/admin/views/groups.html b/frontend/admin/views/groups.html index 1d204d5..7ee6631 100755 --- a/frontend/admin/views/groups.html +++ b/frontend/admin/views/groups.html @@ -49,10 +49,26 @@

Все группы

- - + +
+ + +
@@ -152,17 +168,18 @@
-
-
- - +
+
+ +
-
- - -
-
- +
+
+