From fb9511280a28299a12471e9ed626f994b9d8b62c Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Mon, 26 Oct 2026 17:15:16 +0300 Subject: [PATCH] fix: polish attestation columns and inline grading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - use numeric semesters 1–8 and add show/hide publication per attestation - edit attestation results directly in the group table without a modal - hide unpublished attestations from students - align header separators, average column and action buttons with the existing UI - remove missed-hours fields until a separate attendance model is ready --- .../ysu/lms/progress/AttestationController.kt | 7 + .../ru/ysu/lms/progress/AttestationService.kt | 37 ++-- .../main/kotlin/ru/ysu/lms/progress/Dtos.kt | 12 +- .../ru/ysu/lms/progress/domain/Attestation.kt | 4 +- ...__attestation_semester_and_publication.sql | 16 ++ .../web/admin/AdminAttestationController.kt | 34 ++-- .../kotlin/ru/ysu/lms/web/admin/AdminForms.kt | 1 - .../ysu/lms/web/admin/AdminGroupController.kt | 7 +- .../ru/ysu/lms/web/admin/AdminLabView.kt | 10 +- .../lms/web/admin/GroupAttestationViews.kt | 29 ++-- .../ysu/lms/web/api/ApiClientAttestation.kt | 16 ++ .../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 11 +- .../ru/ysu/lms/web/form/FormFeedback.kt | 1 - .../ysu/lms/web/student/StudentController.kt | 4 +- web/src/main/resources/static/css/app.css | 8 +- .../main/resources/static/js/admin-group.js | 162 ++++++++++++------ web/src/main/resources/static/js/app.js | 1 - .../main/resources/templates/admin/group.html | 97 +++++------ .../resources/templates/fragments/base.html | 4 +- .../templates/student/dashboard.html | 5 +- 20 files changed, 290 insertions(+), 176 deletions(-) create mode 100644 services/progress-service/src/main/resources/db/migration/V12__attestation_semester_and_publication.sql diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationController.kt index dfecdf4..a850d0b 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationController.kt @@ -39,6 +39,13 @@ class AttestationController( return mapOf("ok" to true) } + @PostMapping("/attestations/{id}/publication") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun setPublished( + @PathVariable id: UUID, + @RequestBody body: AttestationPublicationRequest, + ): AttestationResponse = service.setPublished(id, body.published) + @PostMapping("/attestations/{id}/results") @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun setResult( diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationService.kt index 7802584..df938fd 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AttestationService.kt @@ -30,27 +30,41 @@ class AttestationService( groupId: UUID, studentId: UUID, ): List = - list(groupId).map { column -> - column.copy(results = column.results.filter { it.studentId == studentId.toString() }) - } + list(groupId) + .filter { it.published } + .map { column -> + column.copy(results = column.results.filter { it.studentId == studentId.toString() }) + } @Transactional fun create(request: AttestationCreateRequest): AttestationResponse { val kind = normalizeKind(request.kind) - val semester = request.semester.trim() - if (semester.isEmpty()) throw BadRequestException("Укажите семестр") - if (semester.length > MAX_SEMESTER_LENGTH) throw BadRequestException("Слишком длинное название семестра") + if (request.semester !in 1..8) throw BadRequestException("Укажите семестр от 1 до 8") val column = attestations.save( Attestation( groupId = UUID.fromString(request.groupId), kind = kind, - semester = semester, + semester = request.semester, ), ) return toResponse(column, emptyList()) } + @Transactional + fun setPublished( + attestationId: UUID, + published: Boolean, + ): AttestationResponse { + val column = + attestations + .findById(attestationId) + .orElseThrow { NotFoundException("Аттестация не найдена") } + column.published = published + attestations.save(column) + return toResponse(column, results.findByAttestationId(attestationId)) + } + @Transactional fun delete(attestationId: UUID) { val column = @@ -81,15 +95,10 @@ class AttestationService( val allowed = VALUES[column.kind] ?: emptySet() if (value !in allowed) throw BadRequestException("Недопустимая оценка для этой формы аттестации") - val missedHours = if (column.kind == KIND_INTERMEDIATE) request.missedHours else null - if (missedHours != null && missedHours < 0) { - throw BadRequestException("Число пропущенных часов не может быть отрицательным") - } val entity = (existing ?: AttestationResult(attestationId = attestationId, studentId = studentId)).apply { this.value = value - this.missedHours = missedHours this.updatedAt = Instant.now() } results.save(entity) @@ -111,17 +120,17 @@ class AttestationService( groupId = column.groupId.toString(), kind = column.kind, semester = column.semester, + published = column.published, results = rows .sortedBy { it.studentId } - .map { AttestationResultResponse(it.studentId.toString(), it.value, it.missedHours) }, + .map { AttestationResultResponse(it.studentId.toString(), it.value) }, ) companion object { const val KIND_INTERMEDIATE = "intermediate" const val KIND_PASS = "pass" const val KIND_EXAM = "exam" - private const val MAX_SEMESTER_LENGTH = 100 private val KINDS = setOf(KIND_INTERMEDIATE, KIND_PASS, KIND_EXAM) private val VALUES = diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt index f4d4953..e22f51d 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt @@ -167,27 +167,31 @@ data class AttestationCreateRequest( val groupId: String, /** intermediate — промежуточная аттестация, pass — зачёт, exam — экзамен. */ val kind: String, - val semester: String, + /** Номер семестра: 1–8. */ + val semester: Int, ) data class AttestationResultRequest( val studentId: String, /** Пустое значение очищает результат. */ val value: String = "", - val missedHours: Int? = null, ) data class AttestationResultResponse( val studentId: String, val value: String, - val missedHours: Int? = null, +) + +data class AttestationPublicationRequest( + val published: Boolean, ) data class AttestationResponse( val id: String, val groupId: String, val kind: String, - val semester: String, + val semester: Int, + val published: Boolean = false, val results: List = emptyList(), ) diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Attestation.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Attestation.kt index 0e39311..e23ad8f 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Attestation.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/domain/Attestation.kt @@ -16,7 +16,8 @@ class Attestation( @Id var id: UUID = Ids.v7(), @Column(name = "group_id", nullable = false) var groupId: UUID = UUID(0, 0), @Column(nullable = false, length = 16) var kind: String = "intermediate", - @Column(nullable = false, length = 100) var semester: String = "", + @Column(nullable = false) var semester: Int = 1, + @Column(nullable = false) var published: Boolean = false, @Column(name = "created_at", nullable = false) var createdAt: Instant = Instant.now(), ) @@ -27,7 +28,6 @@ class AttestationResult( @Id @Column(name = "attestation_id", nullable = false) var attestationId: UUID = UUID(0, 0), @Id @Column(name = "student_id", nullable = false) var studentId: UUID = UUID(0, 0), @Column(nullable = false, length = 32) var value: String = "", - @Column(name = "missed_hours") var missedHours: Int? = null, @Column(name = "updated_at", nullable = false) var updatedAt: Instant = Instant.now(), ) diff --git a/services/progress-service/src/main/resources/db/migration/V12__attestation_semester_and_publication.sql b/services/progress-service/src/main/resources/db/migration/V12__attestation_semester_and_publication.sql new file mode 100644 index 0000000..b390f9c --- /dev/null +++ b/services/progress-service/src/main/resources/db/migration/V12__attestation_semester_and_publication.sql @@ -0,0 +1,16 @@ +-- Семестр аттестации — номер 1–8; пропуски пока не учитываем. +ALTER TABLE attestations ADD COLUMN semester_no integer; + +UPDATE attestations +SET semester_no = NULLIF(regexp_replace(semester, '[^0-9]', '', 'g'), '')::integer +WHERE regexp_replace(semester, '[^0-9]', '', 'g') ~ '^[1-8]$'; + +UPDATE attestations SET semester_no = 1 WHERE semester_no IS NULL; + +ALTER TABLE attestations ALTER COLUMN semester_no SET NOT NULL; +ALTER TABLE attestations DROP COLUMN semester; +ALTER TABLE attestations RENAME COLUMN semester_no TO semester; +ALTER TABLE attestations ADD CONSTRAINT attestations_semester_check CHECK (semester BETWEEN 1 AND 8); + +ALTER TABLE attestations ADD COLUMN published boolean NOT NULL DEFAULT false; +ALTER TABLE attestation_results DROP COLUMN missed_hours; diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAttestationController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAttestationController.kt index e16d8fe..693161c 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAttestationController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAttestationController.kt @@ -6,13 +6,17 @@ import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseBody import org.springframework.web.servlet.mvc.support.RedirectAttributes import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.AttestationCreatePayload +import ru.ysu.lms.web.api.AttestationPublicationPayload import ru.ysu.lms.web.api.AttestationResultPayload import ru.ysu.lms.web.api.createAttestation import ru.ysu.lms.web.api.deleteAttestation +import ru.ysu.lms.web.api.setAttestationPublication import ru.ysu.lms.web.api.setAttestationResult import ru.ysu.lms.web.session.SessionKeys @@ -29,8 +33,8 @@ class AdminAttestationController( redirect: RedirectAttributes, ): String = try { - val semester = form.semester.trim() - if (semester.isBlank()) throw ApiException(400, "Укажите семестр") + val semester = form.semester.trim().toIntOrNull() + if (semester == null || semester !in 1..8) throw ApiException(400, "Укажите семестр от 1 до 8") api.createAttestation( token(session), AttestationCreatePayload(groupId = id, kind = form.kind.trim(), semester = semester), @@ -72,7 +76,6 @@ class AdminAttestationController( AttestationResultPayload( studentId = form.studentId, value = form.value, - missedHours = parseMissedHours(form.missedHours), ), ) redirect.addFlashAttribute("notice", "Результат аттестации сохранён") @@ -82,13 +85,24 @@ class AdminAttestationController( "redirect:/admin/groups/$id" } - private fun parseMissedHours(raw: String?): Int? { - val text = raw?.trim().orEmpty() - if (text.isEmpty()) return null - val value = text.toIntOrNull() ?: throw ApiException(400, "Число пропущенных часов должно быть целым") - if (value < 0) throw ApiException(400, "Число пропущенных часов не может быть отрицательным") - return value - } + @PostMapping("/groups/{id}/attestations/{attestationId}/publication") + @ResponseBody + fun setAttestationPublication( + @PathVariable attestationId: String, + @RequestParam published: Boolean, + session: HttpSession, + ): Map = + try { + val result = + api.setAttestationPublication( + token(session), + attestationId, + AttestationPublicationPayload(published), + ) + mapOf("ok" to true, "published" to result.published) + } catch (ex: ApiException) { + mapOf("ok" to false, "error" to ex.message) + } private fun token(session: HttpSession): String = session.getAttribute(SessionKeys.TOKEN) as? String diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt index 2060425..8228e7f 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminForms.kt @@ -94,7 +94,6 @@ data class SetAttestationResultForm( val attestationId: String = "", val studentId: String = "", val value: String = "", - val missedHours: String? = null, ) data class SubmissionDownloadForm( diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt index f186a45..619d327 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt @@ -144,7 +144,6 @@ class AdminGroupController( val group = api.groupDetail(t, id) val labs = allLabs.filter { it.status == "published" } val attestations = GroupAttestationViews.columns(api.listAttestations(t, id)) - val attestationSemesters = GroupAttestationViews.semesters(allLabs, attestations) val grades = api.listGrades(t) val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment } @@ -193,7 +192,7 @@ class AdminGroupController( groupViewModel( t, id, - GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations, attestationSemesters), + GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations), ), ) return "admin/group" @@ -278,7 +277,6 @@ class AdminGroupController( val labPublication: Map, val submissionsBy: Map>, val attestations: List, - val attestationSemesters: List, ) private fun groupViewModel( @@ -303,7 +301,6 @@ class AdminGroupController( data.labPublication, submissionCounts, data.attestations, - data.attestationSemesters, ) } @@ -315,7 +312,6 @@ class AdminGroupController( val labPublication: Map, val submissionCounts: Map, val attestations: List, - val attestationSemesters: List, ) private fun fillGroupModel( @@ -329,7 +325,6 @@ class AdminGroupController( model.addAttribute("labPublication", view.labPublication) model.addAttribute("labSubmissionCounts", view.submissionCounts) model.addAttribute("attestations", view.attestations) - model.addAttribute("attestationSemesters", view.attestationSemesters) model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 }) } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt index 3e6b8a3..ab27eba 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabView.kt @@ -56,8 +56,12 @@ data class GroupStudentRow( /** Результат студента по одному столбцу аттестации. */ data class GroupAttestationResult( val value: String = "", +) + +/** Вариант оценки в выпадающем списке аттестации. */ +data class AttestationOption( + val value: String = "", val label: String = "", - val missedHours: Int? = null, ) /** Столбец аттестации на странице группы. */ @@ -65,7 +69,9 @@ data class GroupAttestation( val id: String = "", val kind: String = "", val kindLabel: String = "", - val semester: String = "", + val semester: Int = 1, + val published: Boolean = false, + val options: List = emptyList(), val results: Map = emptyMap(), ) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/GroupAttestationViews.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/GroupAttestationViews.kt index fd3df90..51b1f07 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/GroupAttestationViews.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/GroupAttestationViews.kt @@ -2,10 +2,16 @@ package ru.ysu.lms.web.admin import ru.ysu.lms.web.AttestationPresentation import ru.ysu.lms.web.api.AttestationDto -import ru.ysu.lms.web.api.LabSummaryDto /** Преобразование ответов сервиса аттестаций в модель страницы группы. */ object GroupAttestationViews { + private val VALUES_BY_KIND = + mapOf( + "intermediate" to listOf("excellent", "satisfactory", "unsatisfactory", "not_attested"), + "pass" to listOf("passed", "failed", "absent"), + "exam" to listOf("excellent", "good", "satisfactory", "unsatisfactory", "absent"), + ) + fun columns(attestations: List): List = attestations.map { column -> GroupAttestation( @@ -13,27 +19,18 @@ object GroupAttestationViews { kind = column.kind, kindLabel = AttestationPresentation.kindLabel(column.kind), semester = column.semester, + published = column.published, + options = + VALUES_BY_KIND[column.kind].orEmpty().map { value -> + AttestationOption(value, AttestationPresentation.valueLabel(column.kind, value)) + }, results = column.results.associate { row -> - row.studentId to - GroupAttestationResult( - value = row.value, - label = AttestationPresentation.valueLabel(column.kind, row.value), - missedHours = row.missedHours, - ) + row.studentId to GroupAttestationResult(value = row.value) }, ) } - fun semesters( - labs: List, - attestations: List, - ): List = - (labs.map { it.semesterTitle } + attestations.map { it.semester }) - .filter { it.isNotBlank() && it != "—" } - .distinct() - .sorted() - fun attach( rows: List, columns: List, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAttestation.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAttestation.kt index 0b31578..d975bec 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAttestation.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAttestation.kt @@ -64,6 +64,22 @@ fun ApiClient.setAttestationResult( .body(AttestationDto::class.java) } +fun ApiClient.setAttestationPublication( + token: String, + attestationId: String, + payload: AttestationPublicationPayload, +): AttestationDto = + call { + client + .post() + .uri("/api/v1/attestations/{id}/publication", attestationId) + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .body(AttestationDto::class.java) + } + fun ApiClient.myAttestations( token: String, groupId: String, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index 4c1c46a..82824f6 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -268,7 +268,6 @@ data class GradeDto( data class AttestationResultDto( val studentId: String = "", val value: String = "", - val missedHours: Int? = null, ) @JsonIgnoreProperties(ignoreUnknown = true) @@ -276,20 +275,24 @@ data class AttestationDto( val id: String = "", val groupId: String = "", val kind: String = "", - val semester: String = "", + val semester: Int = 1, + val published: Boolean = false, val results: List = emptyList(), ) data class AttestationCreatePayload( val groupId: String, val kind: String, - val semester: String, + val semester: Int, ) data class AttestationResultPayload( val studentId: String, val value: String, - val missedHours: Int? = null, +) + +data class AttestationPublicationPayload( + val published: Boolean, ) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt b/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt index ed7c14e..829307b 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt @@ -55,7 +55,6 @@ data class FormFeedback( Rule(Regex("(?iu)номер варианта"), listOf("number")), Rule(Regex("(?iu)код уровня"), listOf("code", "newLevelCode", "levels")), Rule(Regex("(?iu)оценк"), listOf("score", "value")), - Rule(Regex("(?iu)(пропущ.*час|число.*час)"), listOf("missedHours")), Rule(Regex("(?iu)семестр"), listOf("semester")), Rule(Regex("(?iu)тип аттестации"), listOf("kind")), Rule(Regex("(?iu)(максимум баллов|балл)"), listOf("pointsMax", "score")), diff --git a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt index 7a88fd0..68d9d66 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/student/StudentController.kt @@ -63,10 +63,9 @@ class StudentController( data class StudentAttestationView( val kindLabel: String, - val semester: String, + val semester: Int, val valueLabel: String, val positive: Boolean?, - val missedHours: Int?, ) @GetMapping @@ -98,7 +97,6 @@ class StudentController( semester = column.semester, valueLabel = result?.let { AttestationPresentation.valueLabel(column.kind, it.value) } ?: "—", positive = result?.let { AttestationPresentation.isPositive(column.kind, it.value) }, - missedHours = result?.missedHours, ) }, ) diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index fb02ac0..526831c 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -557,11 +557,13 @@ dialog.dlg.dlg-lg .dlg-body { max-height: 88vh; min-height: 62vh; } .matrix tbody tr:last-child td { border-bottom: none; } .matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); } .matrix td.cell-lab { min-width: 9.5rem; border-left: 1px solid var(--c-border); } -.matrix th.attestation-col { min-width: 10rem; border-left: 1px solid var(--c-border); } -.matrix td.attestation-cell { min-width: 10rem; border-left: 1px solid var(--c-border); } -.matrix td.average-cell { min-width: 7rem; border-left: 1px solid var(--c-border); font-weight: 600; } +.matrix th.attestation-col { min-width: 10rem; border-left: 1px solid var(--c-border); border-right: 1px solid var(--c-border); } +.matrix td.attestation-cell { min-width: 10rem; border-left: 1px solid var(--c-border); border-right: 1px solid var(--c-border); } +.matrix th.average-col { min-width: 7rem; border-left: 1px solid var(--c-border); border-right: 1px solid var(--c-border); } +.matrix td.average-cell { min-width: 7rem; border-left: 1px solid var(--c-border); border-right: 1px solid var(--c-border); font-weight: 600; } .attestation-head { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25rem; text-align: left; } .attestation-kind { font-weight: 600; color: var(--c-text); } +.attestation-select { width: 100%; min-width: 8.5rem; font-size: 0.8125rem; } .matrix td.student-cell { min-width: 12rem; } .student-name { font-weight: 600; font-size: 0.84375rem; } .student-cell .font-mono { margin-top: 0.125rem; } diff --git a/web/src/main/resources/static/js/admin-group.js b/web/src/main/resources/static/js/admin-group.js index 77d2036..6e01311 100644 --- a/web/src/main/resources/static/js/admin-group.js +++ b/web/src/main/resources/static/js/admin-group.js @@ -32,59 +32,6 @@ function openGradeEditor(btn) { if (d && d.showModal) d.showModal(); } -/* ---------- Аттестация: выбор результата ---------- */ - -var ATTESTATION_OPTIONS = { - intermediate: [['excellent', 'отл'], ['satisfactory', 'атт'], ['unsatisfactory', 'неуд'], ['not_attested', 'не атт']], - pass: [['passed', 'зачтено'], ['failed', 'не зачтено'], ['absent', 'неявка']], - exam: [['excellent', 'отлично'], ['good', 'хорошо'], ['satisfactory', 'удовлетворительно'], ['unsatisfactory', 'неудовлетворительно'], ['absent', 'неявка']] -}; - -function openAttestation(btn) { - var form = $id('attestation-form'); - if (!form) return; - - var kind = btn.getAttribute('data-attestation-kind') || 'intermediate'; - var student = btn.getAttribute('data-student') || ''; - var studentName = btn.getAttribute('data-student-name') || ''; - var label = btn.getAttribute('data-attestation-label') || 'Аттестация'; - var semester = btn.getAttribute('data-semester') || ''; - var value = btn.getAttribute('data-attestation-value') || ''; - - $id('attestation-id').value = btn.getAttribute('data-attestation') || ''; - $id('attestation-student').value = student; - $id('attestation-who').value = studentName + ' — ' + label + (semester ? ' (' + semester + ')' : ''); - - var select = $id('attestation-value'); - select.innerHTML = ''; - var empty = document.createElement('option'); - empty.value = ''; - empty.textContent = '— не выставлено —'; - select.appendChild(empty); - (ATTESTATION_OPTIONS[kind] || []).forEach(function (item) { - var option = document.createElement('option'); - option.value = item[0]; - option.textContent = item[1]; - select.appendChild(option); - }); - select.value = value; - - var hours = $id('attestation-hours'); - var hoursWrap = $id('attestation-hours-wrap'); - var missed = btn.getAttribute('data-missed-hours'); - if (hours) hours.value = missed && missed !== 'null' ? missed : ''; - if (hoursWrap) hoursWrap.hidden = kind !== 'intermediate'; - if (kind !== 'intermediate' && hours) hours.value = ''; - - var title = $id('attestation-title'); - if (title) title.textContent = label + ' — ' + studentName; - - var dialog = $id('modal-attestation'); - if (dialog && dialog.showModal) dialog.showModal(); -} - -window.openAttestation = openAttestation; - function csRow(text, tone) { var box = $id('cs-rows'); var row = document.createElement('div'); @@ -726,6 +673,115 @@ function submitGradeForm(form) { }); } +/* ---------- Аттестация: сохранение прямо из ячейки ---------- */ +function submitAttestationForm(form) { + if (form.dataset.saving === '1') return; + form.dataset.saving = '1'; + var select = form.querySelector('select[name="value"]'); + if (select) select.disabled = true; + + fetch(form.action, { + method: 'POST', + body: new FormData(form), + headers: { 'X-Requested-With': 'fetch' } + }) + .then(function (response) { + if (response.redirected && new URL(response.url, location.href).pathname === '/login') { + window.location.assign(response.url); + return null; + } + if (!response.ok) throw new Error('HTTP ' + response.status); + return response.text(); + }) + .then(function (html) { + if (!html) return; + var doc = new DOMParser().parseFromString(html, 'text/html'); + var feedbackNode = doc.getElementById('form-feedback'); + if (feedbackNode) { + var feedback = null; + try { + feedback = JSON.parse(feedbackNode.getAttribute('data-feedback') || 'null'); + } catch (e) { + feedback = null; + } + if (feedback && typeof window.lmsApplyFormFeedback === 'function') { + window.lmsApplyFormFeedback(feedback); + } + var errorToast = doc.querySelector('.toast-item.toast-error .toast-text'); + showToast(errorToast + ? errorToast.textContent.trim() + : ((feedback && feedback.general) || 'Не удалось сохранить результат'), 'error'); + return; + } + var error = doc.querySelector('.toast-item.toast-error .toast-text'); + if (error) { + showToast(error.textContent.trim(), 'error'); + return; + } + var notice = doc.querySelector('.toast-item.toast-success .toast-text'); + showToast(notice ? notice.textContent.trim() : 'Результат аттестации сохранён', 'success'); + }) + .catch(function (error) { + showToast('Не удалось сохранить результат: ' + error.message, 'error'); + }) + .finally(function () { + form.dataset.saving = ''; + if (select) select.disabled = false; + }); +} + +document.addEventListener('submit', function (ev) { + var form = ev.target; + if (!form || !form.classList || !form.classList.contains('js-attestation-form')) return; + ev.preventDefault(); + submitAttestationForm(form); +}, true); + +/* ---------- Показ/скрытие столбца аттестации ---------- */ +document.addEventListener('submit', function (ev) { + var form = ev.target; + if (!form || !form.classList || !form.classList.contains('js-att-pub-form')) return; + ev.preventDefault(); + var button = form.querySelector('button[type="submit"]'); + if (button) button.disabled = true; + + fetch(form.action, { + method: 'POST', + body: new FormData(form), + headers: { 'X-Requested-With': 'fetch' } + }) + .then(function (response) { + if (!response.ok) throw new Error('HTTP ' + response.status); + return response.json(); + }) + .then(function (result) { + if (!result || !result.ok) { + showToast((result && result.error) || 'Не удалось изменить публикацию аттестации', 'error'); + return; + } + var published = !!result.published; + var hidden = form.querySelector('input[name="published"]'); + if (hidden) hidden.value = published ? 'false' : 'true'; + if (button) { + button.classList.toggle('is-all', published); + button.classList.toggle('is-empty', !published); + button.title = published + ? 'Аттестация видна студентам — скрыть' + : 'Аттестация скрыта — показать студентам'; + } + var head = form.closest('th'); + var badge = head ? head.querySelector('.js-att-hidden') : null; + if (badge) badge.hidden = published; + showToast(published ? 'Аттестация открыта студентам' : 'Аттестация скрыта от студентов', 'success'); + }) + .catch(function (error) { + showToast('Не удалось изменить публикацию: ' + error.message, 'error'); + }) + .finally(function () { + if (button) button.disabled = false; + }); +}, true); + document.addEventListener('submit', function (ev) { if (!ev.target || ev.target.id !== 'grade-form') return; csSerialize(); diff --git a/web/src/main/resources/static/js/app.js b/web/src/main/resources/static/js/app.js index b1b73ea..abd376c 100644 --- a/web/src/main/resources/static/js/app.js +++ b/web/src/main/resources/static/js/app.js @@ -270,7 +270,6 @@ var kind = action.getAttribute('data-action'); var fn = { grade: window.openGradeEditor, - attestation: window.openAttestation, variant: window.openVariant, defense: window.openDefense, assign: window.openLabAssign, diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index 1fd44a7..5c0d5f3 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -16,7 +16,9 @@

- Скачать пароли + + Пароли +
@@ -71,21 +73,35 @@ - Средний балл + Средний балл
+
+ + + +
+ title="Удалить аттестацию" aria-label="Удалить аттестацию"> + +
Аттестация - — + Семестр 1 + скрыто
Пароль @@ -138,18 +154,21 @@ - — - +
+ + + + +
временный @@ -220,48 +239,24 @@
- - - - -

Например: «осень 2026» или «весна 2027». Семестр будет указан в ведомости и кабинете студента.

+ +

Номер семестра от 1 до 8. Он будет указан в ведомости и кабинете студента.

- - -
-

Результат аттестации

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

Оставьте пустым, чтобы убрать результат.

-
-
- -

Укажите для оценки «не атт», если известны пропуски по любой причине.

-
- -
-
-
- @@ -436,7 +431,7 @@
- + diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index 2a6dec5..d2cd225 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -17,7 +17,7 @@ - + @@ -25,7 +25,7 @@ - + diff --git a/web/src/main/resources/templates/student/dashboard.html b/web/src/main/resources/templates/student/dashboard.html index 02749f1..2043eb3 100644 --- a/web/src/main/resources/templates/student/dashboard.html +++ b/web/src/main/resources/templates/student/dashboard.html @@ -28,18 +28,17 @@
- + - + -
Форма аттестацииСеместрРезультатПропущено
Форма аттестацииСеместрРезультат
——— — —