fix: polish attestation columns and inline grading

- 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
This commit is contained in:
2026-10-26 17:15:16 +03:00
parent f7a1830548
commit fb9511280a
20 changed files with 290 additions and 176 deletions

View File

@@ -39,6 +39,13 @@ class AttestationController(
return mapOf("ok" to true) 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") @PostMapping("/attestations/{id}/results")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun setResult( fun setResult(

View File

@@ -30,27 +30,41 @@ class AttestationService(
groupId: UUID, groupId: UUID,
studentId: UUID, studentId: UUID,
): List<AttestationResponse> = ): List<AttestationResponse> =
list(groupId).map { column -> list(groupId)
column.copy(results = column.results.filter { it.studentId == studentId.toString() }) .filter { it.published }
} .map { column ->
column.copy(results = column.results.filter { it.studentId == studentId.toString() })
}
@Transactional @Transactional
fun create(request: AttestationCreateRequest): AttestationResponse { fun create(request: AttestationCreateRequest): AttestationResponse {
val kind = normalizeKind(request.kind) val kind = normalizeKind(request.kind)
val semester = request.semester.trim() if (request.semester !in 1..8) throw BadRequestException("Укажите семестр от 1 до 8")
if (semester.isEmpty()) throw BadRequestException("Укажите семестр")
if (semester.length > MAX_SEMESTER_LENGTH) throw BadRequestException("Слишком длинное название семестра")
val column = val column =
attestations.save( attestations.save(
Attestation( Attestation(
groupId = UUID.fromString(request.groupId), groupId = UUID.fromString(request.groupId),
kind = kind, kind = kind,
semester = semester, semester = request.semester,
), ),
) )
return toResponse(column, emptyList()) 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 @Transactional
fun delete(attestationId: UUID) { fun delete(attestationId: UUID) {
val column = val column =
@@ -81,15 +95,10 @@ class AttestationService(
val allowed = VALUES[column.kind] ?: emptySet() val allowed = VALUES[column.kind] ?: emptySet()
if (value !in allowed) throw BadRequestException("Недопустимая оценка для этой формы аттестации") 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 = val entity =
(existing ?: AttestationResult(attestationId = attestationId, studentId = studentId)).apply { (existing ?: AttestationResult(attestationId = attestationId, studentId = studentId)).apply {
this.value = value this.value = value
this.missedHours = missedHours
this.updatedAt = Instant.now() this.updatedAt = Instant.now()
} }
results.save(entity) results.save(entity)
@@ -111,17 +120,17 @@ class AttestationService(
groupId = column.groupId.toString(), groupId = column.groupId.toString(),
kind = column.kind, kind = column.kind,
semester = column.semester, semester = column.semester,
published = column.published,
results = results =
rows rows
.sortedBy { it.studentId } .sortedBy { it.studentId }
.map { AttestationResultResponse(it.studentId.toString(), it.value, it.missedHours) }, .map { AttestationResultResponse(it.studentId.toString(), it.value) },
) )
companion object { companion object {
const val KIND_INTERMEDIATE = "intermediate" const val KIND_INTERMEDIATE = "intermediate"
const val KIND_PASS = "pass" const val KIND_PASS = "pass"
const val KIND_EXAM = "exam" const val KIND_EXAM = "exam"
private const val MAX_SEMESTER_LENGTH = 100
private val KINDS = setOf(KIND_INTERMEDIATE, KIND_PASS, KIND_EXAM) private val KINDS = setOf(KIND_INTERMEDIATE, KIND_PASS, KIND_EXAM)
private val VALUES = private val VALUES =

View File

@@ -167,27 +167,31 @@ data class AttestationCreateRequest(
val groupId: String, val groupId: String,
/** intermediate — промежуточная аттестация, pass — зачёт, exam — экзамен. */ /** intermediate — промежуточная аттестация, pass — зачёт, exam — экзамен. */
val kind: String, val kind: String,
val semester: String, /** Номер семестра: 1–8. */
val semester: Int,
) )
data class AttestationResultRequest( data class AttestationResultRequest(
val studentId: String, val studentId: String,
/** Пустое значение очищает результат. */ /** Пустое значение очищает результат. */
val value: String = "", val value: String = "",
val missedHours: Int? = null,
) )
data class AttestationResultResponse( data class AttestationResultResponse(
val studentId: String, val studentId: String,
val value: String, val value: String,
val missedHours: Int? = null, )
data class AttestationPublicationRequest(
val published: Boolean,
) )
data class AttestationResponse( data class AttestationResponse(
val id: String, val id: String,
val groupId: String, val groupId: String,
val kind: String, val kind: String,
val semester: String, val semester: Int,
val published: Boolean = false,
val results: List<AttestationResultResponse> = emptyList(), val results: List<AttestationResultResponse> = emptyList(),
) )

View File

@@ -16,7 +16,8 @@ class Attestation(
@Id var id: UUID = Ids.v7(), @Id var id: UUID = Ids.v7(),
@Column(name = "group_id", nullable = false) var groupId: UUID = UUID(0, 0), @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 = 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(), @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 = "attestation_id", nullable = false) var attestationId: UUID = UUID(0, 0),
@Id @Column(name = "student_id", nullable = false) var studentId: 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(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(), @Column(name = "updated_at", nullable = false) var updatedAt: Instant = Instant.now(),
) )

View File

@@ -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;

View File

@@ -6,13 +6,17 @@ import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping 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 org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.AttestationCreatePayload 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.AttestationResultPayload
import ru.ysu.lms.web.api.createAttestation import ru.ysu.lms.web.api.createAttestation
import ru.ysu.lms.web.api.deleteAttestation 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.api.setAttestationResult
import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionKeys
@@ -29,8 +33,8 @@ class AdminAttestationController(
redirect: RedirectAttributes, redirect: RedirectAttributes,
): String = ): String =
try { try {
val semester = form.semester.trim() val semester = form.semester.trim().toIntOrNull()
if (semester.isBlank()) throw ApiException(400, "Укажите семестр") if (semester == null || semester !in 1..8) throw ApiException(400, "Укажите семестр от 1 до 8")
api.createAttestation( api.createAttestation(
token(session), token(session),
AttestationCreatePayload(groupId = id, kind = form.kind.trim(), semester = semester), AttestationCreatePayload(groupId = id, kind = form.kind.trim(), semester = semester),
@@ -72,7 +76,6 @@ class AdminAttestationController(
AttestationResultPayload( AttestationResultPayload(
studentId = form.studentId, studentId = form.studentId,
value = form.value, value = form.value,
missedHours = parseMissedHours(form.missedHours),
), ),
) )
redirect.addFlashAttribute("notice", "Результат аттестации сохранён") redirect.addFlashAttribute("notice", "Результат аттестации сохранён")
@@ -82,13 +85,24 @@ class AdminAttestationController(
"redirect:/admin/groups/$id" "redirect:/admin/groups/$id"
} }
private fun parseMissedHours(raw: String?): Int? { @PostMapping("/groups/{id}/attestations/{attestationId}/publication")
val text = raw?.trim().orEmpty() @ResponseBody
if (text.isEmpty()) return null fun setAttestationPublication(
val value = text.toIntOrNull() ?: throw ApiException(400, "Число пропущенных часов должно быть целым") @PathVariable attestationId: String,
if (value < 0) throw ApiException(400, "Число пропущенных часов не может быть отрицательным") @RequestParam published: Boolean,
return value session: HttpSession,
} ): Map<String, Any> =
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 = private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String session.getAttribute(SessionKeys.TOKEN) as? String

View File

@@ -94,7 +94,6 @@ data class SetAttestationResultForm(
val attestationId: String = "", val attestationId: String = "",
val studentId: String = "", val studentId: String = "",
val value: String = "", val value: String = "",
val missedHours: String? = null,
) )
data class SubmissionDownloadForm( data class SubmissionDownloadForm(

View File

@@ -144,7 +144,6 @@ class AdminGroupController(
val group = api.groupDetail(t, id) val group = api.groupDetail(t, id)
val labs = allLabs.filter { it.status == "published" } val labs = allLabs.filter { it.status == "published" }
val attestations = GroupAttestationViews.columns(api.listAttestations(t, id)) val attestations = GroupAttestationViews.columns(api.listAttestations(t, id))
val attestationSemesters = GroupAttestationViews.semesters(allLabs, attestations)
val grades = api.listGrades(t) val grades = api.listGrades(t)
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score } val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment } val generalCommentBy = grades.associate { "${it.studentId}|${it.labId}" to it.generalComment }
@@ -193,7 +192,7 @@ class AdminGroupController(
groupViewModel( groupViewModel(
t, t,
id, id,
GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations, attestationSemesters), GroupRenderData(group, labs, rows, labPublication, submissionsBy, attestations),
), ),
) )
return "admin/group" return "admin/group"
@@ -278,7 +277,6 @@ class AdminGroupController(
val labPublication: Map<String, GroupLabPublication>, val labPublication: Map<String, GroupLabPublication>,
val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>, val submissionsBy: Map<LabSummaryDto, Map<String, SubmissionDto>>,
val attestations: List<GroupAttestation>, val attestations: List<GroupAttestation>,
val attestationSemesters: List<String>,
) )
private fun groupViewModel( private fun groupViewModel(
@@ -303,7 +301,6 @@ class AdminGroupController(
data.labPublication, data.labPublication,
submissionCounts, submissionCounts,
data.attestations, data.attestations,
data.attestationSemesters,
) )
} }
@@ -315,7 +312,6 @@ class AdminGroupController(
val labPublication: Map<String, GroupLabPublication>, val labPublication: Map<String, GroupLabPublication>,
val submissionCounts: Map<String, Int>, val submissionCounts: Map<String, Int>,
val attestations: List<GroupAttestation>, val attestations: List<GroupAttestation>,
val attestationSemesters: List<String>,
) )
private fun fillGroupModel( private fun fillGroupModel(
@@ -329,7 +325,6 @@ class AdminGroupController(
model.addAttribute("labPublication", view.labPublication) model.addAttribute("labPublication", view.labPublication)
model.addAttribute("labSubmissionCounts", view.submissionCounts) model.addAttribute("labSubmissionCounts", view.submissionCounts)
model.addAttribute("attestations", view.attestations) model.addAttribute("attestations", view.attestations)
model.addAttribute("attestationSemesters", view.attestationSemesters)
model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 }) model.addAttribute("publishedLabsCount", view.labPublication.values.count { it.publishedCount > 0 })
} }

View File

@@ -56,8 +56,12 @@ data class GroupStudentRow(
/** Результат студента по одному столбцу аттестации. */ /** Результат студента по одному столбцу аттестации. */
data class GroupAttestationResult( data class GroupAttestationResult(
val value: String = "", val value: String = "",
)
/** Вариант оценки в выпадающем списке аттестации. */
data class AttestationOption(
val value: String = "",
val label: String = "", val label: String = "",
val missedHours: Int? = null,
) )
/** Столбец аттестации на странице группы. */ /** Столбец аттестации на странице группы. */
@@ -65,7 +69,9 @@ data class GroupAttestation(
val id: String = "", val id: String = "",
val kind: String = "", val kind: String = "",
val kindLabel: String = "", val kindLabel: String = "",
val semester: String = "", val semester: Int = 1,
val published: Boolean = false,
val options: List<AttestationOption> = emptyList(),
val results: Map<String, GroupAttestationResult> = emptyMap(), val results: Map<String, GroupAttestationResult> = emptyMap(),
) )

View File

@@ -2,10 +2,16 @@ package ru.ysu.lms.web.admin
import ru.ysu.lms.web.AttestationPresentation import ru.ysu.lms.web.AttestationPresentation
import ru.ysu.lms.web.api.AttestationDto import ru.ysu.lms.web.api.AttestationDto
import ru.ysu.lms.web.api.LabSummaryDto
/** Преобразование ответов сервиса аттестаций в модель страницы группы. */ /** Преобразование ответов сервиса аттестаций в модель страницы группы. */
object GroupAttestationViews { 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<AttestationDto>): List<GroupAttestation> = fun columns(attestations: List<AttestationDto>): List<GroupAttestation> =
attestations.map { column -> attestations.map { column ->
GroupAttestation( GroupAttestation(
@@ -13,27 +19,18 @@ object GroupAttestationViews {
kind = column.kind, kind = column.kind,
kindLabel = AttestationPresentation.kindLabel(column.kind), kindLabel = AttestationPresentation.kindLabel(column.kind),
semester = column.semester, semester = column.semester,
published = column.published,
options =
VALUES_BY_KIND[column.kind].orEmpty().map { value ->
AttestationOption(value, AttestationPresentation.valueLabel(column.kind, value))
},
results = results =
column.results.associate { row -> column.results.associate { row ->
row.studentId to row.studentId to GroupAttestationResult(value = row.value)
GroupAttestationResult(
value = row.value,
label = AttestationPresentation.valueLabel(column.kind, row.value),
missedHours = row.missedHours,
)
}, },
) )
} }
fun semesters(
labs: List<LabSummaryDto>,
attestations: List<GroupAttestation>,
): List<String> =
(labs.map { it.semesterTitle } + attestations.map { it.semester })
.filter { it.isNotBlank() && it != "—" }
.distinct()
.sorted()
fun attach( fun attach(
rows: List<GroupStudentRow>, rows: List<GroupStudentRow>,
columns: List<GroupAttestation>, columns: List<GroupAttestation>,

View File

@@ -64,6 +64,22 @@ fun ApiClient.setAttestationResult(
.body(AttestationDto::class.java) .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( fun ApiClient.myAttestations(
token: String, token: String,
groupId: String, groupId: String,

View File

@@ -268,7 +268,6 @@ data class GradeDto(
data class AttestationResultDto( data class AttestationResultDto(
val studentId: String = "", val studentId: String = "",
val value: String = "", val value: String = "",
val missedHours: Int? = null,
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -276,20 +275,24 @@ data class AttestationDto(
val id: String = "", val id: String = "",
val groupId: String = "", val groupId: String = "",
val kind: String = "", val kind: String = "",
val semester: String = "", val semester: Int = 1,
val published: Boolean = false,
val results: List<AttestationResultDto> = emptyList(), val results: List<AttestationResultDto> = emptyList(),
) )
data class AttestationCreatePayload( data class AttestationCreatePayload(
val groupId: String, val groupId: String,
val kind: String, val kind: String,
val semester: String, val semester: Int,
) )
data class AttestationResultPayload( data class AttestationResultPayload(
val studentId: String, val studentId: String,
val value: String, val value: String,
val missedHours: Int? = null, )
data class AttestationPublicationPayload(
val published: Boolean,
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)

View File

@@ -55,7 +55,6 @@ data class FormFeedback(
Rule(Regex("(?iu)номер варианта"), listOf("number")), Rule(Regex("(?iu)номер варианта"), listOf("number")),
Rule(Regex("(?iu)код уровня"), listOf("code", "newLevelCode", "levels")), Rule(Regex("(?iu)код уровня"), listOf("code", "newLevelCode", "levels")),
Rule(Regex("(?iu)оценк"), listOf("score", "value")), Rule(Regex("(?iu)оценк"), listOf("score", "value")),
Rule(Regex("(?iu)(пропущ.*час|число.*час)"), listOf("missedHours")),
Rule(Regex("(?iu)семестр"), listOf("semester")), Rule(Regex("(?iu)семестр"), listOf("semester")),
Rule(Regex("(?iu)тип аттестации"), listOf("kind")), Rule(Regex("(?iu)тип аттестации"), listOf("kind")),
Rule(Regex("(?iu)(максимум баллов|балл)"), listOf("pointsMax", "score")), Rule(Regex("(?iu)(максимум баллов|балл)"), listOf("pointsMax", "score")),

View File

@@ -63,10 +63,9 @@ class StudentController(
data class StudentAttestationView( data class StudentAttestationView(
val kindLabel: String, val kindLabel: String,
val semester: String, val semester: Int,
val valueLabel: String, val valueLabel: String,
val positive: Boolean?, val positive: Boolean?,
val missedHours: Int?,
) )
@GetMapping @GetMapping
@@ -98,7 +97,6 @@ class StudentController(
semester = column.semester, semester = column.semester,
valueLabel = result?.let { AttestationPresentation.valueLabel(column.kind, it.value) } ?: "—", valueLabel = result?.let { AttestationPresentation.valueLabel(column.kind, it.value) } ?: "—",
positive = result?.let { AttestationPresentation.isPositive(column.kind, it.value) }, positive = result?.let { AttestationPresentation.isPositive(column.kind, it.value) },
missedHours = result?.missedHours,
) )
}, },
) )

View File

@@ -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 tbody tr:last-child td { border-bottom: none; }
.matrix th.lab-col { min-width: 9.5rem; border-left: 1px solid var(--c-border); } .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 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 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); } .matrix td.attestation-cell { min-width: 10rem; 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); font-weight: 600; } .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-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-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; } .matrix td.student-cell { min-width: 12rem; }
.student-name { font-weight: 600; font-size: 0.84375rem; } .student-name { font-weight: 600; font-size: 0.84375rem; }
.student-cell .font-mono { margin-top: 0.125rem; } .student-cell .font-mono { margin-top: 0.125rem; }

View File

@@ -32,59 +32,6 @@ function openGradeEditor(btn) {
if (d && d.showModal) d.showModal(); 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) { function csRow(text, tone) {
var box = $id('cs-rows'); var box = $id('cs-rows');
var row = document.createElement('div'); 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) { document.addEventListener('submit', function (ev) {
if (!ev.target || ev.target.id !== 'grade-form') return; if (!ev.target || ev.target.id !== 'grade-form') return;
csSerialize(); csSerialize();

View File

@@ -270,7 +270,6 @@
var kind = action.getAttribute('data-action'); var kind = action.getAttribute('data-action');
var fn = { var fn = {
grade: window.openGradeEditor, grade: window.openGradeEditor,
attestation: window.openAttestation,
variant: window.openVariant, variant: window.openVariant,
defense: window.openDefense, defense: window.openDefense,
assign: window.openLabAssign, assign: window.openLabAssign,

View File

@@ -16,7 +16,9 @@
</p> </p>
</div> </div>
<div class="actions"> <div class="actions">
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a> <a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">
<i class="cell-ico ico bi bi-download" aria-hidden="true"></i> Пароли
</a>
<button class="btn btn-secondary" type="button" data-modal="modal-add-attestation">+ Аттестация</button> <button class="btn btn-secondary" type="button" data-modal="modal-add-attestation">+ Аттестация</button>
<button class="btn" type="button" data-modal="modal-add-student">+ Студент</button> <button class="btn" type="button" data-modal="modal-add-student">+ Студент</button>
</div> </div>
@@ -71,21 +73,35 @@
</div> </div>
</div> </div>
</th> </th>
<th class="text-center" title="Средний балл по выставленным оценкам">Средний балл</th> <th class="text-center average-col" title="Средний балл по выставленным оценкам">Средний балл</th>
<th th:each="att : ${attestations}" class="text-center attestation-col" data-nosort> <th th:each="att : ${attestations}" class="text-center attestation-col" data-nosort>
<div class="attestation-head"> <div class="attestation-head">
<div class="lab-head-actions"> <div class="lab-head-actions">
<form class="inline-form js-att-pub-form" method="post"
th:action="@{/admin/groups/{gid}/attestations/{aid}/publication(gid=${group.id}, aid=${att.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="published" th:value="${!att.published}">
<button class="btn btn-secondary btn-sm p-2 js-pub-eye js-att-pub" type="submit"
th:classappend="${att.published} ? 'is-all' : 'is-empty'"
th:title="${att.published} ? 'Аттестация видна студентам — скрыть' : 'Аттестация скрыта — показать студентам'">
<i class="eye-open ico bi bi-eye" aria-hidden="true"></i>
<i class="eye-closed ico bi bi-eye-slash" aria-hidden="true"></i>
</button>
</form>
<form class="inline-form" <form class="inline-form"
th:action="@{/admin/groups/{gid}/attestations/{aid}/delete(gid=${group.id}, aid=${att.id})}" th:action="@{/admin/groups/{gid}/attestations/{aid}/delete(gid=${group.id}, aid=${att.id})}"
method="post" method="post"
data-confirm="Удалить столбец аттестации вместе со всеми результатами?"> data-confirm="Удалить столбец аттестации вместе со всеми результатами?">
<input type="hidden" name="_csrf" th:value="${csrfToken}"> <input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm p-2" type="submit" <button class="btn btn-danger btn-sm p-2" type="submit"
title="Удалить аттестацию" aria-label="Удалить аттестацию">✕</button> title="Удалить аттестацию" aria-label="Удалить аттестацию">
<i class="cell-ico ico bi bi-trash" aria-hidden="true"></i>
</button>
</form> </form>
</div> </div>
<span class="attestation-kind" th:text="${att.kindLabel}">Аттестация</span> <span class="attestation-kind" th:text="${att.kindLabel}">Аттестация</span>
<span class="lab-col-title" th:text="${att.semester}">—</span> <span class="lab-col-title" th:text="'Семестр ' + ${att.semester}">Семестр 1</span>
<span class="badge badge-warn js-att-hidden" th:hidden="${att.published}">скрыто</span>
</div> </div>
</th> </th>
<th data-nosort>Пароль</th> <th data-nosort>Пароль</th>
@@ -138,18 +154,21 @@
</div> </div>
</div> </div>
</td> </td>
<td class="text-center font-mono average-cell" <td class="text-center average-cell"
th:text="${row.averageScore != null ? row.averageScore : '—'}">—</td> th:text="${row.averageScore != null ? row.averageScore : '—'}">—</td>
<td th:each="att : ${attestations}" class="text-center attestation-cell"> <td th:each="att : ${attestations}" class="text-center attestation-cell">
<button class="score-link" type="button" <form class="inline-form js-attestation-form" method="post"
th:classappend="${row.attestations[att.id] == null} ? 'none' : ''" th:action="@{/admin/groups/{id}/attestation-results(id=${group.id})}">
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-attestation=${att.id},data-attestation-kind=${att.kind},data-attestation-value=${row.attestations[att.id]?.value},data-missed-hours=${row.attestations[att.id]?.missedHours},data-attestation-label=${att.kindLabel},data-semester=${att.semester}" <input type="hidden" name="_csrf" th:value="${csrfToken}">
data-action="attestation" <input type="hidden" name="attestationId" th:value="${att.id}">
th:title="${att.kindLabel} + ' — ' + ${att.semester}"> <input type="hidden" name="studentId" th:value="${row.studentId}">
<span th:text="${row.attestations[att.id] != null ? row.attestations[att.id].label : '—'}">—</span> <select class="input attestation-select" name="value" data-auto-submit
<span th:if="${row.attestations[att.id]?.missedHours != null}" th:attr="data-student-name=${row.fullName},data-attestation-label=${att.kindLabel}">
class="cell-sub" th:text="' (' + ${row.attestations[att.id].missedHours} + ' ч.)'"></span> <option value="" th:selected="${row.attestations[att.id] == null}">—</option>
</button> <option th:each="opt : ${att.options}" th:value="${opt.value}" th:text="${opt.label}"
th:selected="${row.attestations[att.id]?.value == opt.value}"></option>
</select>
</form>
</td> </td>
<td class="text-center"> <td class="text-center">
<span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span> <span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span>
@@ -220,48 +239,24 @@
<option value="exam">Экзамен</option> <option value="exam">Экзамен</option>
</select></div> </select></div>
<div><label class="label" for="attestation-semester">Семестр</label> <div><label class="label" for="attestation-semester">Семестр</label>
<input class="input" id="attestation-semester" name="semester" list="attestation-semesters" <select class="input" id="attestation-semester" name="semester" required
placeholder="осень 2026" maxlength="100" data-error-required="Укажите семестр" required> data-error-required="Укажите семестр от 1 до 8">
<datalist id="attestation-semesters"> <option value="1">Семестр 1</option>
<option th:each="s : ${attestationSemesters}" th:value="${s}"></option> <option value="2">Семестр 2</option>
</datalist> <option value="3">Семестр 3</option>
<p class="hint">Например: «осень 2026» или «весна 2027». Семестр будет указан в ведомости и кабинете студента.</p> <option value="4">Семестр 4</option>
<option value="5">Семестр 5</option>
<option value="6">Семестр 6</option>
<option value="7">Семестр 7</option>
<option value="8">Семестр 8</option>
</select>
<p class="hint">Номер семестра от 1 до 8. Он будет указан в ведомости и кабинете студента.</p>
</div> </div>
<button class="btn btn-primary" type="submit">Добавить столбец</button> <button class="btn btn-primary" type="submit">Добавить столбец</button>
</form> </form>
</div> </div>
</dialog> </dialog>
<!-- Диалог: результат аттестации студента -->
<dialog id="modal-attestation" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title" id="attestation-title">Результат аттестации</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body">
<form class="form-stack" id="attestation-form" method="post"
th:action="@{/admin/groups/{id}/attestation-results(id=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="attestationId" id="attestation-id">
<input type="hidden" name="studentId" id="attestation-student">
<div><label class="label">Студент</label>
<input class="input" id="attestation-who" readonly></div>
<div><label class="label" for="attestation-value">Результат</label>
<select class="input" id="attestation-value" name="value">
<option value="">— не выставлено —</option>
</select>
<p class="hint">Оставьте пустым, чтобы убрать результат.</p>
</div>
<div id="attestation-hours-wrap"><label class="label" for="attestation-hours">Пропущено академических часов</label>
<input class="input" id="attestation-hours" name="missedHours" type="number" min="0" step="1"
inputmode="numeric" placeholder="например, 24">
<p class="hint">Укажите для оценки «не атт», если известны пропуски по любой причине.</p>
</div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<!-- Диалог: индивидуальное задание (защита) --> <!-- Диалог: индивидуальное задание (защита) -->
<!-- Диалог: назначение варианта --> <!-- Диалог: назначение варианта -->
<dialog id="modal-variant" class="dlg dlg-lg"> <dialog id="modal-variant" class="dlg dlg-lg">
@@ -436,7 +431,7 @@
<div class="dlg-body" id="assign-modal-body"></div> <div class="dlg-body" id="assign-modal-body"></div>
</dialog> </dialog>
<script th:src="@{/js/admin-group.js(v='40')}" defer></script> <script th:src="@{/js/admin-group.js(v='41')}" defer></script>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -17,7 +17,7 @@
<!-- v=37: сброс кэша стилей после изменения геометрии; при изменении css поднимать версию --> <!-- v=37: сброс кэша стилей после изменения геометрии; при изменении css поднимать версию -->
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}"> <link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
<link rel="stylesheet" th:href="@{/css/bootstrap-icons.min.css}"> <link rel="stylesheet" th:href="@{/css/bootstrap-icons.min.css}">
<link rel="stylesheet" th:href="@{/css/app.css(v='38')}"> <link rel="stylesheet" th:href="@{/css/app.css(v='39')}">
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='36')}"> <link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='36')}">
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='36')}" disabled> <link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='36')}" disabled>
<script th:src="@{/js/highlight.min.js(v='36')}" defer></script> <script th:src="@{/js/highlight.min.js(v='36')}" defer></script>
@@ -25,7 +25,7 @@
<script th:src="@{/js/tablekit.js(v='38')}" defer></script> <script th:src="@{/js/tablekit.js(v='38')}" defer></script>
<script th:src="@{/js/bootstrap.bundle.min.js}" defer></script> <script th:src="@{/js/bootstrap.bundle.min.js}" defer></script>
<script th:src="@{/js/theme-init.js(v='36')}"></script> <script th:src="@{/js/theme-init.js(v='36')}"></script>
<script th:src="@{/js/app.js(v='39')}" defer></script> <script th:src="@{/js/app.js(v='40')}" defer></script>
<script th:src="@{/js/ui-state.js(v='36')}" defer></script> <script th:src="@{/js/ui-state.js(v='36')}" defer></script>
<script th:src="@{/js/form-feedback.js(v='39')}" defer></script> <script th:src="@{/js/form-feedback.js(v='39')}" defer></script>
<script th:src="@{/js/modal-ajax.js(v='1')}" defer></script> <script th:src="@{/js/modal-ajax.js(v='1')}" defer></script>

View File

@@ -28,18 +28,17 @@
<div th:if="${!#lists.isEmpty(attestations)}" class="card card-flush mb-6"> <div th:if="${!#lists.isEmpty(attestations)}" class="card card-flush mb-6">
<table> <table>
<thead> <thead>
<tr><th>Форма аттестации</th><th>Семестр</th><th>Результат</th><th>Пропущено</th></tr> <tr><th>Форма аттестации</th><th>Семестр</th><th>Результат</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr th:each="a : ${attestations}"> <tr th:each="a : ${attestations}">
<td th:text="${a.kindLabel}">—</td> <td th:text="${a.kindLabel}">—</td>
<td th:text="${a.semester}">—</td> <td th:text="'Семестр ' + ${a.semester}">—</td>
<td> <td>
<span class="badge" <span class="badge"
th:classappend="${a.positive == true} ? 'badge-ok' : (${a.positive == false} ? 'badge-warn' : '')" th:classappend="${a.positive == true} ? 'badge-ok' : (${a.positive == false} ? 'badge-warn' : '')"
th:text="${a.valueLabel}">—</span> th:text="${a.valueLabel}">—</span>
</td> </td>
<td class="text-muted" th:text="${a.missedHours != null ? a.missedHours + ' ч.' : '—'}">—</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>