From bf02efb8b8e386fb20557b88bcc43f55ac83ae8c Mon Sep 17 00:00:00 2001 From: Zuev Date: Fri, 1 May 2026 14:02:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=83=D1=87=D0=B5=D0=B1=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DYNAMIC_SCHEDULE_IMPLEMENTATION.md | 2 +- .../AcademicCalendarController.java | 19 +- .../controller/EducationFormController.java | 15 +- .../app/controller/GroupController.java | 3 + .../magistr/app/dto/CalendarStudyFormDto.java | 8 - .../magistr/app/model/AcademicCalendar.java | 6 +- .../magistr/app/model/CalendarStudyForm.java | 42 - .../AcademicCalendarRepository.java | 2 + .../CalendarStudyFormRepository.java | 11 - .../repository/EducationFormRepository.java | 3 + .../main/resources/db/migration/V1__init.sql | 22 +- docs/ACADEMIC_CALENDAR.md | 8 +- docs/API.md | 5 +- docs/BUSINESS_LOGIC.md | 4 +- docs/DATABASE.md | 19 +- docs/FRONTEND.md | 18 +- frontend/admin/css/components.css | 205 ++++- frontend/admin/index.html | 18 + frontend/admin/js/main.js | 4 + frontend/admin/js/views/academic-calendar.js | 869 ++++++++++++++++++ frontend/admin/js/views/departments-data.js | 140 --- frontend/admin/js/views/groups.js | 1 + frontend/admin/js/views/profiles.js | 192 ++++ frontend/admin/js/views/schedule.js | 619 +------------ frontend/admin/views/academic-calendar.html | 225 +++++ frontend/admin/views/departments-data.html | 63 -- frontend/admin/views/profiles.html | 72 ++ frontend/admin/views/schedule.html | 194 ---- 28 files changed, 1644 insertions(+), 1145 deletions(-) delete mode 100644 backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java delete mode 100644 backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java delete mode 100644 backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java create mode 100644 frontend/admin/js/views/academic-calendar.js create mode 100644 frontend/admin/js/views/profiles.js create mode 100644 frontend/admin/views/academic-calendar.html create mode 100644 frontend/admin/views/profiles.html diff --git a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md b/DYNAMIC_SCHEDULE_IMPLEMENTATION.md index 51ff698..888fb93 100644 --- a/DYNAMIC_SCHEDULE_IMPLEMENTATION.md +++ b/DYNAMIC_SCHEDULE_IMPLEMENTATION.md @@ -17,7 +17,7 @@ | `specialties` | Коды и названия специальностей | | `specialty_profiles` | Профили обучения внутри специальности | | `student_groups` | Группы со ссылками на специальность и профиль | -| `calendar_study_forms` | Формы обучения календарного графика | +| `education_forms` | Общий справочник форм обучения для групп и календарных графиков | | `academic_calendar_activity_types` | Коды активностей из Excel-графика | | `academic_calendars` | Календарные графики профиля, формы обучения и учебного года | | `academic_calendar_days` | Дневная сетка графика по курсу и дате | diff --git a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java index 37941e8..4352867 100644 --- a/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java +++ b/backend/src/main/java/com/magistr/app/controller/AcademicCalendarController.java @@ -21,7 +21,7 @@ public class AcademicCalendarController { private final AcademicYearRepository academicYearRepository; private final SpecialtiesRepository specialtiesRepository; private final SpecialtyProfileRepository profileRepository; - private final CalendarStudyFormRepository studyFormRepository; + private final EducationFormRepository educationFormRepository; private final ScheduleGeneratorService scheduleGeneratorService; public AcademicCalendarController(AcademicCalendarRepository calendarRepository, @@ -30,7 +30,7 @@ public class AcademicCalendarController { AcademicYearRepository academicYearRepository, SpecialtiesRepository specialtiesRepository, SpecialtyProfileRepository profileRepository, - CalendarStudyFormRepository studyFormRepository, + EducationFormRepository educationFormRepository, ScheduleGeneratorService scheduleGeneratorService) { this.calendarRepository = calendarRepository; this.calendarDayRepository = calendarDayRepository; @@ -38,7 +38,7 @@ public class AcademicCalendarController { this.academicYearRepository = academicYearRepository; this.specialtiesRepository = specialtiesRepository; this.profileRepository = profileRepository; - this.studyFormRepository = studyFormRepository; + this.educationFormRepository = educationFormRepository; this.scheduleGeneratorService = scheduleGeneratorService; } @@ -51,13 +51,6 @@ public class AcademicCalendarController { .toList(); } - @GetMapping("/study-forms") - public List getStudyForms() { - return studyFormRepository.findAllByOrderByNameAsc().stream() - .map(form -> new CalendarStudyFormDto(form.getId(), form.getName(), form.getDescription())) - .toList(); - } - @GetMapping("/{id}") public ResponseEntity getCalendar(@PathVariable Long id) { return calendarRepository.findByIdWithDetails(id) @@ -165,8 +158,8 @@ public class AcademicCalendarController { if (!profile.getSpeciality().getId().equals(speciality.getId())) { throw new IllegalArgumentException("Профиль не относится к выбранной специальности"); } - CalendarStudyForm studyForm = studyFormRepository.findById(request.studyFormId()) - .orElseThrow(() -> new IllegalArgumentException("Форма обучения графика не найдена")); + EducationForm studyForm = educationFormRepository.findById(request.studyFormId()) + .orElseThrow(() -> new IllegalArgumentException("Форма обучения не найдена")); calendar.setTitle(request.title().trim()); calendar.setAcademicYear(year); @@ -218,7 +211,7 @@ public class AcademicCalendarController { AcademicYear year = calendar.getAcademicYear(); Speciality speciality = calendar.getSpeciality(); SpecialtyProfile profile = calendar.getSpecialtyProfile(); - CalendarStudyForm studyForm = calendar.getStudyForm(); + EducationForm studyForm = calendar.getStudyForm(); return new AcademicCalendarDto( calendar.getId(), calendar.getTitle(), diff --git a/backend/src/main/java/com/magistr/app/controller/EducationFormController.java b/backend/src/main/java/com/magistr/app/controller/EducationFormController.java index dd31357..b8885ce 100755 --- a/backend/src/main/java/com/magistr/app/controller/EducationFormController.java +++ b/backend/src/main/java/com/magistr/app/controller/EducationFormController.java @@ -2,6 +2,7 @@ package com.magistr.app.controller; import com.magistr.app.model.EducationForm; import com.magistr.app.model.StudentGroup; +import com.magistr.app.repository.AcademicCalendarRepository; import com.magistr.app.repository.EducationFormRepository; import com.magistr.app.repository.GroupRepository; import org.springframework.http.ResponseEntity; @@ -16,16 +17,19 @@ public class EducationFormController { private final EducationFormRepository educationFormRepository; private final GroupRepository groupRepository; + private final AcademicCalendarRepository academicCalendarRepository; public EducationFormController(EducationFormRepository educationFormRepository, - GroupRepository groupRepository) { + GroupRepository groupRepository, + AcademicCalendarRepository academicCalendarRepository) { this.educationFormRepository = educationFormRepository; this.groupRepository = groupRepository; + this.academicCalendarRepository = academicCalendarRepository; } @GetMapping public List> getAll() { - return educationFormRepository.findAll().stream() + return educationFormRepository.findAllByOrderByNameAsc().stream() .map(ef -> Map.of("id", ef.getId(), "name", ef.getName())) .toList(); } @@ -54,12 +58,17 @@ public class EducationFormController { return ResponseEntity.notFound().build(); } - // Check if any groups use this education form + // Нельзя удалить форму обучения, пока она используется группами или графиками. List linked = groupRepository.findByEducationFormId(id); if (!linked.isEmpty()) { return ResponseEntity.badRequest().body(Map.of( "message", "Невозможно удалить: есть привязанные группы (" + linked.size() + ")")); } + long linkedCalendars = academicCalendarRepository.countByStudyFormId(id); + if (linkedCalendars > 0) { + return ResponseEntity.badRequest().body(Map.of( + "message", "Невозможно удалить: есть привязанные календарные графики (" + linkedCalendars + ")")); + } educationFormRepository.deleteById(id); return ResponseEntity.ok(Map.of("message", "Форма обучения удалена")); diff --git a/backend/src/main/java/com/magistr/app/controller/GroupController.java b/backend/src/main/java/com/magistr/app/controller/GroupController.java index fdb179e..8462ce7 100755 --- a/backend/src/main/java/com/magistr/app/controller/GroupController.java +++ b/backend/src/main/java/com/magistr/app/controller/GroupController.java @@ -294,6 +294,9 @@ public class GroupController { || !calendar.getSpecialtyProfile().getId().equals(group.getSpecialtyProfile().getId())) { return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует специальности и профилю группы")); } + if (!calendar.getStudyForm().getId().equals(group.getEducationForm().getId())) { + return ResponseEntity.badRequest().body(Map.of("message", "График не соответствует форме обучения группы")); + } StudentGroupCalendarAssignment assignment = assignmentRepository .findByGroupIdAndAcademicYearIdWithDetails(group.getId(), academicYear.getId()) diff --git a/backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java b/backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java deleted file mode 100644 index 7880b76..0000000 --- a/backend/src/main/java/com/magistr/app/dto/CalendarStudyFormDto.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.magistr.app.dto; - -public record CalendarStudyFormDto( - Long id, - String name, - String description -) { -} diff --git a/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java b/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java index 9d5894d..f528be8 100644 --- a/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java +++ b/backend/src/main/java/com/magistr/app/model/AcademicCalendar.java @@ -27,7 +27,7 @@ public class AcademicCalendar { @ManyToOne(optional = false) @JoinColumn(name = "study_form_id", nullable = false) - private CalendarStudyForm studyForm; + private EducationForm studyForm; @Column(name = "course_count", nullable = false) private Integer courseCount; @@ -72,11 +72,11 @@ public class AcademicCalendar { this.specialtyProfile = specialtyProfile; } - public CalendarStudyForm getStudyForm() { + public EducationForm getStudyForm() { return studyForm; } - public void setStudyForm(CalendarStudyForm studyForm) { + public void setStudyForm(EducationForm studyForm) { this.studyForm = studyForm; } diff --git a/backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java b/backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java deleted file mode 100644 index 49ea74f..0000000 --- a/backend/src/main/java/com/magistr/app/model/CalendarStudyForm.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.magistr.app.model; - -import jakarta.persistence.*; - -@Entity -@Table(name = "calendar_study_forms") -public class CalendarStudyForm { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true, length = 100) - private String name; - - @Column(columnDefinition = "TEXT") - private String description; - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } -} diff --git a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java index 5d2257e..fdb8ec8 100644 --- a/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/AcademicCalendarRepository.java @@ -10,6 +10,8 @@ import java.util.Optional; public interface AcademicCalendarRepository extends JpaRepository { + long countByStudyFormId(Long studyFormId); + @Query(""" select calendar from AcademicCalendar calendar diff --git a/backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java b/backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java deleted file mode 100644 index 7c7eb25..0000000 --- a/backend/src/main/java/com/magistr/app/repository/CalendarStudyFormRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.magistr.app.repository; - -import com.magistr.app.model.CalendarStudyForm; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -public interface CalendarStudyFormRepository extends JpaRepository { - - List findAllByOrderByNameAsc(); -} diff --git a/backend/src/main/java/com/magistr/app/repository/EducationFormRepository.java b/backend/src/main/java/com/magistr/app/repository/EducationFormRepository.java index dd94980..8d87e5a 100755 --- a/backend/src/main/java/com/magistr/app/repository/EducationFormRepository.java +++ b/backend/src/main/java/com/magistr/app/repository/EducationFormRepository.java @@ -3,9 +3,12 @@ package com.magistr.app.repository; import com.magistr.app.model.EducationForm; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface EducationFormRepository extends JpaRepository { Optional findByName(String name); + + List findAllByOrderByNameAsc(); } diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 4c08017..43f45e6 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -295,17 +295,6 @@ ON CONFLICT (academic_year_id, semester_type) DO NOTHING; CREATE INDEX IF NOT EXISTS idx_semesters_dates ON semesters(start_date, end_date); -CREATE TABLE IF NOT EXISTS calendar_study_forms ( - id BIGSERIAL PRIMARY KEY, - name VARCHAR(100) UNIQUE NOT NULL, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -INSERT INTO calendar_study_forms (name, description) VALUES - ('очная', 'Очная форма обучения') -ON CONFLICT (name) DO NOTHING; - CREATE TABLE IF NOT EXISTS academic_calendar_activity_types ( id BIGSERIAL PRIMARY KEY, code VARCHAR(10) UNIQUE NOT NULL, @@ -338,7 +327,7 @@ CREATE TABLE IF NOT EXISTS academic_calendars ( academic_year_id BIGINT NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE, specialty_id BIGINT NOT NULL REFERENCES specialties(id), specialty_profile_id BIGINT NOT NULL REFERENCES specialty_profiles(id), - study_form_id BIGINT NOT NULL REFERENCES calendar_study_forms(id), + study_form_id BIGINT NOT NULL REFERENCES education_forms(id), course_count INT NOT NULL DEFAULT 4, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -358,7 +347,7 @@ SELECT study_form.id, 4 FROM academic_years ay -CROSS JOIN calendar_study_forms study_form +CROSS JOIN education_forms study_form JOIN specialties specialty ON specialty.specialty_code IN ('09.03.01', '10.03.01') JOIN specialty_profiles profile ON profile.specialty_id = specialty.id AND profile.name = 'Без профиля' WHERE ay.title = '2025-2026' @@ -569,7 +558,6 @@ COMMENT ON TABLE specialty_profiles IS 'Профили обучения внут COMMENT ON TABLE time_slots IS 'Настраиваемая сетка временных слотов занятий'; COMMENT ON TABLE academic_years IS 'Учебные годы'; COMMENT ON TABLE semesters IS 'Семестры учебного года'; -COMMENT ON TABLE calendar_study_forms IS 'Формы обучения календарного учебного графика'; COMMENT ON TABLE academic_calendar_activity_types IS 'Коды активностей календарного учебного графика'; COMMENT ON TABLE academic_calendars IS 'Календарные учебные графики по профилю, форме обучения и учебному году'; COMMENT ON TABLE academic_calendar_days IS 'Дневные ячейки календарного учебного графика'; @@ -666,10 +654,6 @@ COMMENT ON COLUMN specialty_profiles.specialty_id IS 'ID специальнос COMMENT ON COLUMN specialty_profiles.name IS 'Название профиля обучения'; COMMENT ON COLUMN specialty_profiles.description IS 'Описание профиля обучения'; -COMMENT ON COLUMN calendar_study_forms.id IS 'ID формы обучения графика'; -COMMENT ON COLUMN calendar_study_forms.name IS 'Название формы обучения графика'; -COMMENT ON COLUMN calendar_study_forms.description IS 'Описание формы обучения графика'; - COMMENT ON COLUMN academic_calendar_activity_types.code IS 'Код активности из календарного графика'; COMMENT ON COLUMN academic_calendar_activity_types.name IS 'Название активности'; COMMENT ON COLUMN academic_calendar_activity_types.allow_schedule IS 'Разрешает ли код генерацию обычного расписания'; @@ -678,7 +662,7 @@ COMMENT ON COLUMN academic_calendars.title IS 'Название календар COMMENT ON COLUMN academic_calendars.academic_year_id IS 'ID учебного года'; COMMENT ON COLUMN academic_calendars.specialty_id IS 'ID специальности'; COMMENT ON COLUMN academic_calendars.specialty_profile_id IS 'ID профиля обучения'; -COMMENT ON COLUMN academic_calendars.study_form_id IS 'ID формы обучения графика'; +COMMENT ON COLUMN academic_calendars.study_form_id IS 'ID формы обучения из education_forms'; COMMENT ON COLUMN academic_calendars.course_count IS 'Количество курсов в графике'; COMMENT ON COLUMN academic_calendar_days.calendar_id IS 'ID календарного учебного графика'; diff --git a/docs/ACADEMIC_CALENDAR.md b/docs/ACADEMIC_CALENDAR.md index e8f86fa..9225edb 100644 --- a/docs/ACADEMIC_CALENDAR.md +++ b/docs/ACADEMIC_CALENDAR.md @@ -17,8 +17,9 @@ - `specialties` хранит код и название специальности, например `09.03.04`. - `specialty_profiles` хранит профили внутри специальности. У одной специальности может быть несколько профилей. - `academic_calendars` хранит график профиля, формы обучения и учебного года. +- Форма обучения графика берётся из общего справочника `education_forms`; отдельной таблицы форм для календаря нет. - `academic_calendar_days` хранит дневную сетку графика по курсу и дате. -- `student_groups` связана со специальностью и профилем обучения. +- `student_groups` связана со специальностью, профилем и той же таблицей форм обучения. - `student_group_calendar_assignments` назначает группе конкретный график на учебный год. Группа выбирает не код активности напрямую, а календарный график. Поэтому один график можно назначить нескольким группам. @@ -60,8 +61,8 @@ 1. В разделе кафедр и специальностей администратор создаёт специальность и её профили. 2. В разделе групп администратор создаёт группу, выбирая специальность и профиль обучения. -3. В разделе расписания администратор создаёт учебный год и семестры. -4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения и количество курсов. +3. В разделе календарного графика администратор создаёт учебный год и семестры. +4. Там же создаётся календарный учебный график: название, учебный год, специальность, профиль, форма обучения из общего справочника и количество курсов. 5. Администратор открывает сетку графика, заполняет дни вручную или массово через диапазон дат. 6. В разделе групп администратор назначает календарный график группе на нужный учебный год. @@ -72,5 +73,6 @@ - Старый отдельный механизм праздников не используется: праздник хранится кодом `*` в дневной сетке. - Дни вне учебного года или пустые периоды Excel хранятся кодом `=`. - График привязан к профилю обучения, а не только к коду специальности. +- Форма обучения графика должна совпадать с формой обучения группы при назначении графика. - Название группы не является уникальным ключом; идентичные названия допустимы, если различаются другие параметры. - Excel-импорт в первой реализации не предусмотрен: редактор заполняет данные через сайт. diff --git a/docs/API.md b/docs/API.md index cfad4e2..8b6c51a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -212,7 +212,6 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 | Метод | URL | Назначение | |-------|-----|------------| | `GET` | `/api/admin/academic-calendars` | Список графиков, фильтры `academicYearId`, `specialtyId`, `profileId` | -| `GET` | `/api/admin/academic-calendars/study-forms` | Формы обучения графика | | `GET` | `/api/admin/academic-calendars/{id}` | Один график | | `POST` | `/api/admin/academic-calendars` | Создать график | | `PUT` | `/api/admin/academic-calendars/{id}` | Обновить график | @@ -232,6 +231,8 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03 } ``` +`studyFormId` берётся из общего справочника форм обучения `GET /api/education-forms`; отдельного справочника форм для календарных графиков нет. + **Ячейка сетки графика:** ```json { @@ -558,7 +559,7 @@ CRUD доступен по: ### `DELETE /api/education-forms/{id}` -Удаление формы обучения. **Невозможно**, если к ней привязаны группы. +Удаление формы обучения. **Невозможно**, если к ней привязаны группы или календарные учебные графики. --- diff --git a/docs/BUSINESS_LOGIC.md b/docs/BUSINESS_LOGIC.md index 637d3b5..4beea74 100644 --- a/docs/BUSINESS_LOGIC.md +++ b/docs/BUSINESS_LOGIC.md @@ -35,10 +35,10 @@ ### Формы обучения (Education Forms) -Уровни/формы обучения для привязки к группам. +Уровни/формы обучения для привязки к группам и календарным учебным графикам. - Предзаполнены: Бакалавриат, Магистратура, Специалитет -- Нельзя удалить форму обучения, если к ней привязаны группы +- Нельзя удалить форму обучения, если к ней привязаны группы или календарные графики ### Учебные группы (Student Groups) diff --git a/docs/DATABASE.md b/docs/DATABASE.md index c7ab763..f03926c 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -148,12 +148,6 @@ erDiagram DATE end_date } - calendar_study_forms { - BIGSERIAL id PK - VARCHAR name UK - TEXT description - } - academic_calendar_activity_types { BIGSERIAL id PK VARCHAR code UK @@ -244,7 +238,7 @@ erDiagram semesters ||--o{ schedule_rules : "semester_id" specialties ||--o{ academic_calendars : "specialty_id" specialty_profiles ||--o{ academic_calendars : "specialty_profile_id" - calendar_study_forms ||--o{ academic_calendars : "study_form_id" + education_forms ||--o{ academic_calendars : "study_form_id" academic_calendars ||--o{ academic_calendar_days : "calendar_id" academic_calendars ||--o{ student_group_calendar_assignments : "calendar_id" academic_calendar_activity_types ||--o{ academic_calendar_days : "activity_type_id" @@ -309,6 +303,8 @@ erDiagram | `name` | VARCHAR(100) UNIQUE | Название (Бакалавриат, Магистратура, Специалитет) | | `description` | TEXT | Описание | +Эта таблица используется и группами (`student_groups.education_form_id`), и календарными учебными графиками (`academic_calendars.study_form_id`). Отдельного справочника форм обучения для календарей нет. + #### `student_groups` — Учебные группы | Колонка | Тип | Описание | |---------|-----|----------| @@ -420,13 +416,6 @@ erDiagram | `start_date` | DATE | Дата начала, от неё считается неделя 1 | | `end_date` | DATE | Дата окончания | -#### `calendar_study_forms` — Формы обучения календарного графика -| Колонка | Тип | Описание | -|---------|-----|----------| -| `id` | BIGSERIAL PK | ID | -| `name` | VARCHAR(100) UNIQUE | Форма обучения графика, seed: `очная` | -| `description` | TEXT | Описание | - #### `academic_calendar_activity_types` — Коды активностей графика | Колонка | Тип | Описание | |---------|-----|----------| @@ -446,7 +435,7 @@ erDiagram | `academic_year_id` | BIGINT FK → academic_years | Учебный год | | `specialty_id` | BIGINT FK → specialties | Специальность | | `specialty_profile_id` | BIGINT FK → specialty_profiles | Профиль обучения | -| `study_form_id` | BIGINT FK → calendar_study_forms | Форма обучения графика | +| `study_form_id` | BIGINT FK → education_forms | Форма обучения из общего справочника | | `course_count` | INT CHECK(> 0) | Количество курсов в сетке | | `created_at` | TIMESTAMP | Дата создания | | `updated_at` | TIMESTAMP | Дата обновления | diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 6261df7..6b2a1b6 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -42,7 +42,9 @@ frontend/ │ │ ├── subjects.js # Управление дисциплинами │ │ ├── equipments.js # Управление оборудованием │ │ ├── edu-forms.js # Формы обучения -│ │ ├── schedule.js # Конструктор правил, сетки пар и учебного графика +│ │ ├── profiles.js # Профили обучения специальностей +│ │ ├── schedule.js # Конструктор правил расписания и сетки пар +│ │ ├── academic-calendar.js # Календарные учебные графики │ │ ├── database.js # Управление тенантами │ │ └── departments-data.js # Создание кафедры/специальности │ ├── views/ # HTML-шаблоны представлений @@ -52,7 +54,9 @@ frontend/ │ │ ├── subjects.html │ │ ├── equipments.html │ │ ├── edu-forms.html +│ │ ├── profiles.html │ │ ├── schedule.html +│ │ ├── academic-calendar.html │ │ ├── database.html │ │ └── departments-data.html │ │ @@ -85,6 +89,7 @@ frontend/ Пользователи Группы Расписание занятий +Календарный график ``` При клике на пункт меню `main.js`: @@ -100,10 +105,12 @@ frontend/ | `users` | CRUD пользователей | `/api/users` | | `groups` | CRUD групп | `/api/groups` | | `edu-forms` | Формы обучения | `/api/education-forms` | +| `profiles` | Создание, редактирование и удаление профилей обучения специальностей | `/api/specialties/{id}/profiles` | | `equipments` | Оборудование | `/api/equipments` | | `classrooms` | Аудитории | `/api/classrooms` | | `subjects` | Дисциплины | `/api/subjects` | -| `schedule` | Конструктор правил динамического расписания, сетка пар, учебные годы, семестры и Excel-подобный редактор календарного учебного графика | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/lesson-types` | +| `schedule` | Конструктор правил динамического расписания и сетка пар | `/api/admin/schedule-rules`, `/api/admin/time-slots`, `/api/admin/calendar/years`, `/api/lesson-types` | +| `academic-calendar` | Учебные годы, семестры, создание календарных графиков и Excel-подобный редактор дневной сетки | `/api/admin/calendar`, `/api/admin/academic-calendars`, `/api/admin/calendar/activity-types`, `/api/education-forms` | | `auditorium-workload` | Загруженность аудиторий: аудитории в строках, временные слоты в столбцах | mock-данные | | `database` | Тенанты | `/api/database` | | `departments-data` | Создание, редактирование и удаление кафедр/специальностей | `/api/departments`, `/api/specialties` | @@ -112,8 +119,11 @@ frontend/ - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. - Вкладка `auditorium-workload` показывает матрицу загруженности: строки — аудитории, столбцы — временные слоты. -- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор слотов. В этом же модуле администратор создаёт календарные графики, заполняет дневную сетку по курсам и кодам активностей, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. -- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}`, `PUT/DELETE /api/specialties/{id}` и CRUD профилей `/api/specialties/{id}/profiles`. +- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. +- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы и набор слотов. Из календарной системы здесь используется только список семестров для выбора периода действия правила. +- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. +- Редактор годового графика во вкладке `academic-calendar` показывает мелкую табличную сетку: каждый курс разделён на семестры (`1`/`2`, `3`/`4` и далее), внутри семестра столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. Подробная расшифровка и изменение кода открываются в модальном окне по клику на ячейку. +- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. ### Страница настроек (`/admin/settings/`) diff --git a/frontend/admin/css/components.css b/frontend/admin/css/components.css index 1cb7e41..bc05ef2 100755 --- a/frontend/admin/css/components.css +++ b/frontend/admin/css/components.css @@ -199,40 +199,138 @@ font-size: 1rem; } -.calendar-course-header span, -.calendar-week-label { +.calendar-course-header span { color: var(--text-secondary); font-size: 0.78rem; } -.calendar-days-grid { +.calendar-semesters-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); - gap: 0.5rem; + gap: 0.75rem; padding: 0.75rem; } -.calendar-day { - display: grid; - gap: 0.4rem; - min-height: 92px; - padding: 0.55rem; - background: var(--bg-card); +.calendar-semester-block { border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); + background: var(--bg-card); + overflow: hidden; } -.calendar-day-meta { +.calendar-semester-header { display: flex; + align-items: center; justify-content: space-between; - gap: 0.35rem; + gap: 1rem; + min-height: 36px; + padding: 0.45rem 0.65rem; + border-bottom: 1px solid var(--bg-card-border); +} + +.calendar-semester-header h4 { + margin: 0; + font-size: 0.86rem; +} + +.calendar-semester-header span { color: var(--text-secondary); - font-size: 0.78rem; - font-weight: 600; + font-size: 0.72rem; + white-space: nowrap; +} + +.calendar-semester-table-wrap { + overflow-x: auto; + background: var(--bg-primary); +} + +.calendar-semester-table { + width: max-content; + min-width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 0.68rem; +} + +.calendar-semester-table th, +.calendar-semester-table td { + border: 1px solid var(--bg-card-border); +} + +.calendar-semester-table thead th { + position: sticky; + top: 0; + z-index: 1; + height: 22px; + min-width: 26px; + padding: 0 0.2rem; + background: var(--bg-input); + color: var(--text-secondary); + font-weight: 700; + text-align: center; +} + +.calendar-semester-table .calendar-day-head { + position: sticky; + left: 0; + z-index: 2; + width: 36px; + min-width: 36px; + background: var(--bg-input); + color: var(--text-secondary); + font-weight: 700; + text-align: center; +} + +.calendar-day { + position: relative; + width: 26px; + min-width: 26px; + height: 24px; + min-height: 24px; + padding: 0; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 9%, var(--bg-card)); + cursor: pointer; + outline: none; + box-shadow: inset 0 2px 0 var(--activity-color, var(--accent)); + text-align: center; + transition: background var(--transition), box-shadow var(--transition), transform var(--transition); +} + +.calendar-day:hover, +.calendar-day:focus-visible { + background: color-mix(in srgb, var(--activity-color, var(--accent)) 18%, var(--bg-card)); + box-shadow: inset 0 2px 0 var(--activity-color, var(--accent)), 0 0 0 2px color-mix(in srgb, var(--activity-color, var(--accent)) 36%, transparent); + transform: scale(1.08); + z-index: 3; +} + +.calendar-day-code { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: var(--text-primary); + font-size: 0.68rem; + line-height: 1; + font-weight: 800; } .calendar-day-activity { - min-width: 0; + display: none !important; +} + +.calendar-empty-day { + width: 26px; + min-width: 26px; + height: 24px; + background: color-mix(in srgb, var(--bg-input) 65%, transparent); +} + +.calendar-empty-semester { + padding: 0.75rem; + color: var(--text-secondary); + font-size: 0.82rem; } .calendar-totals { @@ -254,6 +352,71 @@ font-weight: 600; } +.calendar-day-dialog-card { + max-width: 560px; +} + +.calendar-day-dialog-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + margin: 0.75rem 0; +} + +.calendar-day-dialog-grid div { + padding: 0.75rem; + border: 1px solid var(--bg-card-border); + border-radius: var(--radius-sm); + background: var(--bg-input); +} + +.calendar-day-dialog-grid span { + display: block; + color: var(--text-secondary); + font-size: 0.78rem; + margin-bottom: 0.25rem; +} + +.calendar-day-dialog-grid strong { + font-size: 0.95rem; +} + +.calendar-day-dialog-activity { + display: flex; + gap: 0.8rem; + align-items: center; + margin: 0.8rem 0 1rem; + padding: 0.75rem; + border-radius: var(--radius-sm); + background: var(--bg-input); + border: 1px solid var(--bg-card-border); +} + +.calendar-day-dialog-code { + width: 46px; + min-width: 46px; + height: 46px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: color-mix(in srgb, var(--activity-color, var(--accent)) 20%, transparent); + border: 1px solid color-mix(in srgb, var(--activity-color, var(--accent)) 55%, transparent); + font-weight: 800; +} + +.calendar-day-dialog-activity p { + margin: 0.2rem 0 0; + color: var(--text-secondary); + font-size: 0.85rem; +} + +.profiles-context { + color: var(--text-secondary); + font-weight: 600; + margin: -0.25rem 0 0.75rem; +} + .muted { color: var(--text-secondary); } @@ -266,6 +429,12 @@ .calendar-fill-panel { grid-template-columns: repeat(2, minmax(180px, 1fr)); } + + .calendar-semester-header { + align-items: flex-start; + flex-direction: column; + gap: 0.2rem; + } } @media (max-width: 640px) { @@ -276,6 +445,10 @@ .calendar-fill-panel { grid-template-columns: 1fr; } + + .calendar-day-dialog-grid { + grid-template-columns: 1fr; + } } /* Hide Number Arrows */ diff --git a/frontend/admin/index.html b/frontend/admin/index.html index 3881f85..361f729 100755 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -60,6 +60,14 @@ Создание кафедры/специальности + + + + + + + Профили обучения + @@ -110,6 +118,16 @@ Расписание занятий + + + + + + + + + Календарный график + diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index 596d309..dabb058 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -28,6 +28,8 @@ import {initSchedule} from "./views/schedule.js"; import {initDatabase} from "./views/database.js"; import {initDepartmentsData} from "./views/departments-data.js"; import {initAuditoriumWorkload} from "./views/auditorium-workload.js"; +import {initProfiles} from "./views/profiles.js"; +import {initAcademicCalendar} from "./views/academic-calendar.js"; // Configuration const ROUTES = { @@ -38,9 +40,11 @@ const ROUTES = { classrooms: { title: 'Аудитории', file: 'views/classrooms.html', init: initClassrooms }, subjects: { title: 'Дисциплины и преподаватели', file: 'views/subjects.html', init: initSubjects }, schedule: { title: 'Расписание занятий', file: 'views/schedule.html', init: initSchedule }, + 'academic-calendar': { title: 'Календарный учебный график', file: 'views/academic-calendar.html', init: initAcademicCalendar }, 'auditorium-workload': { title: 'Загруженность аудиторий', file: 'views/auditorium-workload.html', init: initAuditoriumWorkload }, database: { title: 'База данных', file: 'views/database.html', init: initDatabase }, 'departments-data': { title: 'Создание кафедры/специальности', file: 'views/departments-data.html', init: initDepartmentsData }, + profiles: { title: 'Профили обучения', file: 'views/profiles.html', init: initProfiles }, }; let currentTab = null; diff --git a/frontend/admin/js/views/academic-calendar.js b/frontend/admin/js/views/academic-calendar.js new file mode 100644 index 0000000..eaaaad6 --- /dev/null +++ b/frontend/admin/js/views/academic-calendar.js @@ -0,0 +1,869 @@ +import { api } from '../api.js'; +import { escapeHtml, showAlert, hideAlert } from '../utils.js'; + +const DAY_OPTIONS = [ + { value: 1, label: 'Понедельник' }, + { value: 2, label: 'Вторник' }, + { value: 3, label: 'Среда' }, + { value: 4, label: 'Четверг' }, + { value: 5, label: 'Пятница' }, + { value: 6, label: 'Суббота' }, + { value: 7, label: 'Воскресенье' } +]; + +const SEMESTER_LABELS = { + autumn: 'осенний', + spring: 'весенний' +}; + +export async function initAcademicCalendar() { + const academicYearForm = document.getElementById('academic-year-form'); + const academicYearFormTitle = document.getElementById('academic-year-form-title'); + const academicYearIdInput = document.getElementById('academic-year-id'); + const academicYearTitleInput = document.getElementById('academic-year-title'); + const academicYearStartInput = document.getElementById('academic-year-start'); + const academicYearEndInput = document.getElementById('academic-year-end'); + const academicYearResetButton = document.getElementById('academic-year-reset'); + const academicYearsTbody = document.getElementById('academic-years-tbody'); + const academicYearsRefreshButton = document.getElementById('academic-years-refresh'); + + const semesterForm = document.getElementById('semester-form'); + const semesterFormTitle = document.getElementById('semester-form-title'); + const semesterIdInput = document.getElementById('semester-id'); + const semesterYearSelect = document.getElementById('semester-year'); + const semesterTypeSelect = document.getElementById('semester-type'); + const semesterStartInput = document.getElementById('semester-start'); + const semesterEndInput = document.getElementById('semester-end'); + const semesterResetButton = document.getElementById('semester-reset'); + + const calendarForm = document.getElementById('academic-calendar-form'); + const calendarFormTitle = document.getElementById('academic-calendar-form-title'); + const calendarIdInput = document.getElementById('academic-calendar-id'); + const calendarTitleInput = document.getElementById('academic-calendar-title'); + const calendarYearSelect = document.getElementById('academic-calendar-year'); + const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty'); + const calendarProfileSelect = document.getElementById('academic-calendar-profile'); + const calendarStudyFormSelect = document.getElementById('academic-calendar-study-form'); + const calendarCourseCountInput = document.getElementById('academic-calendar-course-count'); + const calendarResetButton = document.getElementById('academic-calendar-reset'); + const calendarsRefreshButton = document.getElementById('academic-calendars-refresh'); + const calendarsTbody = document.getElementById('academic-calendars-tbody'); + + const editorCalendarSelect = document.getElementById('calendar-editor-calendar'); + const editorLoadButton = document.getElementById('calendar-editor-load'); + const editorSaveButton = document.getElementById('calendar-editor-save'); + const fillCourseSelect = document.getElementById('calendar-fill-course'); + const fillStartInput = document.getElementById('calendar-fill-start'); + const fillEndInput = document.getElementById('calendar-fill-end'); + const fillActivitySelect = document.getElementById('calendar-fill-activity'); + const fillApplyButton = document.getElementById('calendar-fill-apply'); + const calendarGrid = document.getElementById('calendar-grid'); + const calendarTotals = document.getElementById('calendar-totals'); + const calendarDayDialog = document.getElementById('calendar-day-dialog'); + const calendarDayDialogClose = document.getElementById('calendar-day-dialog-close'); + const calendarDayDialogDate = document.getElementById('calendar-day-dialog-date'); + const calendarDayDialogCourse = document.getElementById('calendar-day-dialog-course'); + const calendarDayDialogWeek = document.getElementById('calendar-day-dialog-week'); + const calendarDayDialogDay = document.getElementById('calendar-day-dialog-day'); + const calendarDayDialogActivitySummary = document.getElementById('calendar-day-dialog-activity-summary'); + const calendarDayDialogActivitySelect = document.getElementById('calendar-day-dialog-activity'); + + let academicYears = []; + let semesters = []; + let specialties = []; + let profilesBySpecialty = new Map(); + let studyForms = []; + let activityTypes = []; + let calendars = []; + let activeCalendarDay = null; + + bindEvents(); + + try { + await Promise.all([loadBaseLists(), loadYears(), loadStudyForms(), loadActivityTypes()]); + await loadCalendars(); + } catch (error) { + showAlert('academic-calendar-alert', error.message || 'Ошибка загрузки календарных графиков', 'error'); + } + + function bindEvents() { + academicYearsRefreshButton?.addEventListener('click', loadYears); + calendarsRefreshButton?.addEventListener('click', loadCalendars); + academicYearResetButton?.addEventListener('click', resetAcademicYearForm); + semesterResetButton?.addEventListener('click', resetSemesterForm); + calendarResetButton?.addEventListener('click', resetCalendarForm); + calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value)); + editorLoadButton?.addEventListener('click', loadCalendarGrid); + editorSaveButton?.addEventListener('click', saveCalendarGrid); + fillApplyButton?.addEventListener('click', applyCalendarFill); + + calendarGrid?.addEventListener('change', (event) => { + if (event.target.matches('.calendar-day-activity')) { + updateCalendarDayVisual(event.target.closest('.calendar-day')); + renderCalendarTotals(); + } + }); + calendarGrid?.addEventListener('click', (event) => { + const day = event.target.closest('.calendar-day'); + if (day) openCalendarDayDialog(day); + }); + calendarGrid?.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + const day = event.target.closest('.calendar-day'); + if (!day) return; + event.preventDefault(); + openCalendarDayDialog(day); + }); + calendarDayDialogClose?.addEventListener('click', closeCalendarDayDialog); + calendarDayDialog?.addEventListener('click', (event) => { + if (event.target === calendarDayDialog) closeCalendarDayDialog(); + }); + calendarDayDialogActivitySelect?.addEventListener('change', () => { + if (!activeCalendarDay) return; + const select = activeCalendarDay.querySelector('.calendar-day-activity'); + select.value = calendarDayDialogActivitySelect.value; + updateCalendarDayVisual(activeCalendarDay); + updateCalendarDayDialogSummary(activeCalendarDay); + renderCalendarTotals(); + }); + + academicYearForm?.addEventListener('submit', saveAcademicYear); + semesterForm?.addEventListener('submit', saveSemester); + calendarForm?.addEventListener('submit', saveCalendar); + + academicYearsTbody?.addEventListener('click', handleAcademicYearTableClick); + calendarsTbody?.addEventListener('click', handleCalendarTableClick); + } + + async function loadBaseLists() { + specialties = await api.get('/api/specialties'); + await loadProfiles(); + populateSpecialtySelects(); + } + + 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 loadYears() { + academicYearsTbody.innerHTML = '
Загрузка...
'; + try { + academicYears = await api.get('/api/admin/calendar/years'); + semesters = academicYears.flatMap(year => year.semesters || []); + renderAcademicYears(); + populateYearSelects(); + } catch (error) { + academicYearsTbody.innerHTML = `
Ошибка загрузки: ${escapeHtml(error.message)}
`; + throw error; + } + } + + function renderAcademicYears() { + if (!academicYears.length) { + academicYearsTbody.innerHTML = '
Учебные годы не созданы
'; + return; + } + academicYearsTbody.innerHTML = academicYears.map(year => ` +
+ ${escapeHtml(year.title)} + ${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)} + ${renderSemesterList(year.semesters || [])} + + + + +
+ `).join(''); + } + + function renderSemesterList(items) { + if (!items.length) return '
Нет семестров'; + return items.map(semester => ` +
+ ${escapeHtml(semesterLabel(semester))} + +
+ `).join(''); + } + + async function saveAcademicYear(event) { + event.preventDefault(); + hideAlert('academic-year-alert'); + const payload = { + title: academicYearTitleInput.value.trim(), + startDate: academicYearStartInput.value, + endDate: academicYearEndInput.value + }; + try { + if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года'); + const id = academicYearIdInput.value; + await (id + ? api.put('/api/admin/calendar/years/' + id, payload) + : api.post('/api/admin/calendar/years', payload)); + showAlert('academic-year-alert', 'Учебный год сохранён', 'success'); + resetAcademicYearForm(); + await loadYears(); + await loadCalendars(); + } catch (error) { + showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error'); + } + } + + async function handleAcademicYearTableClick(event) { + const editYearButton = event.target.closest('.btn-edit-year'); + const deleteYearButton = event.target.closest('.btn-delete-year'); + const editSemesterButton = event.target.closest('.btn-edit-semester'); + + if (editYearButton) { + const year = academicYears.find(item => item.id == editYearButton.dataset.id); + if (!year) return; + academicYearFormTitle.textContent = 'Редактирование учебного года'; + academicYearIdInput.value = year.id; + academicYearTitleInput.value = year.title || ''; + academicYearStartInput.value = year.startDate || ''; + academicYearEndInput.value = year.endDate || ''; + hideAlert('academic-year-alert'); + academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (editSemesterButton) { + const semester = semesters.find(item => item.id == editSemesterButton.dataset.id); + if (!semester) return; + semesterFormTitle.textContent = 'Редактирование семестра'; + semesterIdInput.value = semester.id; + semesterYearSelect.value = semester.academicYearId || ''; + semesterTypeSelect.value = normalizeSemesterType(semester.semesterType); + semesterStartInput.value = semester.startDate || ''; + semesterEndInput.value = semester.endDate || ''; + syncSelects(semesterYearSelect, semesterTypeSelect); + hideAlert('semester-alert'); + semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (deleteYearButton) { + if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return; + try { + await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id); + showAlert('academic-year-alert', 'Учебный год удалён', 'success'); + await loadYears(); + await loadCalendars(); + } catch (error) { + showAlert('academic-year-alert', error.message || 'Ошибка удаления учебного года', 'error'); + } + } + } + + function resetAcademicYearForm() { + academicYearForm.reset(); + academicYearFormTitle.textContent = 'Учебный год'; + academicYearIdInput.value = ''; + hideAlert('academic-year-alert'); + } + + async function saveSemester(event) { + event.preventDefault(); + hideAlert('semester-alert'); + const yearId = semesterYearSelect.value; + const payload = { + semesterType: semesterTypeSelect.value, + startDate: semesterStartInput.value, + endDate: semesterEndInput.value + }; + try { + if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) { + throw new Error('Заполните учебный год, тип и даты семестра'); + } + const id = semesterIdInput.value; + await (id + ? api.put('/api/admin/calendar/semesters/' + id, payload) + : api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload)); + showAlert('semester-alert', 'Семестр сохранён', 'success'); + resetSemesterForm(); + await loadYears(); + } catch (error) { + showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error'); + } + } + + function resetSemesterForm() { + semesterForm.reset(); + semesterFormTitle.textContent = 'Семестр'; + semesterIdInput.value = ''; + syncSelects(semesterYearSelect, semesterTypeSelect); + hideAlert('semester-alert'); + } + + async function loadStudyForms() { + studyForms = await api.get('/api/education-forms'); + populateStudyFormSelect(); + } + + async function loadActivityTypes() { + activityTypes = await api.get('/api/admin/calendar/activity-types'); + populateActivitySelects(); + } + + async function loadCalendars() { + calendarsTbody.innerHTML = 'Загрузка...'; + try { + calendars = await api.get('/api/admin/academic-calendars'); + renderCalendars(); + populateEditorCalendarSelect(); + } catch (error) { + calendarsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + throw error; + } + } + + function renderCalendars() { + if (!calendars.length) { + calendarsTbody.innerHTML = 'Календарные графики не созданы'; + return; + } + calendarsTbody.innerHTML = calendars.map(calendar => ` + + ${escapeHtml(calendar.title)} + ${escapeHtml(calendar.academicYearTitle || '-')} + ${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')} + ${escapeHtml(calendar.specialtyProfileName || '-')} + ${escapeHtml(calendar.studyFormName || '-')} + ${escapeHtml(String(calendar.courseCount || '-'))} + + + + + + + `).join(''); + } + + async function saveCalendar(event) { + event.preventDefault(); + hideAlert('academic-calendar-alert'); + const payload = { + id: calendarIdInput.value ? Number(calendarIdInput.value) : null, + title: calendarTitleInput.value.trim(), + academicYearId: Number(calendarYearSelect.value), + specialtyId: Number(calendarSpecialtySelect.value), + specialtyProfileId: Number(calendarProfileSelect.value), + studyFormId: Number(calendarStudyFormSelect.value), + courseCount: Number(calendarCourseCountInput.value) + }; + try { + if (!payload.title || !payload.academicYearId || !payload.specialtyId + || !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) { + throw new Error('Заполните все поля календарного графика'); + } + const id = calendarIdInput.value; + const saved = id + ? await api.put('/api/admin/academic-calendars/' + id, payload) + : await api.post('/api/admin/academic-calendars', payload); + showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success'); + resetCalendarForm(); + await loadCalendars(); + } catch (error) { + showAlert('academic-calendar-alert', error.message || 'Ошибка сохранения календарного графика', 'error'); + } + } + + async function handleCalendarTableClick(event) { + const editButton = event.target.closest('.btn-edit-calendar'); + const gridButton = event.target.closest('.btn-open-calendar-grid'); + const deleteButton = event.target.closest('.btn-delete-calendar'); + + if (editButton) { + const calendar = calendars.find(item => item.id == editButton.dataset.id); + if (!calendar) return; + calendarFormTitle.textContent = 'Редактирование календарного графика'; + calendarIdInput.value = calendar.id; + calendarTitleInput.value = calendar.title || ''; + calendarYearSelect.value = calendar.academicYearId || ''; + calendarSpecialtySelect.value = calendar.specialtyId || ''; + populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId); + calendarStudyFormSelect.value = calendar.studyFormId || ''; + calendarCourseCountInput.value = calendar.courseCount || 4; + syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect); + hideAlert('academic-calendar-alert'); + calendarForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (gridButton) { + editorCalendarSelect.value = gridButton.dataset.id; + syncSelects(editorCalendarSelect); + await loadCalendarGrid(); + calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + if (deleteButton) { + if (!confirm('Удалить календарный график?')) return; + try { + await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id); + showAlert('academic-calendar-alert', 'Календарный график удалён', 'success'); + await loadCalendars(); + } catch (error) { + showAlert('academic-calendar-alert', error.message || 'Ошибка удаления календарного графика', 'error'); + } + } + } + + function resetCalendarForm() { + calendarForm.reset(); + calendarFormTitle.textContent = 'Календарный учебный график'; + calendarIdInput.value = ''; + calendarCourseCountInput.value = 4; + populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value); + syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect); + hideAlert('academic-calendar-alert'); + } + + async function loadCalendarGrid() { + hideAlert('calendar-editor-alert'); + const calendar = selectedEditorCalendar(); + if (!calendar) { + showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); + return; + } + try { + const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`); + const gridRows = rows.length ? rows : buildDefaultGrid(calendar); + renderCalendarGrid(gridRows); + showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success'); + } catch (error) { + showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error'); + } + } + + async function saveCalendarGrid() { + hideAlert('calendar-editor-alert'); + const calendar = selectedEditorCalendar(); + if (!calendar) { + showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); + return; + } + const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({ + calendarId: calendar.id, + courseNumber: Number(day.dataset.course), + date: day.dataset.date, + weekNumber: Number(day.dataset.week), + dayOfWeek: Number(day.dataset.day), + activityTypeId: Number(day.querySelector('.calendar-day-activity').value) + })); + if (!rows.length) { + showAlert('calendar-editor-alert', 'Сначала загрузите сетку графика', 'error'); + return; + } + try { + await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows); + showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success'); + await loadCalendarGrid(); + } catch (error) { + showAlert('calendar-editor-alert', error.message || 'Ошибка сохранения сетки', 'error'); + } + } + + function applyCalendarFill() { + hideAlert('calendar-editor-alert'); + const activityId = fillActivitySelect.value; + const start = fillStartInput.value; + const end = fillEndInput.value; + if (!activityId || !start || !end) { + showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error'); + return; + } + if (end < start) { + showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error'); + return; + } + const course = fillCourseSelect.value; + let changed = 0; + calendarGrid.querySelectorAll('.calendar-day').forEach(day => { + const sameCourse = !course || day.dataset.course === course; + const inRange = day.dataset.date >= start && day.dataset.date <= end; + if (sameCourse && inRange) { + day.querySelector('.calendar-day-activity').value = activityId; + updateCalendarDayVisual(day); + changed += 1; + } + }); + renderCalendarTotals(); + showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success'); + } + + function renderCalendarGrid(rows) { + const grouped = groupBy(rows, row => row.courseNumber); + calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => ` +
+
+

${course} курс

+ ${courseRows.length} дней +
+
+ ${renderCourseSemesters(Number(course), courseRows)} +
+
+ `).join(''); + populateFillCourseSelect(); + renderCalendarTotals(); + } + + function renderCourseSemesters(course, courseRows) { + const calendar = selectedEditorCalendar(); + const semestersForCourse = semesterBlocksForCourse(calendar, course, courseRows); + return semestersForCourse.map(block => ` +
+
+

${block.semesterNumber} семестр

+ ${escapeHtml(block.caption)} +
+ ${renderSemesterTable(block.rows)} +
+ `).join(''); + } + + function renderSemesterTable(rows) { + if (!rows.length) { + return '
Нет дат в этом семестре
'; + } + + const rowsByWeek = groupBy(rows, row => row.weekNumber); + const weeks = Array.from(rowsByWeek.keys()).sort((a, b) => Number(a) - Number(b)); + const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row])); + return ` +
+ + + + + ${weeks.map(week => ``).join('')} + + + + ${DAY_OPTIONS.map(day => ` + + + ${weeks.map(week => { + const row = cellByWeekAndDay.get(`${week}:${day.value}`); + return row ? renderCalendarDay(row) : ''; + }).join('')} + + `).join('')} + +
День${week}
${escapeHtml(day.label.slice(0, 2))}
+
+ `; + } + + function renderCalendarDay(row) { + const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId(); + const dayName = shortDayLabel(row.dayOfWeek); + const activity = activityById(selected) || activityTypes[0] || {}; + const code = activity.code || row.activityCode || 'Т'; + const color = activity.colorCode || '#64748b'; + return ` + + ${escapeHtml(code)} + + + `; + } + + function openCalendarDayDialog(day) { + activeCalendarDay = day; + calendarDayDialogDate.textContent = `${formatLongDate(day.dataset.date)} (${shortDayLabel(day.dataset.day)})`; + calendarDayDialogCourse.textContent = `${day.dataset.course} курс`; + calendarDayDialogWeek.textContent = `${day.dataset.week} неделя`; + calendarDayDialogDay.textContent = dayLabel(day.dataset.day); + calendarDayDialogActivitySelect.innerHTML = options( + activityTypes.map(toActivityOption), + day.querySelector('.calendar-day-activity').value + ); + syncSelects(calendarDayDialogActivitySelect); + updateCalendarDayDialogSummary(day); + calendarDayDialog.classList.add('open'); + } + + function closeCalendarDayDialog() { + activeCalendarDay = null; + calendarDayDialog.classList.remove('open'); + } + + function updateCalendarDayDialogSummary(day) { + const activity = activityById(day.querySelector('.calendar-day-activity').value); + if (!activity) { + calendarDayDialogActivitySummary.innerHTML = ''; + return; + } + calendarDayDialogActivitySummary.innerHTML = ` + ${escapeHtml(activity.code)} +
+ ${escapeHtml(activity.name)} +

${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}

+
+ `; + } + + function updateCalendarDayVisual(day) { + if (!day) return; + const select = day.querySelector('.calendar-day-activity'); + const activity = activityById(select.value); + const code = activity?.code || '-'; + const color = activity?.colorCode || '#64748b'; + day.style.setProperty('--activity-color', color); + day.querySelector('.calendar-day-code').textContent = code; + day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`); + day.setAttribute('title', `${formatLongDate(day.dataset.date)}, ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, ${code} - ${activity?.name || ''}`); + } + + function buildDefaultGrid(calendar) { + const rows = []; + const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate); + for (let course = 1; course <= calendar.courseCount; course += 1) { + dates.forEach((date, index) => { + rows.push({ + id: null, + calendarId: calendar.id, + courseNumber: course, + date: date.iso, + weekNumber: Math.floor(index / 7) + 1, + dayOfWeek: date.dayOfWeek, + activityTypeId: theoryActivityId(), + activityCode: 'Т' + }); + }); + } + return rows; + } + + function semesterBlocksForCourse(calendar, course, courseRows) { + const sortedRows = [...courseRows].sort((a, b) => String(a.date).localeCompare(String(b.date))); + const year = academicYears.find(item => String(item.id) === String(calendar?.academicYearId)); + const yearSemesters = [...(year?.semesters || [])] + .sort((a, b) => String(a.startDate).localeCompare(String(b.startDate))); + + if (!yearSemesters.length) { + return [{ + semesterNumber: `${course * 2 - 1}-${course * 2}`, + caption: dateRangeCaption(sortedRows), + rows: sortedRows + }]; + } + + return yearSemesters.map((semester, index) => { + const nextSemester = yearSemesters[index + 1]; + const startDate = index === 0 + ? minIso(calendar.academicYearStartDate, semester.startDate) + : semester.startDate; + const endDate = nextSemester + ? previousIsoDate(nextSemester.startDate) + : maxIso(calendar.academicYearEndDate, semester.endDate); + const rows = sortedRows.filter(row => row.date >= startDate && row.date <= endDate); + return { + semesterNumber: course * 2 - 1 + index, + caption: dateRangeCaption(rows) || `${formatLongDate(startDate)} - ${formatLongDate(endDate)}`, + rows + }; + }); + } + + function renderCalendarTotals() { + const counts = new Map(); + calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => { + const activity = activityById(select.value); + const key = activity ? activity.code : '-'; + counts.set(key, (counts.get(key) || 0) + 1); + }); + calendarTotals.innerHTML = Array.from(counts.entries()) + .sort(([a], [b]) => a.localeCompare(b, 'ru')) + .map(([code, count]) => { + const activity = activityTypes.find(item => item.code === code); + return `${escapeHtml(code)}: ${count}`; + }).join(''); + } + + function populateYearSelects() { + const yearOptions = '' + + academicYears.map(year => ``).join(''); + semesterYearSelect.innerHTML = yearOptions; + calendarYearSelect.innerHTML = yearOptions; + syncSelects(semesterYearSelect, calendarYearSelect); + } + + function populateSpecialtySelects() { + calendarSpecialtySelect.innerHTML = '' + + specialties.map(speciality => ``).join(''); + populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value); + syncSelects(calendarSpecialtySelect); + } + + function populateProfileSelect(select, specialtyId, selectedProfileId = null) { + const profiles = profilesBySpecialty.get(String(specialtyId)) || []; + select.innerHTML = profiles.length + ? '' + profiles.map(profile => + `` + ).join('') + : ''; + if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) { + select.value = selectedProfileId; + } + syncSelects(select); + } + + function populateStudyFormSelect() { + calendarStudyFormSelect.innerHTML = '' + + studyForms.map(form => ``).join(''); + syncSelects(calendarStudyFormSelect); + } + + function populateActivitySelects() { + const activityOptions = '' + + activityTypes.map(type => ``).join(''); + fillActivitySelect.innerHTML = activityOptions; + syncSelects(fillActivitySelect); + } + + function populateEditorCalendarSelect() { + const current = editorCalendarSelect.value; + editorCalendarSelect.innerHTML = '' + + calendars.map(calendar => ``).join(''); + if (current && calendars.some(calendar => String(calendar.id) === String(current))) { + editorCalendarSelect.value = current; + } + syncSelects(editorCalendarSelect); + } + + function populateFillCourseSelect() { + const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course)); + fillCourseSelect.innerHTML = '' + + Array.from(courses).sort((a, b) => Number(a) - Number(b)) + .map(course => ``).join(''); + syncSelects(fillCourseSelect); + } + + function options(items, selectedValue) { + const selected = selectedValue == null ? '' : String(selectedValue); + return '' + items.map(item => { + const value = String(item.value); + return ``; + }).join(''); + } + + function toActivityOption(type) { + return { value: type.id, label: `${type.code} - ${type.name}` }; + } + + function selectedEditorCalendar() { + return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value)); + } + + function theoryActivityId() { + return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || ''; + } + + function activityIdByCode(code) { + return activityTypes.find(type => type.code === code)?.id || null; + } + + function activityById(id) { + return activityTypes.find(type => String(type.id) === String(id)); + } + + function semesterLabel(semester) { + const type = normalizeSemesterType(semester.semesterType); + return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; + } + + function normalizeSemesterType(type) { + return String(type || '').toLowerCase(); + } + + function specialityLabel(speciality) { + const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id; + const name = speciality.specialityName || speciality.name || ''; + return name ? `${code} - ${name}` : String(code); + } + + function dayLabel(value) { + return DAY_OPTIONS.find(day => String(day.value) === String(value))?.label || '-'; + } + + function shortDayLabel(value) { + const label = dayLabel(value); + return label === '-' ? '-' : label.slice(0, 2); + } + + function formatShortDate(value) { + const [year, month, day] = String(value).split('-'); + return `${day}.${month}`; + } + + function formatLongDate(value) { + const [year, month, day] = String(value).split('-'); + return `${day}.${month}.${year}`; + } + + function dateRangeCaption(rows) { + if (!rows.length) return ''; + const dates = rows.map(row => row.date).sort(); + return `${formatLongDate(dates[0])} - ${formatLongDate(dates[dates.length - 1])}`; + } + + function minIso(a, b) { + return String(a) <= String(b) ? a : b; + } + + function maxIso(a, b) { + return String(a) >= String(b) ? a : b; + } + + function previousIsoDate(value) { + const date = new Date(`${value}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() - 1); + return date.toISOString().slice(0, 10); + } + + function enumerateDates(startIso, endIso) { + const result = []; + const current = new Date(`${startIso}T00:00:00Z`); + const end = new Date(`${endIso}T00:00:00Z`); + while (current <= end) { + const iso = current.toISOString().slice(0, 10); + const jsDay = current.getUTCDay(); + result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay }); + current.setUTCDate(current.getUTCDate() + 1); + } + return result; + } + + function groupBy(items, keyFn) { + const grouped = new Map(); + items.forEach(item => { + const key = keyFn(item); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(item); + }); + return grouped; + } + + function syncSelects(...selects) { + selects.filter(Boolean).forEach(select => { + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + } +} diff --git a/frontend/admin/js/views/departments-data.js b/frontend/admin/js/views/departments-data.js index 45eca7f..2e2e64f 100644 --- a/frontend/admin/js/views/departments-data.js +++ b/frontend/admin/js/views/departments-data.js @@ -7,22 +7,15 @@ export async function initDepartmentsData() { const createDeptForm = document.getElementById('create-department-form'); const createSpecForm = document.getElementById('create-specialty-form'); - const createProfileForm = document.getElementById('create-profile-form'); - const profileSpecialtySelect = document.getElementById('profile-specialty'); - const profilesTbody = document.getElementById('profiles-tbody'); const editDeptForm = document.getElementById('edit-department-form'); const editSpecForm = document.getElementById('edit-specialty-form'); - const editProfileForm = document.getElementById('edit-profile-form'); const editDeptModal = document.getElementById('modal-edit-department'); const editSpecModal = document.getElementById('modal-edit-specialty'); - const editProfileModal = document.getElementById('modal-edit-profile'); const editDeptClose = document.getElementById('modal-edit-department-close'); const editSpecClose = document.getElementById('modal-edit-specialty-close'); - const editProfileClose = document.getElementById('modal-edit-profile-close'); let departments = []; let specialties = []; - let profiles = []; async function loadData() { // Load Departments @@ -37,11 +30,8 @@ export async function initDepartmentsData() { try { specialties = await api.get('/api/specialties'); renderSpecialties(); - renderProfileSpecialtySelect(); - await loadProfiles(); } catch (e) { specTbody.innerHTML = '-'; - profilesTbody.innerHTML = '-'; } } @@ -81,52 +71,6 @@ export async function initDepartmentsData() { `).join(''); } - function renderProfileSpecialtySelect() { - const current = profileSpecialtySelect.value; - profileSpecialtySelect.innerHTML = '' + - specialties.map(s => ``).join(''); - if (current && specialties.some(s => String(s.id) === String(current))) { - profileSpecialtySelect.value = current; - } else if (specialties.length) { - profileSpecialtySelect.value = String(specialties[0].id); - } - syncSelect(profileSpecialtySelect); - } - - async function loadProfiles() { - const specialtyId = profileSpecialtySelect.value; - if (!specialtyId) { - profiles = []; - profilesTbody.innerHTML = 'Выберите специальность'; - return; - } - profilesTbody.innerHTML = 'Загрузка...'; - try { - profiles = await api.get(`/api/specialties/${specialtyId}/profiles`); - renderProfiles(); - } catch (error) { - profilesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; - } - } - - function renderProfiles() { - if (!profiles.length) { - profilesTbody.innerHTML = 'Профили не созданы'; - return; - } - profilesTbody.innerHTML = profiles.map(profile => ` - - ${profile.id} - ${escapeHtml(profile.name)} - ${escapeHtml(profile.description || '-')} - - - - - - `).join(''); - } - createDeptForm.addEventListener('submit', async (e) => { e.preventDefault(); hideAlert('create-dept-alert'); @@ -197,59 +141,6 @@ export async function initDepartmentsData() { } }); - profileSpecialtySelect.addEventListener('change', loadProfiles); - - createProfileForm.addEventListener('submit', async (e) => { - e.preventDefault(); - hideAlert('create-profile-alert'); - const specialtyId = profileSpecialtySelect.value; - const name = document.getElementById('profile-name').value.trim(); - const description = document.getElementById('profile-description').value.trim(); - - if (!specialtyId || !name) { - showAlert('create-profile-alert', 'Выберите специальность и заполните название профиля', 'error'); - return; - } - - try { - await api.post(`/api/specialties/${specialtyId}/profiles`, { name, description }); - showAlert('create-profile-alert', `Профиль "${name}" создан`, 'success'); - createProfileForm.reset(); - profileSpecialtySelect.value = specialtyId; - syncSelect(profileSpecialtySelect); - await loadProfiles(); - } catch (error) { - showAlert('create-profile-alert', error.message || 'Ошибка создания профиля', 'error'); - } - }); - - profilesTbody.addEventListener('click', async (e) => { - const editBtn = e.target.closest('.btn-edit-profile'); - const deleteBtn = e.target.closest('.btn-delete-profile'); - - if (editBtn) { - const profile = profiles.find(item => item.id == editBtn.dataset.id); - if (!profile) return; - document.getElementById('edit-profile-id').value = profile.id; - document.getElementById('edit-profile-name').value = profile.name || ''; - document.getElementById('edit-profile-description').value = profile.description || ''; - hideAlert('edit-profile-alert'); - editProfileModal.classList.add('open'); - return; - } - - if (deleteBtn) { - if (!confirm('Удалить профиль обучения?')) return; - try { - await api.delete(`/api/specialties/${profileSpecialtySelect.value}/profiles/${deleteBtn.dataset.id}`); - showAlert('create-profile-alert', 'Профиль удалён', 'success'); - await loadProfiles(); - } catch (error) { - showAlert('create-profile-alert', error.message || 'Ошибка удаления профиля', 'error'); - } - } - }); - specTbody.addEventListener('click', async (e) => { const editBtn = e.target.closest('.btn-edit-specialty'); const deleteBtn = e.target.closest('.btn-delete-specialty'); @@ -322,45 +213,14 @@ export async function initDepartmentsData() { } }); - editProfileForm.addEventListener('submit', async (e) => { - e.preventDefault(); - hideAlert('edit-profile-alert'); - const specialtyId = profileSpecialtySelect.value; - const id = document.getElementById('edit-profile-id').value; - const name = document.getElementById('edit-profile-name').value.trim(); - const description = document.getElementById('edit-profile-description').value.trim(); - - if (!specialtyId || !id || !name) { - showAlert('edit-profile-alert', 'Заполните название профиля', 'error'); - return; - } - - try { - await api.put(`/api/specialties/${specialtyId}/profiles/${id}`, { id: Number(id), name, description }); - editProfileModal.classList.remove('open'); - showAlert('create-profile-alert', `Профиль "${name}" обновлён`, 'success'); - await loadProfiles(); - } catch (error) { - showAlert('edit-profile-alert', error.message || 'Ошибка обновления профиля', 'error'); - } - }); - editDeptClose.addEventListener('click', () => editDeptModal.classList.remove('open')); editSpecClose.addEventListener('click', () => editSpecModal.classList.remove('open')); - editProfileClose.addEventListener('click', () => editProfileModal.classList.remove('open')); editDeptModal.addEventListener('click', (e) => { if (e.target === editDeptModal) editDeptModal.classList.remove('open'); }); editSpecModal.addEventListener('click', (e) => { if (e.target === editSpecModal) editSpecModal.classList.remove('open'); }); - editProfileModal.addEventListener('click', (e) => { - if (e.target === editProfileModal) editProfileModal.classList.remove('open'); - }); await loadData(); - - function syncSelect(select) { - select.dispatchEvent(new Event('change', { bubbles: true })); - } } diff --git a/frontend/admin/js/views/groups.js b/frontend/admin/js/views/groups.js index d06cf62..70606ba 100644 --- a/frontend/admin/js/views/groups.js +++ b/frontend/admin/js/views/groups.js @@ -225,6 +225,7 @@ export async function initGroups() { String(calendar.academicYearId) === String(yearId) && String(calendar.specialtyId) === String(group.specialtyId || group.specialityCode) && String(calendar.specialtyProfileId) === String(group.specialtyProfileId) + && String(calendar.studyFormId) === String(group.educationFormId) ) : []; calendarSelect.innerHTML = matching.length diff --git a/frontend/admin/js/views/profiles.js b/frontend/admin/js/views/profiles.js new file mode 100644 index 0000000..299a768 --- /dev/null +++ b/frontend/admin/js/views/profiles.js @@ -0,0 +1,192 @@ +import { api } from '../api.js'; +import { escapeHtml, showAlert, hideAlert } from '../utils.js'; + +export async function initProfiles() { + const createProfileForm = document.getElementById('create-profile-form'); + const profileSpecialtySelect = document.getElementById('profile-specialty'); + const profileNameInput = document.getElementById('profile-name'); + const profileDescriptionInput = document.getElementById('profile-description'); + const profilesTbody = document.getElementById('profiles-tbody'); + const profilesContext = document.getElementById('profiles-context'); + const refreshButton = document.getElementById('profiles-refresh'); + const editProfileForm = document.getElementById('edit-profile-form'); + const editProfileModal = document.getElementById('modal-edit-profile'); + const editProfileClose = document.getElementById('modal-edit-profile-close'); + + let specialties = []; + let profiles = []; + + await loadSpecialties(); + + profileSpecialtySelect.addEventListener('change', loadProfiles); + refreshButton.addEventListener('click', loadProfiles); + + createProfileForm.addEventListener('submit', async (event) => { + event.preventDefault(); + hideAlert('create-profile-alert'); + + const specialtyId = profileSpecialtySelect.value; + const name = profileNameInput.value.trim(); + const description = profileDescriptionInput.value.trim(); + + if (!specialtyId || !name) { + showAlert('create-profile-alert', 'Выберите специальность и заполните название профиля', 'error'); + return; + } + + try { + await api.post(`/api/specialties/${specialtyId}/profiles`, { name, description }); + showAlert('create-profile-alert', `Профиль "${name}" создан`, 'success'); + createProfileForm.reset(); + profileSpecialtySelect.value = specialtyId; + syncSelect(profileSpecialtySelect); + await loadProfiles(); + } catch (error) { + showAlert('create-profile-alert', error.message || 'Ошибка создания профиля', 'error'); + } + }); + + profilesTbody.addEventListener('click', async (event) => { + const editButton = event.target.closest('.btn-edit-profile'); + const deleteButton = event.target.closest('.btn-delete-profile'); + + if (editButton) { + const profile = profiles.find(item => String(item.id) === String(editButton.dataset.id)); + if (!profile) return; + + document.getElementById('edit-profile-id').value = profile.id; + document.getElementById('edit-profile-name').value = profile.name || ''; + document.getElementById('edit-profile-description').value = profile.description || ''; + hideAlert('edit-profile-alert'); + editProfileModal.classList.add('open'); + return; + } + + if (deleteButton) { + if (!confirm('Удалить профиль обучения?')) return; + try { + await api.delete(`/api/specialties/${profileSpecialtySelect.value}/profiles/${deleteButton.dataset.id}`); + showAlert('create-profile-alert', 'Профиль удалён', 'success'); + await loadProfiles(); + } catch (error) { + showAlert('create-profile-alert', error.message || 'Ошибка удаления профиля', 'error'); + } + } + }); + + editProfileForm.addEventListener('submit', async (event) => { + event.preventDefault(); + hideAlert('edit-profile-alert'); + + const specialtyId = profileSpecialtySelect.value; + const id = document.getElementById('edit-profile-id').value; + const name = document.getElementById('edit-profile-name').value.trim(); + const description = document.getElementById('edit-profile-description').value.trim(); + + if (!specialtyId || !id || !name) { + showAlert('edit-profile-alert', 'Заполните название профиля', 'error'); + return; + } + + try { + await api.put(`/api/specialties/${specialtyId}/profiles/${id}`, { id: Number(id), name, description }); + editProfileModal.classList.remove('open'); + showAlert('create-profile-alert', `Профиль "${name}" обновлён`, 'success'); + await loadProfiles(); + } catch (error) { + showAlert('edit-profile-alert', error.message || 'Ошибка обновления профиля', 'error'); + } + }); + + editProfileClose.addEventListener('click', () => editProfileModal.classList.remove('open')); + editProfileModal.addEventListener('click', (event) => { + if (event.target === editProfileModal) { + editProfileModal.classList.remove('open'); + } + }); + + async function loadSpecialties() { + profilesTbody.innerHTML = 'Загрузка...'; + try { + specialties = await api.get('/api/specialties'); + renderSpecialtySelect(); + await loadProfiles(); + } catch (error) { + profileSpecialtySelect.innerHTML = ''; + profilesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + syncSelect(profileSpecialtySelect); + } + } + + function renderSpecialtySelect() { + if (!specialties.length) { + profileSpecialtySelect.innerHTML = ''; + syncSelect(profileSpecialtySelect); + return; + } + + const current = profileSpecialtySelect.value; + profileSpecialtySelect.innerHTML = '' + + specialties.map(specialty => { + const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id; + const name = specialty.specialityName || specialty.name || 'Без названия'; + return ``; + }).join(''); + + if (current && specialties.some(specialty => String(specialty.id) === String(current))) { + profileSpecialtySelect.value = current; + } else { + profileSpecialtySelect.value = String(specialties[0].id); + } + syncSelect(profileSpecialtySelect); + } + + async function loadProfiles() { + const specialtyId = profileSpecialtySelect.value; + const specialty = specialties.find(item => String(item.id) === String(specialtyId)); + profilesContext.textContent = specialty ? specialtyLabel(specialty) : ''; + + if (!specialtyId) { + profiles = []; + profilesTbody.innerHTML = 'Выберите специальность'; + return; + } + + profilesTbody.innerHTML = 'Загрузка...'; + try { + profiles = await api.get(`/api/specialties/${specialtyId}/profiles`); + renderProfiles(); + } catch (error) { + profilesTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; + } + } + + function renderProfiles() { + if (!profiles.length) { + profilesTbody.innerHTML = 'Профили не созданы'; + return; + } + + profilesTbody.innerHTML = profiles.map(profile => ` + + ${profile.id} + ${escapeHtml(profile.name)} + ${escapeHtml(profile.description || '-')} + + + + + + `).join(''); + } + + function specialtyLabel(specialty) { + const code = specialty.specialityCode || specialty.specialtyCode || specialty.specialty_code || specialty.id; + const name = specialty.specialityName || specialty.name || 'Без названия'; + return `${code} · ${name}`; + } + + function syncSelect(select) { + select.dispatchEvent(new Event('change', { bubbles: true })); + } +} diff --git a/frontend/admin/js/views/schedule.js b/frontend/admin/js/views/schedule.js index ee38e92..d0f0fa3 100644 --- a/frontend/admin/js/views/schedule.js +++ b/frontend/admin/js/views/schedule.js @@ -48,49 +48,6 @@ export async function initSchedule() { const timeSlotsTbody = document.getElementById('time-slots-tbody'); const refreshSlotsButton = document.getElementById('time-slots-refresh'); - const academicYearForm = document.getElementById('academic-year-form'); - const academicYearFormTitle = document.getElementById('academic-year-form-title'); - const academicYearIdInput = document.getElementById('academic-year-id'); - const academicYearTitleInput = document.getElementById('academic-year-title'); - const academicYearStartInput = document.getElementById('academic-year-start'); - const academicYearEndInput = document.getElementById('academic-year-end'); - const academicYearResetButton = document.getElementById('academic-year-reset'); - const academicYearsTbody = document.getElementById('academic-years-tbody'); - const academicYearsRefreshButton = document.getElementById('academic-years-refresh'); - - const semesterForm = document.getElementById('semester-form'); - const semesterFormTitle = document.getElementById('semester-form-title'); - const semesterIdInput = document.getElementById('semester-id'); - const semesterYearSelect = document.getElementById('semester-year'); - const semesterTypeSelect = document.getElementById('semester-type'); - const semesterStartInput = document.getElementById('semester-start'); - const semesterEndInput = document.getElementById('semester-end'); - const semesterResetButton = document.getElementById('semester-reset'); - - const calendarForm = document.getElementById('academic-calendar-form'); - const calendarFormTitle = document.getElementById('academic-calendar-form-title'); - const calendarIdInput = document.getElementById('academic-calendar-id'); - const calendarTitleInput = document.getElementById('academic-calendar-title'); - const calendarYearSelect = document.getElementById('academic-calendar-year'); - const calendarSpecialtySelect = document.getElementById('academic-calendar-specialty'); - const calendarProfileSelect = document.getElementById('academic-calendar-profile'); - const calendarStudyFormSelect = document.getElementById('academic-calendar-study-form'); - const calendarCourseCountInput = document.getElementById('academic-calendar-course-count'); - const calendarResetButton = document.getElementById('academic-calendar-reset'); - const calendarsRefreshButton = document.getElementById('academic-calendars-refresh'); - const calendarsTbody = document.getElementById('academic-calendars-tbody'); - - const editorCalendarSelect = document.getElementById('calendar-editor-calendar'); - const editorLoadButton = document.getElementById('calendar-editor-load'); - const editorSaveButton = document.getElementById('calendar-editor-save'); - const fillCourseSelect = document.getElementById('calendar-fill-course'); - const fillStartInput = document.getElementById('calendar-fill-start'); - const fillEndInput = document.getElementById('calendar-fill-end'); - const fillActivitySelect = document.getElementById('calendar-fill-activity'); - const fillApplyButton = document.getElementById('calendar-fill-apply'); - const calendarGrid = document.getElementById('calendar-grid'); - const calendarTotals = document.getElementById('calendar-totals'); - let rules = []; let subjects = []; let groups = []; @@ -98,22 +55,15 @@ export async function initSchedule() { let classrooms = []; let lessonTypes = []; let timeSlots = []; - let academicYears = []; let semesters = []; - let specialties = []; - let profilesBySpecialty = new Map(); - let studyForms = []; - let activityTypes = []; - let calendars = []; - let currentGridRows = []; bindEvents(); try { - await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears(), loadStudyForms(), loadActivityTypes()]); + await Promise.all([loadBaseLists(), loadTimeSlots(), loadYears()]); renderRuleGroups([]); renderRuleSlots([{}]); - await Promise.all([loadRules(), loadCalendars()]); + await loadRules(); } catch (error) { showAlert('schedule-rule-alert', error.message || 'Ошибка загрузки данных расписания', 'error'); } @@ -121,18 +71,9 @@ export async function initSchedule() { function bindEvents() { refreshRulesButton?.addEventListener('click', loadRules); refreshSlotsButton?.addEventListener('click', loadTimeSlots); - academicYearsRefreshButton?.addEventListener('click', loadYears); - calendarsRefreshButton?.addEventListener('click', loadCalendars); ruleResetButton?.addEventListener('click', resetRuleForm); slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}])); timeSlotResetButton?.addEventListener('click', resetTimeSlotForm); - academicYearResetButton?.addEventListener('click', resetAcademicYearForm); - semesterResetButton?.addEventListener('click', resetSemesterForm); - calendarResetButton?.addEventListener('click', resetCalendarForm); - calendarSpecialtySelect?.addEventListener('change', () => populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value)); - editorLoadButton?.addEventListener('click', loadCalendarGrid); - editorSaveButton?.addEventListener('click', saveCalendarGrid); - fillApplyButton?.addEventListener('click', applyCalendarFill); ruleSlotsContainer.addEventListener('click', (event) => { const removeButton = event.target.closest('.schedule-slot-remove'); @@ -142,44 +83,27 @@ export async function initSchedule() { renderRuleSlots(slots.length ? slots : [{}]); }); - calendarGrid.addEventListener('change', (event) => { - if (event.target.matches('.calendar-day-activity')) { - renderCalendarTotals(); - } - }); - ruleForm.addEventListener('submit', saveRule); timeSlotForm.addEventListener('submit', saveTimeSlot); - academicYearForm.addEventListener('submit', saveAcademicYear); - semesterForm.addEventListener('submit', saveSemester); - calendarForm.addEventListener('submit', saveCalendar); - rulesTbody.addEventListener('click', handleRuleTableClick); timeSlotsTbody.addEventListener('click', handleTimeSlotTableClick); - academicYearsTbody.addEventListener('click', handleAcademicYearTableClick); - calendarsTbody.addEventListener('click', handleCalendarTableClick); } async function loadBaseLists() { - [subjects, groups, teachers, classrooms, lessonTypes, specialties] = await Promise.all([ + [subjects, groups, teachers, classrooms, lessonTypes] = await Promise.all([ api.get('/api/subjects'), api.get('/api/groups'), api.get('/api/users/teachers'), api.get('/api/classrooms'), - api.get('/api/lesson-types'), - api.get('/api/specialties') + api.get('/api/lesson-types') ]); - await loadProfiles(); populateSubjectSelects(); - populateSpecialtySelects(); } - 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 loadYears() { + const academicYears = await api.get('/api/admin/calendar/years'); + semesters = academicYears.flatMap(year => year.semesters || []); + populateSemesterSelects(); } async function loadRules() { @@ -418,6 +342,7 @@ export async function initSchedule() { showAlert('time-slot-alert', 'Временной слот сохранён', 'success'); resetTimeSlotForm(); await loadTimeSlots(); + renderRuleSlots(readRuleSlots(false).length ? readRuleSlots(false) : [{}]); } catch (error) { showAlert('time-slot-alert', error.message || 'Ошибка сохранения временного слота', 'error'); } @@ -426,6 +351,7 @@ export async function initSchedule() { async function handleTimeSlotTableClick(event) { const editButton = event.target.closest('.btn-edit-time-slot'); const deleteButton = event.target.closest('.btn-delete-time-slot'); + if (editButton) { const slot = timeSlots.find(item => item.id == editButton.dataset.id); if (!slot) return; @@ -439,12 +365,14 @@ export async function initSchedule() { timeSlotForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } + if (deleteButton) { if (!confirm('Удалить временной слот?')) return; try { await api.delete('/api/admin/time-slots/' + deleteButton.dataset.id); showAlert('time-slot-alert', 'Временной слот удалён', 'success'); await loadTimeSlots(); + renderRuleSlots(readRuleSlots(false).length ? readRuleSlots(false) : [{}]); } catch (error) { showAlert('time-slot-alert', error.message || 'Ошибка удаления временного слота', 'error'); } @@ -458,426 +386,10 @@ export async function initSchedule() { hideAlert('time-slot-alert'); } - async function loadYears() { - academicYearsTbody.innerHTML = 'Загрузка...'; - try { - academicYears = await api.get('/api/admin/calendar/years'); - semesters = academicYears.flatMap(year => year.semesters || []); - renderAcademicYears(); - populateYearSelects(); - populateSemesterSelects(); - } catch (error) { - academicYearsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; - } - } - - function renderAcademicYears() { - if (!academicYears.length) { - academicYearsTbody.innerHTML = 'Учебные годы не созданы'; - return; - } - academicYearsTbody.innerHTML = academicYears.map(year => ` - - ${escapeHtml(year.title)} - ${escapeHtml(year.startDate)} - ${escapeHtml(year.endDate)} - ${renderSemesterList(year.semesters || [])} - - - - - - `).join(''); - } - - function renderSemesterList(items) { - if (!items.length) return 'Нет семестров'; - return items.map(semester => ` -
- ${escapeHtml(semesterLabel(semester))} - -
- `).join(''); - } - - async function saveAcademicYear(event) { - event.preventDefault(); - hideAlert('academic-year-alert'); - const payload = { - title: academicYearTitleInput.value.trim(), - startDate: academicYearStartInput.value, - endDate: academicYearEndInput.value - }; - try { - if (!payload.title || !payload.startDate || !payload.endDate) throw new Error('Заполните название и даты учебного года'); - const id = academicYearIdInput.value; - await (id - ? api.put('/api/admin/calendar/years/' + id, payload) - : api.post('/api/admin/calendar/years', payload)); - showAlert('academic-year-alert', 'Учебный год сохранён', 'success'); - resetAcademicYearForm(); - await loadYears(); - await loadCalendars(); - } catch (error) { - showAlert('academic-year-alert', error.message || 'Ошибка сохранения учебного года', 'error'); - } - } - - async function handleAcademicYearTableClick(event) { - const editYearButton = event.target.closest('.btn-edit-year'); - const deleteYearButton = event.target.closest('.btn-delete-year'); - const editSemesterButton = event.target.closest('.btn-edit-semester'); - if (editYearButton) { - const year = academicYears.find(item => item.id == editYearButton.dataset.id); - if (!year) return; - academicYearFormTitle.textContent = 'Редактирование учебного года'; - academicYearIdInput.value = year.id; - academicYearTitleInput.value = year.title || ''; - academicYearStartInput.value = year.startDate || ''; - academicYearEndInput.value = year.endDate || ''; - hideAlert('academic-year-alert'); - academicYearForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - if (editSemesterButton) { - const semester = semesters.find(item => item.id == editSemesterButton.dataset.id); - if (!semester) return; - semesterFormTitle.textContent = 'Редактирование семестра'; - semesterIdInput.value = semester.id; - semesterYearSelect.value = semester.academicYearId || ''; - semesterTypeSelect.value = normalizeSemesterType(semester.semesterType); - semesterStartInput.value = semester.startDate || ''; - semesterEndInput.value = semester.endDate || ''; - syncSelects(semesterYearSelect, semesterTypeSelect); - hideAlert('semester-alert'); - semesterForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - if (deleteYearButton) { - if (!confirm('Удалить учебный год вместе с семестрами и календарными графиками?')) return; - try { - await api.delete('/api/admin/calendar/years/' + deleteYearButton.dataset.id); - showAlert('academic-year-alert', 'Учебный год удалён', 'success'); - await loadYears(); - await loadCalendars(); - } catch (error) { - showAlert('academic-year-alert', error.message || 'Ошибка удаления учебного года', 'error'); - } - } - } - - function resetAcademicYearForm() { - academicYearForm.reset(); - academicYearFormTitle.textContent = 'Учебный год'; - academicYearIdInput.value = ''; - hideAlert('academic-year-alert'); - } - - async function saveSemester(event) { - event.preventDefault(); - hideAlert('semester-alert'); - const yearId = semesterYearSelect.value; - const payload = { - semesterType: semesterTypeSelect.value, - startDate: semesterStartInput.value, - endDate: semesterEndInput.value - }; - try { - if (!yearId || !payload.semesterType || !payload.startDate || !payload.endDate) throw new Error('Заполните учебный год, тип и даты семестра'); - const id = semesterIdInput.value; - await (id - ? api.put('/api/admin/calendar/semesters/' + id, payload) - : api.post(`/api/admin/calendar/years/${yearId}/semesters`, payload)); - showAlert('semester-alert', 'Семестр сохранён', 'success'); - resetSemesterForm(); - await loadYears(); - } catch (error) { - showAlert('semester-alert', error.message || 'Ошибка сохранения семестра', 'error'); - } - } - - function resetSemesterForm() { - semesterForm.reset(); - semesterFormTitle.textContent = 'Семестр'; - semesterIdInput.value = ''; - syncSelects(semesterYearSelect, semesterTypeSelect); - hideAlert('semester-alert'); - } - - async function loadStudyForms() { - studyForms = await api.get('/api/admin/academic-calendars/study-forms'); - populateStudyFormSelect(); - } - - async function loadActivityTypes() { - activityTypes = await api.get('/api/admin/calendar/activity-types'); - populateActivitySelects(); - } - - async function loadCalendars() { - calendarsTbody.innerHTML = 'Загрузка...'; - try { - calendars = await api.get('/api/admin/academic-calendars'); - renderCalendars(); - populateEditorCalendarSelect(); - } catch (error) { - calendarsTbody.innerHTML = `Ошибка загрузки: ${escapeHtml(error.message)}`; - } - } - - function renderCalendars() { - if (!calendars.length) { - calendarsTbody.innerHTML = 'Календарные графики не созданы'; - return; - } - calendarsTbody.innerHTML = calendars.map(calendar => ` - - ${escapeHtml(calendar.title)} - ${escapeHtml(calendar.academicYearTitle || '-')} - ${escapeHtml(`${calendar.specialtyCode || ''} ${calendar.specialtyName || ''}`.trim() || '-')} - ${escapeHtml(calendar.specialtyProfileName || '-')} - ${escapeHtml(calendar.studyFormName || '-')} - ${escapeHtml(String(calendar.courseCount || '-'))} - - - - - - - `).join(''); - } - - async function saveCalendar(event) { - event.preventDefault(); - hideAlert('academic-calendar-alert'); - const payload = { - id: calendarIdInput.value ? Number(calendarIdInput.value) : null, - title: calendarTitleInput.value.trim(), - academicYearId: Number(calendarYearSelect.value), - specialtyId: Number(calendarSpecialtySelect.value), - specialtyProfileId: Number(calendarProfileSelect.value), - studyFormId: Number(calendarStudyFormSelect.value), - courseCount: Number(calendarCourseCountInput.value) - }; - try { - if (!payload.title || !payload.academicYearId || !payload.specialtyId - || !payload.specialtyProfileId || !payload.studyFormId || !payload.courseCount) { - throw new Error('Заполните все поля календарного графика'); - } - const id = calendarIdInput.value; - const saved = id - ? await api.put('/api/admin/academic-calendars/' + id, payload) - : await api.post('/api/admin/academic-calendars', payload); - showAlert('academic-calendar-alert', `График "${escapeHtml(saved.title)}" сохранён`, 'success'); - resetCalendarForm(); - await loadCalendars(); - } catch (error) { - showAlert('academic-calendar-alert', error.message || 'Ошибка сохранения календарного графика', 'error'); - } - } - - async function handleCalendarTableClick(event) { - const editButton = event.target.closest('.btn-edit-calendar'); - const gridButton = event.target.closest('.btn-open-calendar-grid'); - const deleteButton = event.target.closest('.btn-delete-calendar'); - if (editButton) { - const calendar = calendars.find(item => item.id == editButton.dataset.id); - if (!calendar) return; - calendarFormTitle.textContent = 'Редактирование календарного графика'; - calendarIdInput.value = calendar.id; - calendarTitleInput.value = calendar.title || ''; - calendarYearSelect.value = calendar.academicYearId || ''; - calendarSpecialtySelect.value = calendar.specialtyId || ''; - populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value, calendar.specialtyProfileId); - calendarStudyFormSelect.value = calendar.studyFormId || ''; - calendarCourseCountInput.value = calendar.courseCount || 4; - syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect); - hideAlert('academic-calendar-alert'); - calendarForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - if (gridButton) { - editorCalendarSelect.value = gridButton.dataset.id; - syncSelects(editorCalendarSelect); - await loadCalendarGrid(); - calendarGrid.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - if (deleteButton) { - if (!confirm('Удалить календарный график?')) return; - try { - await api.delete('/api/admin/academic-calendars/' + deleteButton.dataset.id); - showAlert('academic-calendar-alert', 'Календарный график удалён', 'success'); - await loadCalendars(); - } catch (error) { - showAlert('academic-calendar-alert', error.message || 'Ошибка удаления календарного графика', 'error'); - } - } - } - - function resetCalendarForm() { - calendarForm.reset(); - calendarFormTitle.textContent = 'Календарный учебный график'; - calendarIdInput.value = ''; - calendarCourseCountInput.value = 4; - populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value); - syncSelects(calendarYearSelect, calendarSpecialtySelect, calendarProfileSelect, calendarStudyFormSelect); - hideAlert('academic-calendar-alert'); - } - - async function loadCalendarGrid() { - hideAlert('calendar-editor-alert'); - const calendar = selectedEditorCalendar(); - if (!calendar) { - showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); - return; - } - try { - const rows = await api.get(`/api/admin/academic-calendars/${calendar.id}/grid`); - currentGridRows = rows.length ? rows : buildDefaultGrid(calendar); - renderCalendarGrid(currentGridRows); - showAlert('calendar-editor-alert', rows.length ? 'Сетка загружена' : 'Подготовлена новая сетка по датам учебного года', 'success'); - } catch (error) { - showAlert('calendar-editor-alert', error.message || 'Ошибка загрузки сетки', 'error'); - } - } - - async function saveCalendarGrid() { - hideAlert('calendar-editor-alert'); - const calendar = selectedEditorCalendar(); - if (!calendar) { - showAlert('calendar-editor-alert', 'Выберите календарный график', 'error'); - return; - } - const rows = Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => ({ - calendarId: calendar.id, - courseNumber: Number(day.dataset.course), - date: day.dataset.date, - weekNumber: Number(day.dataset.week), - dayOfWeek: Number(day.dataset.day), - activityTypeId: Number(day.querySelector('.calendar-day-activity').value) - })); - if (!rows.length) { - showAlert('calendar-editor-alert', 'Сначала загрузите сетку графика', 'error'); - return; - } - try { - await api.put(`/api/admin/academic-calendars/${calendar.id}/grid`, rows); - showAlert('calendar-editor-alert', 'Календарный учебный график сохранён', 'success'); - await loadCalendarGrid(); - } catch (error) { - showAlert('calendar-editor-alert', error.message || 'Ошибка сохранения сетки', 'error'); - } - } - - function applyCalendarFill() { - hideAlert('calendar-editor-alert'); - const activityId = fillActivitySelect.value; - const start = fillStartInput.value; - const end = fillEndInput.value; - if (!activityId || !start || !end) { - showAlert('calendar-editor-alert', 'Выберите даты и код активности для заполнения', 'error'); - return; - } - if (end < start) { - showAlert('calendar-editor-alert', 'Дата окончания не может быть раньше даты начала', 'error'); - return; - } - const course = fillCourseSelect.value; - let changed = 0; - calendarGrid.querySelectorAll('.calendar-day').forEach(day => { - const sameCourse = !course || day.dataset.course === course; - const inRange = day.dataset.date >= start && day.dataset.date <= end; - if (sameCourse && inRange) { - day.querySelector('.calendar-day-activity').value = activityId; - changed += 1; - } - }); - renderCalendarTotals(); - showAlert('calendar-editor-alert', `Обновлено ячеек: ${changed}`, 'success'); - } - - function renderCalendarGrid(rows) { - const grouped = groupBy(rows, row => row.courseNumber); - calendarGrid.innerHTML = Array.from(grouped.entries()).map(([course, courseRows]) => ` -
-
-

${course} курс

- ${courseRows.length} дней -
-
- ${courseRows.map(renderCalendarDay).join('')} -
-
- `).join(''); - populateFillCourseSelect(); - renderCalendarTotals(); - } - - function renderCalendarDay(row) { - const selected = row.activityTypeId || activityIdByCode(row.activityCode) || theoryActivityId(); - const dayName = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'][Number(row.dayOfWeek) - 1] || ''; - return ` -
-
- ${formatShortDate(row.date)} - ${dayName} -
- - ${row.weekNumber} нед. -
- `; - } - - function buildDefaultGrid(calendar) { - const rows = []; - const dates = enumerateDates(calendar.academicYearStartDate, calendar.academicYearEndDate); - for (let course = 1; course <= calendar.courseCount; course += 1) { - dates.forEach((date, index) => { - rows.push({ - id: null, - calendarId: calendar.id, - courseNumber: course, - date: date.iso, - weekNumber: Math.floor(index / 7) + 1, - dayOfWeek: date.dayOfWeek, - activityTypeId: theoryActivityId(), - activityCode: 'Т' - }); - }); - } - return rows; - } - - function renderCalendarTotals() { - const counts = new Map(); - calendarGrid.querySelectorAll('.calendar-day-activity').forEach(select => { - const activity = activityById(select.value); - const key = activity ? activity.code : '-'; - counts.set(key, (counts.get(key) || 0) + 1); - }); - calendarTotals.innerHTML = Array.from(counts.entries()) - .sort(([a], [b]) => a.localeCompare(b, 'ru')) - .map(([code, count]) => { - const activity = activityTypes.find(item => item.code === code); - return `${escapeHtml(code)}: ${count}`; - }).join(''); - } - function populateSubjectSelects() { ruleSubjectSelect.innerHTML = '' + subjects.map(subject => ``).join(''); - } - - function populateYearSelects() { - const yearOptions = '' + - academicYears.map(year => ``).join(''); - semesterYearSelect.innerHTML = yearOptions; - calendarYearSelect.innerHTML = yearOptions; - syncSelects(semesterYearSelect, calendarYearSelect); + syncSelects(ruleSubjectSelect); } function populateSemesterSelects() { @@ -887,57 +399,6 @@ export async function initSchedule() { syncSelects(ruleSemesterSelect); } - function populateSpecialtySelects() { - calendarSpecialtySelect.innerHTML = '' + - specialties.map(speciality => ``).join(''); - populateProfileSelect(calendarProfileSelect, calendarSpecialtySelect.value); - syncSelects(calendarSpecialtySelect); - } - - function populateProfileSelect(select, specialtyId, selectedProfileId = null) { - const profiles = profilesBySpecialty.get(String(specialtyId)) || []; - select.innerHTML = profiles.length - ? '' + profiles.map(profile => - `` - ).join('') - : ''; - if (selectedProfileId && profiles.some(profile => String(profile.id) === String(selectedProfileId))) { - select.value = selectedProfileId; - } - syncSelects(select); - } - - function populateStudyFormSelect() { - calendarStudyFormSelect.innerHTML = '' + - studyForms.map(form => ``).join(''); - syncSelects(calendarStudyFormSelect); - } - - function populateActivitySelects() { - const activityOptions = '' + - activityTypes.map(type => ``).join(''); - fillActivitySelect.innerHTML = activityOptions; - syncSelects(fillActivitySelect); - } - - function populateEditorCalendarSelect() { - const current = editorCalendarSelect.value; - editorCalendarSelect.innerHTML = '' + - calendars.map(calendar => ``).join(''); - if (current && calendars.some(calendar => String(calendar.id) === String(current))) { - editorCalendarSelect.value = current; - } - syncSelects(editorCalendarSelect); - } - - function populateFillCourseSelect() { - const courses = new Set(Array.from(calendarGrid.querySelectorAll('.calendar-day')).map(day => day.dataset.course)); - fillCourseSelect.innerHTML = '' + - Array.from(courses).sort((a, b) => Number(a) - Number(b)) - .map(course => ``).join(''); - syncSelects(fillCourseSelect); - } - function options(items, selectedValue) { const selected = selectedValue == null ? '' : String(selectedValue); return '' + items.map(item => { @@ -946,10 +407,6 @@ export async function initSchedule() { }).join(''); } - function toActivityOption(type) { - return { value: type.id, label: `${type.code} - ${type.name}` }; - } - function toTimeSlotOption(slot) { return { value: slot.id, label: `${slot.orderNumber} пара, ${trimTime(slot.startTime)} - ${trimTime(slot.endTime)}` }; } @@ -967,22 +424,6 @@ export async function initSchedule() { return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` }; } - function selectedEditorCalendar() { - return calendars.find(calendar => String(calendar.id) === String(editorCalendarSelect.value)); - } - - function theoryActivityId() { - return activityTypes.find(type => type.code === 'Т')?.id || activityTypes[0]?.id || ''; - } - - function activityIdByCode(code) { - return activityTypes.find(type => type.code === code)?.id || null; - } - - function activityById(id) { - return activityTypes.find(type => String(type.id) === String(id)); - } - function semesterLabel(semester) { const type = normalizeSemesterType(semester.semesterType); return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; @@ -997,45 +438,11 @@ export async function initSchedule() { return String(type || '').toLowerCase(); } - function specialityLabel(speciality) { - const code = speciality.specialityCode || speciality.specialtyCode || speciality.specialty_code || speciality.id; - const name = speciality.specialityName || speciality.name || ''; - return name ? `${code} - ${name}` : String(code); - } - function trimTime(value) { if (!value) return ''; return String(value).slice(0, 5); } - function formatShortDate(value) { - const [year, month, day] = String(value).split('-'); - return `${day}.${month}`; - } - - function enumerateDates(startIso, endIso) { - const result = []; - const current = new Date(`${startIso}T00:00:00Z`); - const end = new Date(`${endIso}T00:00:00Z`); - while (current <= end) { - const iso = current.toISOString().slice(0, 10); - const jsDay = current.getUTCDay(); - result.push({ iso, dayOfWeek: jsDay === 0 ? 7 : jsDay }); - current.setUTCDate(current.getUTCDate() + 1); - } - return result; - } - - function groupBy(items, keyFn) { - const grouped = new Map(); - items.forEach(item => { - const key = keyFn(item); - if (!grouped.has(key)) grouped.set(key, []); - grouped.get(key).push(item); - }); - return grouped; - } - function syncSelects(...selects) { selects.filter(Boolean).forEach(select => { select.dispatchEvent(new Event('change', { bubbles: true })); diff --git a/frontend/admin/views/academic-calendar.html b/frontend/admin/views/academic-calendar.html new file mode 100644 index 0000000..b84b862 --- /dev/null +++ b/frontend/admin/views/academic-calendar.html @@ -0,0 +1,225 @@ +
+

Учебный год

+
+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+ +
+
+

Учебные годы

+ +
+
+ + + + + + + + + + + + + + +
НазваниеПериодСеместрыДействия
Загрузка...
+
+
+ +
+

Семестр

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
+ +
+
+

Календарный учебный график

+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+
+

Календарные графики

+ +
+
+ + + + + + + + + + + + + + + + + +
НазваниеУчебный годСпециальностьПрофильФормаКурсовДействия
Загрузка...
+
+
+ +
+

Редактор годового графика

+
+
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+
+ + diff --git a/frontend/admin/views/departments-data.html b/frontend/admin/views/departments-data.html index 897ee96..dd31814 100644 --- a/frontend/admin/views/departments-data.html +++ b/frontend/admin/views/departments-data.html @@ -40,47 +40,6 @@ -
-

Профили обучения

-
-
-
- - -
-
- - -
-
- - -
- -
- -
-
- - - - - - - - - - - - - - -
IDПрофильОписаниеДействия
Выберите специальность
-
-
-

Создание специальности

@@ -165,25 +124,3 @@
- - diff --git a/frontend/admin/views/profiles.html b/frontend/admin/views/profiles.html new file mode 100644 index 0000000..b2a40ac --- /dev/null +++ b/frontend/admin/views/profiles.html @@ -0,0 +1,72 @@ +
+
+

Создание профиля обучения

+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+
+

Профили выбранной специальности

+ +
+
+
+ + + + + + + + + + + + + + +
IDПрофильОписаниеДействия
Загрузка...
+
+
+ + diff --git a/frontend/admin/views/schedule.html b/frontend/admin/views/schedule.html index af7209e..57825e5 100644 --- a/frontend/admin/views/schedule.html +++ b/frontend/admin/views/schedule.html @@ -130,197 +130,3 @@ - -
-

Учебный год

-
- -
-
- - -
-
- - -
-
- - -
- - -
- -
-
- -
-
-

Учебные годы

- -
-
- - - - - - - - - - - - - - -
НазваниеПериодСеместрыДействия
Загрузка...
-
-
- -
-

Семестр

-
- -
-
- - -
-
- - -
-
- - -
-
- - -
- - -
- -
-
- -
-
-

Календарный учебный график

- -
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
- -
-
- -
-
-

Календарные графики

- -
-
- - - - - - - - - - - - - - - - - -
НазваниеУчебный годСпециальностьПрофильФормаКурсовДействия
Загрузка...
-
-
- -
-

Редактор годового графика

-
-
- - -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
- -
- -
-
-