Redesign #13
@@ -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;
|
||||
}
|
||||
|
||||
@@ -46,4 +46,32 @@ public interface SubgroupRepository extends JpaRepository<Subgroup, Long> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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` | Список всех подгрупп |
|
||||
|
||||
@@ -60,7 +60,7 @@ Bearer-токен проверяется на backend. Frontend-скрытие
|
||||
|
||||
- **Поля:** Название, численность, форма обучения, кафедра, специальность, профиль обучения, год начала обучения
|
||||
- **Курс:** вычисляется относительно учебного года: `год начала учебного года - year_start_study + 1`, но до начала обучения отдаётся как `0`, а не отрицательное число
|
||||
- **Подгруппы:** Возможно деление группы на подгруппы (таблица `subgroups`)
|
||||
- **Подгруппы:** Возможно деление группы на 2 или 3 подгруппы либо режим без деления (таблица `subgroups`). Сумма численностей активных подгрупп не может превышать численность группы. Нельзя удалить одну подгруппу из активного деления так, чтобы часть студентов не относилась ни к одной подгруппе.
|
||||
- **Календарь:** на каждый учебный год группе назначается конкретный календарный учебный график
|
||||
- **Дисциплины графика:** при назначении графика группе отображаются дисциплины, вручную привязанные к номерам семестров этого графика
|
||||
- **Завершение обучения:** если текущий курс больше `course_count` назначенного календарного графика, группа считается завершившей обучение и не попадает в обычные списки выбора. Историческое расписание по датам периода обучения остаётся доступным.
|
||||
|
||||
@@ -470,7 +470,7 @@ erDiagram
|
||||
| `name` | VARCHAR(100) | Название подгруппы |
|
||||
| `student_capacity` | INT | Количество студентов |
|
||||
|
||||
Уникальность задаётся парой `(group_id, name)`: в разных группах могут быть подгруппы с одинаковым названием. Подгруппы применяются только для лабораторных занятий.
|
||||
Уникальность активных записей задаётся парой `(group_id, lower(name))`: в разных группах могут быть подгруппы с одинаковым названием, а архивные подгруппы не блокируют повторное создание подгруппы с тем же именем. Подгруппы применяются только для лабораторных занятий.
|
||||
|
||||
#### `subjects` — Дисциплины
|
||||
| Колонка | Тип | Описание |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
|
||||
@@ -188,11 +277,30 @@ export async function initGroups() {
|
||||
function populateEfSelects(forms) {
|
||||
populateEfSelect(newGroupEfSelect, forms);
|
||||
populateEfSelect(editGroupEfSelect, forms);
|
||||
const currentFilter = filterEfSelect.value;
|
||||
filterEfSelect.innerHTML = '<option value="">Все формы</option>' +
|
||||
forms.map(form => `<option value="${form.id}">${escapeHtml(form.name)}</option>`).join('');
|
||||
if (currentFilter) filterEfSelect.value = currentFilter;
|
||||
syncSelects(filterEfSelect);
|
||||
renderFilterEfCheckboxes(forms);
|
||||
}
|
||||
|
||||
function renderFilterEfCheckboxes(forms) {
|
||||
const selectedIds = new Set(selectedFilterEducationFormIds());
|
||||
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) {
|
||||
@@ -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 = '<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}">Удалить</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('');
|
||||
@@ -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 `
|
||||
<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 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}`);
|
||||
|
||||
@@ -49,10 +49,26 @@
|
||||
<div class="card-header-row">
|
||||
<h2>Все группы</h2>
|
||||
<div class="filter-row">
|
||||
<label for="filter-ef">Фильтр:</label>
|
||||
<select id="filter-ef">
|
||||
<option value="">Все формы</option>
|
||||
</select>
|
||||
<label for="filter-ef-box">Фильтр:</label>
|
||||
<div class="custom-multi-select">
|
||||
<div class="select-box" id="filter-ef-box" role="button" tabindex="0" aria-expanded="false">
|
||||
<span class="select-text" id="filter-ef-text">Выберите форму обучения</span>
|
||||
<svg class="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="filter-ef-menu">
|
||||
<div id="filter-ef-checkboxes" class="checkbox-group-vertical">
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" value="all" checked>
|
||||
<span class="checkmark"></span>
|
||||
<span>Все</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
@@ -152,17 +168,18 @@
|
||||
<form id="manage-subgroup-form">
|
||||
<input type="hidden" id="manage-subgroup-id">
|
||||
<input type="hidden" id="manage-subgroup-group-id">
|
||||
<div class="form-row" style="align-items: flex-end;">
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="manage-subgroup-name">Название подгруппы</label>
|
||||
<input type="text" id="manage-subgroup-name" placeholder="Подгруппа 1" required>
|
||||
<div class="subgroup-config-grid">
|
||||
<div class="form-group subgroup-count-field">
|
||||
<label for="manage-subgroup-count">Количество подгрупп</label>
|
||||
<select id="manage-subgroup-count">
|
||||
<option value="0">Без разделения на подгруппы</option>
|
||||
<option value="2">Две подгруппы</option>
|
||||
<option value="3">Три подгруппы</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="manage-subgroup-capacity">Численность (чел.)</label>
|
||||
<input type="number" id="manage-subgroup-capacity" min="1" placeholder="12" required>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; margin-bottom: 0;">
|
||||
<button type="submit" class="btn btn-md btn-primary" id="btn-save-subgroup">Создать</button>
|
||||
<div id="manage-subgroup-capacities" class="subgroup-capacity-fields"></div>
|
||||
<div class="subgroup-actions">
|
||||
<button type="submit" class="btn btn-md btn-primary" id="btn-save-subgroup">Применить</button>
|
||||
<button type="button" class="btn btn-md btn-ghost" id="btn-cancel-subgroup-edit" style="display: none;">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user