Files
LMS/web/src/main/resources/templates/admin/group.html
Vladislav Kopnin f7a1830548 feat: add course attestations and average grade column
- store attestation columns and per-student results in progress-service
- add teacher UI for intermediate/pass/exam columns with semester and missed hours
- show average score per student on the group page
- show attestation results on the student dashboard
2026-10-26 16:34:04 +03:00

443 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru" xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/base :: layout(~{::title}, ~{::#content})}">
<head><title th:text="${group.name} + ' — LMS'">Группа</title></head>
<body>
<div id="content">
<div class="page-head">
<div>
<p class="kicker">Ведомость группы</p>
<h1 class="page-title" th:text="${group.name}">Группа</h1>
<p class="meta">
<span th:text="${#lists.size(group.students)}">0</span> студентов ·
<b th:text="${publishedLabsCount}">0</b>
<span th:with="p=${publishedLabsCount}"
th:text="${(p % 10 == 1 and p % 100 != 11) ? 'лабораторная' : ((p % 10 >= 2 and p % 10 <= 4 and (p % 100 < 12 or p % 100 > 14)) ? 'лабораторные' : 'лабораторных')}">лабораторных</span>
с открытыми оценками
</p>
</div>
<div class="actions">
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Скачать пароли</a>
<button class="btn btn-secondary" type="button" data-modal="modal-add-attestation">+ Аттестация</button>
<button class="btn" type="button" data-modal="modal-add-student">+ Студент</button>
</div>
</div>
<div class="toolbar">
<input class="input table-search w-260" data-target="group-table"
placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
<select class="input input-lab-focus" id="lab-focus">
<option value="">все лабораторные</option>
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
</select>
</div>
<div th:if="${#lists.isEmpty(labs) and #lists.isEmpty(attestations)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
<input type="hidden" id="pub-url" th:value="@{/admin/groups/{id}/publish-student(id=${group.id})}">
<div id="defense-meta" hidden th:data-labs="${#strings.listJoin(labs.![id], ',')}"></div>
<div class="card card-flush" th:if="${!#lists.isEmpty(labs) or !#lists.isEmpty(attestations)}">
<div class="scroll-x">
<table class="matrix tablekit" id="group-table">
<thead>
<tr>
<th class="matrix-sticky th-sortable is-asc" title="ФИО — по возрастанию">Студент</th>
<th th:each="lab : ${labs}" class="text-center lab-col" th:attr="data-lab=${lab.id}">
<div class="lab-col-head">
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a>
<span class="lab-col-title" th:text="${lab.title}">—</span>
<div class="lab-head-actions">
<button class="btn btn-secondary btn-sm p-2" type="button"
th:attr="data-url=@{/admin/groups/{id}/assign-modal(id=${group.id}, labId=${lab.id})},data-lab-name=|ЛР ${lab.number}|" data-action="assign"
title="Выдача вариантов: уровни, варианты, автокраспределение по группе">
<i class="cell-ico ico bi bi-list" aria-hidden="true"></i>
</button>
<form class="inline-form" th:with="pub=${labPublication[lab.id]}, pc=${pub != null ? pub.publishedCount : 0}, all=${pub != null and pub.allPublished}"
th:action="@{/admin/groups/{id}/publish(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${lab.id}">
<input class="js-pub-published" type="hidden" name="published"
th:value="${all} ? 'false' : 'true'">
<button class="btn btn-secondary btn-sm p-2 js-pub-eye" type="submit"
th:attr="data-lab=${lab.id},data-count=${pc},data-total=${pub != null ? pub.total : 0},data-all=${all}"
th:classappend="${all} ? 'is-all' : (${pc > 0} ? 'is-partial' : 'is-empty')"
th:title="${pub == null} ? '' : (${all} ? 'Оценки опубликованы всей группе — нажмите, чтобы скрыть у всех' : (${pc > 0} ? 'Опубликовано ' + ${pc} + ' из ' + ${pub.total} + ' — нажмите, чтобы опубликовать всем' : 'Показать оценки и комментарии всей группе'))">
<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>
<span class="mini-count js-pub-count" th:classappend="(${all} or ${pc == 0}) ? 'is-hidden' : ''" th:text="${pc}"></span>
</button>
</form>
<a class="btn btn-secondary btn-sm p-2"
th:href="'/admin/groups/' + ${group.id} + '/submissions.zip?labId=' + ${lab.id}"
th:title="|Скачать решения по ЛР ${lab.number}|">⤓<span class="mini-count" th:text="${labSubmissionCounts[lab.id] ?: 0}">0</span></a>
</div>
</div>
</th>
<th class="text-center" title="Средний балл по выставленным оценкам">Средний балл</th>
<th th:each="att : ${attestations}" class="text-center attestation-col" data-nosort>
<div class="attestation-head">
<div class="lab-head-actions">
<form class="inline-form"
th:action="@{/admin/groups/{gid}/attestations/{aid}/delete(gid=${group.id}, aid=${att.id})}"
method="post"
data-confirm="Удалить столбец аттестации вместе со всеми результатами?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm p-2" type="submit"
title="Удалить аттестацию" aria-label="Удалить аттестацию">✕</button>
</form>
</div>
<span class="attestation-kind" th:text="${att.kindLabel}">Аттестация</span>
<span class="lab-col-title" th:text="${att.semester}">—</span>
</div>
</th>
<th data-nosort>Пароль</th>
<th class="text-right" data-nosort>Действия</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${studentGrades}">
<td class="student-cell matrix-sticky">
<div class="student-name" th:text="${row.fullName}">—</div>
<div class="text-sm text-muted font-mono" th:text="${row.studentNo}">—</div>
</td>
<td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab"
th:attr="data-lab=${cell.labId},data-sort=${cell.variantNumber},data-filter-label=${cell.variantNumber != null ? 'вариант ' + cell.variantNumber : 'без варианта'}">
<div class="cell-stack">
<div class="cell-main">
<button class="variant-btn" type="button"
th:classappend="${cell.variantManual} ? 'manual' : ''"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-variant=${cell.variantId}" data-action="variant"
title="Назначить вариант вручную или выдать случайный">
<span th:if="${cell.variantNumber}" th:text="'В-' + ${cell.variantNumber}">В-1</span>
<span th:if="${cell.variantNumber == null}" class="cell-none">выдать</span>
</button>
<button class="score-link" type="button"
th:classappend="${cell.score == null} ? 'none' : ''"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-score=${cell.score},data-general-comment=${cell.generalComment},data-comments=${row.commentsJson},data-ci=${iterStat.index}" data-action="grade"
th:title="${cell.score} ? 'Оценка ' + ${cell.score} + ' — изменить' : 'Оценки нет — выставить балл и комментарии'">
<span th:text="${cell.score} ?: '—'">—</span>
</button>
<input type="checkbox" class="pub-toggle"
th:checked="${cell.published}"
th:attr="data-lab=${cell.labId},data-student=${row.studentId},data-name=${row.fullName}"
th:title="${cell.published} ? 'Оценка и комментарии видны студенту — снять, чтобы скрыть' : 'Оценка и комментарии скрыты — поставить, чтобы опубликовать'">
</div>
<div class="cell-sub">
<a th:if="${cell.submissionId}" class="sub-link"
th:href="@{/admin/submissions/{id}/download(id=${cell.submissionId}, group=${group.name}, no=${row.studentNo}, name=${row.fullName}, attempt=${cell.submissionSeq})}">сдача ↓ <span th:text="${cell.submissionSeq}">1</span></a>
<span th:unless="${cell.submissionId}" class="sub-dim">не сдано</span>
<button class="cell-btn st js-defense-cell" type="button"
th:classappend="${cell.defenseStatus == null} ? 'dim' : (${cell.defenseStatus == 'ready'} ? 'ok' : 'warn')"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${cell.labId},data-lab-name='ЛР ' + ${cell.labNumber},data-md=${cell.defenseAssignmentMd},data-session=${cell.defenseSessionId},data-status=${cell.defenseStatus},data-seconds=${cell.defenseSecondsLeft},data-code=${cell.defenseCode},data-reason=${cell.defenseReadyReason}" data-action="defense"
th:title="${cell.defenseStatus == null} ? 'Защита не назначена — нажмите, чтобы задать задание' : 'Защита: задание и действия'">
<span class="cell-dim">защита:</span>
<span class="js-def-label"
th:text="${cell.defenseStatus == null ? '—' : (cell.defenseStatus == 'waiting'
? (cell.defenseCode != null ? 'код ' + cell.defenseCode : 'ожидает')
: (cell.defenseStatus == 'ready' ? 'готова' : ''))}">—</span>
<span th:if="${cell.defenseStatus == 'started'}" class="defense-cell-timer js-def-timer" th:data-seconds="${cell.defenseSecondsLeft}">идёт</span>
</button>
</div>
</div>
</td>
<td class="text-center font-mono average-cell"
th:text="${row.averageScore != null ? row.averageScore : '—'}">—</td>
<td th:each="att : ${attestations}" class="text-center attestation-cell">
<button class="score-link" type="button"
th:classappend="${row.attestations[att.id] == null} ? 'none' : ''"
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}"
data-action="attestation"
th:title="${att.kindLabel} + ' — ' + ${att.semester}">
<span th:text="${row.attestations[att.id] != null ? row.attestations[att.id].label : '—'}">—</span>
<span th:if="${row.attestations[att.id]?.missedHours != null}"
class="cell-sub" th:text="' (' + ${row.attestations[att.id].missedHours} + ' ч.)'"></span>
</button>
</td>
<td class="text-center">
<span th:if="${row.mustChangePassword}" class="badge badge-warn">временный</span>
<span th:unless="${row.mustChangePassword}" class="badge badge-ok">изменён</span>
</td>
<td class="text-right actions-cell">
<div class="actions-col">
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post" data-confirm="Сбросить пароль студенту? Текущий пароль перестанет работать, новый покажется один раз.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit"
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить</button>
</form>
<form th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
method="post" data-confirm="Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-sm btn-danger" type="submit">Отчислить</button>
</form>
</div>
</td>
</tr>
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + #lists.size(attestations) + 4}">В группе нет студентов.</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Диалог: оценка + комментарии -->
<dialog id="modal-grade" class="dlg dlg-lg">
<div class="dlg-header">
<h2 class="dlg-title" id="grade-title">Оценка и комментарии</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body">
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="studentId" id="grade-student">
<input type="hidden" name="labId" id="grade-lab">
<input type="hidden" name="commentsJson" id="grade-comments" value="[]">
<div><label class="label">Студент</label><input class="input" id="grade-who" readonly></div>
<div><label class="label" for="grade-score">Балл (0–5)</label>
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1" required
data-error-required="Укажите оценку"></div>
<div><label class="label" for="grade-general">Общий комментарий по лабораторной</label>
<textarea class="input" id="grade-general" name="generalComment" rows="4"
placeholder="Итоговый комментарий по работе в целом…"></textarea></div>
<div id="cs-rows"></div>
<div class="flex gap-2">
<button class="btn btn-secondary" type="button" data-action="comment-add">+ комментарий</button>
<button class="btn btn-primary" type="submit">Сохранить</button>
</div>
</form>
</div>
</dialog>
<!-- Диалог: добавление столбца аттестации -->
<dialog id="modal-add-attestation" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title">Новая аттестация</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body">
<form class="form-stack" method="post" th:action="@{/admin/groups/{id}/attestations(id=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div><label class="label" for="attestation-kind">Форма аттестации</label>
<select class="input" id="attestation-kind" name="kind" required>
<option value="intermediate">Промежуточная аттестация</option>
<option value="pass">Зачёт</option>
<option value="exam">Экзамен</option>
</select></div>
<div><label class="label" for="attestation-semester">Семестр</label>
<input class="input" id="attestation-semester" name="semester" list="attestation-semesters"
placeholder="осень 2026" maxlength="100" data-error-required="Укажите семестр" required>
<datalist id="attestation-semesters">
<option th:each="s : ${attestationSemesters}" th:value="${s}"></option>
</datalist>
<p class="hint">Например: «осень 2026» или «весна 2027». Семестр будет указан в ведомости и кабинете студента.</p>
</div>
<button class="btn btn-primary" type="submit">Добавить столбец</button>
</form>
</div>
</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">
<div class="dlg-header">
<h2 class="dlg-title">Назначить вариант</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body form-stack">
<p class="text-sm text-muted" id="variant-who">—</p>
<div><label class="label" for="variant-level">Уровень</label>
<select class="input" id="variant-level"></select></div>
<div><label class="label" for="variant-variant">Вариант</label>
<select class="input" id="variant-variant"></select></div>
<div id="variant-task-wrap" hidden>
<label class="label">Текст задания</label>
<div id="variant-task" class="md-body"></div>
</div>
<div class="flex flex-wrap gap-2">
<form class="inline-form" id="variant-random-form" method="post"
th:action="@{/admin/groups/{gid}/set-level(gid=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="studentId" id="variant-random-student">
<input type="hidden" name="labId" id="variant-random-lab">
<input type="hidden" name="labLevelId" id="variant-random-level">
<button class="btn btn-primary" type="submit">Выдать случайный из уровня</button>
</form>
<form class="inline-form" id="variant-pin-form" method="post"
th:action="@{/admin/groups/{gid}/pin-variant(gid=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="studentId" id="variant-pin-student">
<input type="hidden" name="labId" id="variant-pin-lab">
<input type="hidden" name="variantId" id="variant-pin-id">
<button class="btn btn-secondary" type="submit">Закрепить выбранный</button>
</form>
</div>
</div>
</dialog>
<dialog id="modal-defense" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title">Индивидуальное задание</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body">
<form class="form-stack" id="defense-form" th:action="@{/admin/defenses/save}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="groupId" th:value="${group.id}">
<div><label class="label" for="defense-student-select">Студент</label>
<select class="input" id="defense-student-select" name="studentId" required>
<option value="">— выберите студента —</option>
<option th:each="s : ${group.students}" th:value="${s.id}"
th:text="${s.fullName} + ' (' + ${s.studentNo} + ')'">—</option>
</select></div>
<div><label class="label" for="defense-lab-select">Лабораторная</label>
<select class="input" id="defense-lab-select" name="labId" required>
<option value="">— выберите лабораторную —</option>
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}. ${lab.title}|">—</option>
</select></div>
<div><label class="label" for="defense-md">Задание (markdown)</label>
<textarea class="input" id="defense-md" name="assignmentMd" rows="10" required></textarea></div>
</form>
<p id="defense-status" class="text-sm text-muted mt-4"></p>
<div class="flex flex-wrap gap-2 defense-footer">
<div id="defense-actions" class="flex flex-wrap gap-2" hidden>
<form class="inline-form" id="defense-issue-form" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" id="defense-action-lab">
<input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-secondary" type="submit">Выдать код</button>
</form>
<form class="inline-form" id="defense-reset-form" method="post" data-confirm="Открыть защиту заново для нового (дополнительного) задания?">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" id="defense-reset-lab">
<input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-danger" type="submit">Начать заново</button>
</form>
</div>
<button class="btn btn-primary" type="submit" form="defense-form">Сохранить</button>
</div>
</div>
</dialog>
<!-- Диалог добавления студента -->
<dialog id="modal-add-student" class="dlg">
<div class="dlg-header">
<h2 class="dlg-title">Добавить студента</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body">
<form class="form-stack" id="enroll-form" method="post"
th:action="@{/admin/groups/{gid}/students/enroll(gid=${group.id})}">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div><label class="label" for="enroll-select">Существующий студент</label>
<select class="input" id="enroll-select" name="studentId" required>
<option value="">— выберите студента —</option>
<option th:each="c : ${candidates}" th:value="${c.id}"
th:text="${c.fullName} + ' (' + ${c.studentNo} + ')' + (${c.currentGroupName} ? ' — из ' + ${c.currentGroupName} : ' — без группы')">
</option>
</select></div>
<p class="hint">Если студент уже в другой группе — он будет переведён в эту.</p>
<button class="btn btn-secondary" type="submit" th:disabled="${#lists.isEmpty(candidates)}">Зачислить существующего</button>
</form>
<form class="form-stack mt-4" th:action="@{/admin/groups/{gid}/students/create(gid=${group.id})}" method="post"
data-success="Студент создан. Пароль показан ниже.">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div class="row g-3">
<div class="col-md-6"><label class="label" for="stNo">Студенческий номер (логин)</label>
<input class="input" id="stNo" name="studentNo" required></div>
<div class="col-md-6"><label class="label" for="stBasis">Основа обучения</label>
<input class="input" id="stBasis" name="basis" placeholder="бюджет"></div>
</div>
<div><label class="label" for="stLast">Фамилия</label>
<input class="input" id="stLast" name="lastName" required></div>
<div class="row g-3">
<div class="col-md-6"><label class="label" for="stFirst">Имя</label>
<input class="input" id="stFirst" name="firstName"></div>
<div class="col-md-6"><label class="label" for="stMiddle">Отчество</label>
<input class="input" id="stMiddle" name="middleName"></div>
</div>
<p class="hint">Новому студенту пароль будет сгенерирован автоматически.</p>
<button class="btn btn-primary" type="submit">Создать нового</button>
</form>
</div>
</dialog>
<div th:if="${createdStudent}" class="card import-card">
<div class="card-body">
<h2 class="section-title">Студент добавлен</h2>
<p class="text-sm" th:text="${createdStudent.fullName}">—</p>
<p class="text-sm">Логин: <code class="code font-mono" th:text="${createdStudent.studentNo}">—</code></p>
<p class="text-sm">Пароль: <code class="code font-mono font-semibold" th:text="${createdStudent.password}">—</code></p>
</div>
</div>
<!-- Итог импорта группы: одноразовые пароли новых студентов показываются здесь один раз. -->
<div th:if="${importResult}" class="card import-card">
<div class="card-body">
<h2 class="section-title">
Группа <span th:text="${importResult.groupName}">—</span> импортирована
</h2>
<p class="text-sm" th:if="${!#lists.isEmpty(importResult.added)}">
Новых студентов: <b th:text="${#lists.size(importResult.added)}">0</b> — логины и одноразовые пароли:
</p>
<table th:if="${!#lists.isEmpty(importResult.added)}">
<thead><tr><th>Студент</th><th>Логин</th><th>Пароль</th></tr></thead>
<tbody>
<tr th:each="c : ${importResult.added}">
<td th:text="${c.fullName}">—</td>
<td class="font-mono" th:text="${c.studentNo}">—</td>
<td class="font-mono font-semibold" th:text="${c.password}">—</td>
</tr>
</tbody>
</table>
<p class="text-sm" th:if="${!#lists.isEmpty(importResult.updated)}">
Обновлено существующих студентов: <b th:text="${#lists.size(importResult.updated)}">0</b>
</p>
<p class="hint">Пароли показываются один раз — сохраните их или воспользуйтесь «Скачать пароли».</p>
</div>
</div>
<!-- Большая модалка «Выдача вариантов»: содержимое подгружается фрагментом по лабе -->
<dialog id="modal-lab-assign" class="dlg dlg-lg">
<div class="dlg-header">
<h2 class="dlg-title" id="assign-modal-title">Выдача вариантов</h2>
<button class="dlg-close" type="button" aria-label="Закрыть">✕</button>
</div>
<div class="dlg-body" id="assign-modal-body"></div>
</dialog>
<script th:src="@{/js/admin-group.js(v='40')}" defer></script>
</div>
</body>
</html>