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:
@@ -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(
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user