Phase 7: deployment, data import, UI hardening and security fixes

Docker/prod deployment:
- multi-stage Dockerfile (JDK21 build, JRE21 run), docker-compose.prod.yml,
  nginx config for lms.our-place.ru, deploy.sh; DEPLOY/IMPORT guides (UI flow)
- Fix Docker 401: LMS_JWT_JWK_SET_URI for academic/progress; forward-headers

Variant distribution (lab page only):
- round-robin by student level, shared random queue across selected groups,
  least-used variants, manual pins always preserved; occupied/dup labels
- setup page: single form + one «Сохранить всё», batch level/variant for
  selected students (checkboxes)

Grades, defense, materials:
- publication gate + comment lists; defense CSV import (2/3 cols, quotes)
- additional task after defense ("начать заново"); enroll/unenroll students
  from group page; temporary-password lock (no navigation until changed) and
  persistent "Смена пароля"; forced change once
- chapter page /learn/{c}; header: LMS home, Войти, user -> dashboard,
  compact theme toggle; responsive mobile header
- markdown: single newlines are soft breaks; highlight.js with light/dark themes
- lab/content imports ZIP-only (lab.yaml+README+variants/assets,
  chapters tree+assets); duplicate lab numbers rejected; variant ordering
  and date defaults honored

Fixes:
- 500 on student create (@MapsId set before persist); broken dialogs/JS refs in group page
This commit is contained in:
2026-09-14 22:01:46 +03:00
parent 5e60cc3819
commit cdf4fb0ee0
58 changed files with 3967 additions and 884 deletions

View File

@@ -4,14 +4,18 @@
<body>
<div id="content">
<a th:href="@{/admin/groups}" class="nav-link">← Все группы</a>
<div class="mt-4 mb-6 flex flex-wrap items-center justify-between gap-4">
<div class="mt-2 mb-4 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="page-title" th:text="${group.name}">Группа</h1>
<h1 class="page-title m-0" th:text="${group.name}">Группа</h1>
<p class="text-sm text-muted mt-1">Студентов: <span th:text="${#lists.size(group.students)}">0</span></p>
</div>
<div class="flex gap-2">
<div class="flex flex-wrap gap-2">
<button class="btn btn-primary" type="button" onclick="openModal('modal-add-student')">+ Студент</button>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Выгрузить XLSX</a>
<button class="btn btn-secondary" type="button"
th:disabled="${#lists.isEmpty(candidates)}"
onclick="openModal('modal-enroll-student')">Добавить существующего</button>
<a class="btn btn-secondary" th:href="'/admin/groups/' + ${group.id} + '/submissions.zip'">Скачать все работы</a>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='xlsx')}">Логины XLSX</a>
<a class="btn btn-secondary" th:href="@{/admin/groups/{id}/credentials(id=${group.id}, format='csv')}">CSV</a>
</div>
</div>
@@ -19,73 +23,141 @@
<div th:if="${notice}" class="alert alert-success" th:text="${notice}"></div>
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
<div th:if="${resetPassword}" class="alert alert-warning">
Новый пароль: <code class="code" th:text="${resetPassword}">password</code>
Новый пароль: <code class="code font-mono" th:text="${resetPassword}">password</code>
</div>
<div th:if="${importResult}" class="card import-card mb-4">
<div class="card-body">
<h2 class="section-title">Импорт: <span th:text="${importResult.groupName}">группа</span></h2>
<p class="text-sm text-muted">
Новых студентов: <span th:text="${#lists.size(importResult.added)}">0</span>,
обновлено: <span th:text="${#lists.size(importResult.updated)}">0</span>.
</p>
<div th:if="${not #lists.isEmpty(importResult.added)}" class="mt-4 overflow-x-auto">
<p class="text-sm alert-warning mini-alert">Сохраните пароли — они показываются для выдачи студентам.</p>
<table>
<thead><tr><th>ФИО</th><th>Логин</th><th>Пароль</th></tr></thead>
<tbody>
<tr th:each="credential : ${importResult.added}">
<td th:text="${credential.fullName}">—</td>
<td class="font-mono" th:text="${credential.studentNo}">—</td>
<td class="font-mono font-semibold" th:text="${credential.password}">—</td>
</tr>
</tbody>
</table>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h2 class="section-title m-0">Результаты по лабораторным</h2>
<div class="flex flex-wrap items-center gap-2" th:if="${!#lists.isEmpty(labs)}">
<label class="label m-0" for="lab-focus">Показать:</label>
<select class="input input-sm" id="lab-focus" onchange="focusLab()">
<option value="">все лабораторные</option>
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
</select>
</div>
</div>
<p class="text-sm text-muted mb-4">
Значки в ячейке: «№» — вариант, «🛡» — защита, «↓» — сдача (скачать), «✎» — оценка.
Наведите на значок — появится пояснение; клик по «↓» скачивает последнее решение,
клик по «✎» открывает оценку и комментарии. Раздача вариантов — на странице лабораторной.
</p>
<div th:if="${#lists.isEmpty(labs)}" class="card card-body mb-6 text-muted">Опубликованных лабораторных пока нет.</div>
<div class="card card-flush mb-6" th:if="${!#lists.isEmpty(labs)}">
<div class="scroll-x">
<table class="matrix">
<thead>
<tr>
<th class="matrix-sticky">Студент</th>
<th th:each="lab : ${labs}" class="text-center lab-col" th:attr="data-lab=${lab.id}">
<div class="lab-col-head">
<div class="lab-head-row">
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a>
<form class="inline-form" 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 type="hidden" name="published"
th:value="${publishedLabIds.contains(lab.id) ? 'false' : 'true'}">
<button class="badge badge-btn badge-sm" type="submit"
th:classappend="${publishedLabIds.contains(lab.id)} ? 'badge-ok' : 'badge-warn'"
th:title="${publishedLabIds.contains(lab.id) ? 'Скрыть оценки и комментарии от студентов' : 'Показать оценки и комментарии студентам'}"
th:text="${publishedLabIds.contains(lab.id) ? 'опубликовано' : 'опубликовать оценки'}">опубликовать оценки</button>
</form>
</div>
</div>
</th>
</tr>
</thead>
<tbody>
<tr th:each="row : ${studentGrades}">
<td class="student-cell matrix-sticky">
<div class="font-medium" 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}">
<div class="cell-chips">
<span class="cell-chip"
th:title="(${cell.variantNumber} ? 'Вариант ' + ${cell.variantNumber} : 'Вариант') + ' — ' + (${cell.variantManual} ? 'закреплён вручную (раздача его не тронет)' : 'выдан автоматически')">
№<span class="font-mono" th:classappend="${cell.variantManual} ? 'text-strong' : ''"
th:text="${cell.variantNumber} ?: '—'">12</span>
</span>
<span th:if="${cell.defenseStatus == null}" class="cell-chip chip-dim" title="Защита не назначена">🛡—</span>
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode == null}" class="cell-chip chip-warn" title="Задание для защиты выдано, ещё без кода">🛡⌛</span>
<span th:if="${cell.defenseStatus == 'waiting' and cell.defenseCode != null}" class="cell-chip chip-warn" title="Защита: код студенту выдан, ждёт входа">🛡🔑</span>
<span th:if="${cell.defenseStatus == 'started'}" class="cell-chip chip-warn" title="Защита: студент пишет работу">🛡⏳</span>
<span th:if="${cell.defenseStatus == 'ready'}" class="cell-chip chip-ok" title="Защита: работа сохранена, можно принимать">🛡✓</span>
<a th:if="${cell.submissionId}" class="cell-chip chip-link"
th:href="'/admin/submissions/' + ${cell.submissionId} + '/download'"
title="Скачать последнее решение студента">↓<span class="font-mono" th:text="${cell.submissionSeq}">1</span></a>
<span th:unless="${cell.submissionId}" class="cell-chip chip-dim" title="Решений пока не сдавалось">↓—</span>
<button class="cell-chip" type="button"
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-comments=${row.commentsJson},data-ci=${iterStat.index}"
onclick="openGradeEditor(this)"
th:title="${cell.score} ? 'Оценка ' + ${cell.score} + ' — открыть правку оценки и комментариев' : 'Поставить оценку и комментарии'"
>✎<span class="font-mono" th:if="${cell.score}" th:text="' ' + ${cell.score}"></span></button>
</div>
</td>
</tr>
<tr th:if="${#lists.isEmpty(studentGrades)}"><td class="empty" th:attr="colspan=${#lists.size(labs) + 1}">В группе нет студентов.</td></tr>
</tbody>
</table>
</div>
</div>
<h2 class="section-title">Баллы за лабораторные</h2>
<p class="text-sm text-muted mb-4">Нажмите ✎ в ячейке балла — откроется ввод оценки с комментариями. Средний балл считается по оценённым лабам.</p>
<div class="card card-flush mb-6 overflow-x-auto">
<table>
<thead>
<tr>
<th>Студент</th>
<th class="text-center" th:each="lab : ${labs}">
<a th:href="@{/admin/labs/{id}(id=${lab.id})}" th:text="|ЛР ${lab.number}|">ЛР</a>
</th>
<th class="text-center">Средний</th>
</tr>
</thead>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h2 class="section-title m-0">Индивидуальные задания (защита)</h2>
<div class="flex flex-wrap items-center gap-2" th:if="${!#lists.isEmpty(labs)}">
<label class="label m-0" for="defense-lab-filter">Лабораторная:</label>
<select class="input input-sm" id="defense-lab-filter" onchange="filterDefense()">
<option value="">Все</option>
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}|">—</option>
</select>
<button class="btn btn-primary btn-sm" type="button" onclick="openDefenseForm(this)">Назначить задание</button>
<button class="btn btn-secondary btn-sm" type="button" onclick="openModal('modal-defense-import')">Импорт CSV</button>
</div>
</div>
<div class="card card-flush mb-8">
<table id="defense-table">
<thead><tr><th>Студент</th><th>ЛР</th><th>Задание</th><th>Статус</th><th class="text-right">Действия</th></tr></thead>
<tbody>
<tr th:each="row : ${studentGrades}">
<td th:text="${row.fullName}">—</td>
<td th:each="score, iter : ${row.scores}">
<div>
<div class="score-cell-root">
<form class="inline-form">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input class="input input-sm input-score" type="number" step="0.1" min="0" max="5" th:value="${score}" readonly>
<button class="btn btn-primary btn-sm" type="button" onclick="openGradeEditor(this)"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${labs[iter.index].id},data-lab-name='ЛР ' + ${labs[iter.index].number},data-score=${score},data-comments=${row.commentsJson},data-ci=${iter.index}">✎</button>
</form>
</div>
<div>
<span th:each="c : ${row.comments[iter.index]}" class="chip"
th:classappend="${c.second == 'positive'} ? chip-pos : (${c.second == 'negative'} ? chip-neg : chip-neu)"
th:text="${c.first}">коммент</span>
</div>
</div>
<tr th:each="d : ${defenseRows}" th:attr="data-lab=${d.labId}">
<td>
<div th:text="${d.fullName}">—</div>
<div class="text-sm text-muted font-mono" th:text="${d.studentNo}">—</div>
</td>
<td class="text-center font-semibold">
<span th:if="${row.average != null}" th:text="${#numbers.formatDecimal(row.average, 1, 2)}">—</span>
<span th:if="${row.average == null}" class="text-muted">—</span>
<td class="font-mono" th:text="|ЛР ${d.labNumber}|">—</td>
<td class="text-sm text-muted" th:text="${#strings.abbreviate(d.assignmentMd, 60)}">—</td>
<td>
<span class="badge badge-warn" th:if="${d.status == 'waiting' and d.code == null}">ждёт кода</span>
<span class="badge badge-warn" th:if="${d.status == 'waiting' and d.code != null}">код выдан</span>
<span class="badge badge-warn" th:if="${d.status == 'started'}">пишет (<span th:text="${d.secondsLeft}"></span> c)</span>
<span class="badge badge-ok" th:if="${d.status == 'ready'}">готов</span>
</td>
<td class="text-right defense-cell-actions">
<form class="inline-form" th:if="${d.sessionId != null}"
th:action="'/admin/defenses/' + ${d.sessionId} + '/issue-code'" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${d.labId}">
<input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-secondary btn-sm" type="submit"
th:text="${d.status == 'waiting' and d.code != null ? 'новый код' : 'выдать код'}">выдать код</button>
</form>
<button class="btn btn-secondary btn-sm" type="button"
th:attr="data-student=${d.studentId},data-student-name=${d.fullName},data-lab=${d.labId},data-lab-name='ЛР ' + ${d.labNumber},data-md=${d.assignmentMd}"
onclick="openDefenseForm(this)">изменить</button>
<button class="btn btn-secondary btn-sm" type="button"
th:attr="data-student-name=${d.fullName},data-lab-name='ЛР ' + ${d.labNumber},data-md=${d.assignmentMd}"
onclick="showDefenseText(this)">текст</button>
<form class="inline-form" th:if="${d.status == 'ready'}"
th:action="'/admin/defenses/' + ${d.sessionId} + '/reset'" method="post"
onsubmit="return confirm('Открыть защиту заново для нового (дополнительного) задания?')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="labId" th:value="${d.labId}">
<input type="hidden" name="groupId" th:value="${group.id}">
<button class="btn btn-danger btn-sm" type="submit">доп. задание: начать заново</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(group.students)}">
<td class="empty" th:colspan="${#lists.size(labs) + 2}">В группе нет студентов.</td>
</tr>
<tr th:if="${#lists.isEmpty(defenseRows)}"><td colspan="5" class="empty">Заданий для защиты пока нет.</td></tr>
</tbody>
</table>
</div>
@@ -93,27 +165,182 @@
<h2 class="section-title">Студенты</h2>
<div class="card card-flush">
<table>
<thead><tr><th>№</th><th>ФИО</th><th>Логин</th><th>Основа</th><th>Пароль</th><th></th></tr></thead>
<thead><tr><th>№</th><th>ФИО</th><th>Логин</th><th>Основа</th><th>Варианты</th><th>Пароль</th><th class="text-right">Действия</th></tr></thead>
<tbody>
<tr th:each="student, iter : ${group.students}">
<tr th:each="row, iter : ${studentGrades}">
<td class="text-muted" th:text="${iter.count}">1</td>
<td th:text="${student.fullName}">—</td>
<td class="font-mono" th:text="${student.studentNo}">—</td>
<td class="text-muted" th:text="${student.basis}">—</td>
<td><span th:if="${student.mustChangePassword}" class="badge badge-warn">временный</span>
<span th:unless="${student.mustChangePassword}" class="badge badge-ok">изменён</span></td>
<td class="text-center">
<form th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${student.id})}" method="post">
<button class="btn btn-danger btn-sm" type="submit">Сбросить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<td th:text="${row.fullName}">—</td>
<td class="font-mono" th:text="${row.studentNo}">—</td>
<td class="text-muted" th:text="${row.basis}">—</td>
<td class="text-sm">
<span th:if="${!#strings.isEmpty(row.variantsSummary)}" th:text="${row.variantsSummary}">—</span>
<span th:if="${#strings.isEmpty(row.variantsSummary)}" class="text-muted">не распределены</span>
</td>
<td><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">
<form class="inline-form" th:action="@{/admin/groups/{gid}/students/{sid}/reset-password(gid=${group.id}, sid=${row.studentId})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm" type="submit"
title="Сгенерировать новый пароль (покажется один раз, потребуется смена при входе)">Сбросить пароль</button>
</form>
<form class="inline-form"
th:action="@{/admin/groups/{gid}/students/{sid}/unenroll(gid=${group.id}, sid=${row.studentId})}"
method="post"
onsubmit="return confirm('Отчислить этого студента из группы? Аккаунт, сдача и оценки сохранятся; студент сможет войти и будет без группы.')">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-danger btn-sm" type="submit">Отчислить из группы</button>
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(group.students)}"><td colspan="6" class="empty">В группе пока нет студентов.</td></tr>
<tr th:if="${#lists.isEmpty(group.students)}"><td colspan="7" class="empty">В группе пока нет студентов.</td></tr>
</tbody>
</table>
</div>
<!-- Диалог: оценка + комментарии -->
<dialog id="modal-grade" class="modal">
<div class="modal-header">
<h2 class="modal-title" id="grade-title">Оценка и комментарии</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-grade')">✕</button>
</div>
<div class="modal-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"></div>
<div id="cs-rows"></div>
<div class="flex gap-2">
<button class="btn btn-secondary btn-sm" type="button" onclick="csRow('','neutral')">+ комментарий</button>
<button class="btn btn-primary" type="submit">Сохранить</button>
</div>
</form>
</div>
</dialog>
<!-- Диалог: назначение индивидуального задания -->
<dialog id="modal-defense" class="modal">
<div class="modal-header">
<h2 class="modal-title">Индивидуальное задание</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-defense')">✕</button>
</div>
<div class="modal-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>
<button class="btn btn-primary" type="submit">Сохранить задание</button>
</form>
</div>
</dialog>
<!-- Диалог просмотра текста задания -->
<dialog id="modal-defense-text" class="modal">
<div class="modal-header">
<h2 class="modal-title" id="defense-text-title">Текст задания</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-defense-text')">✕</button>
</div>
<div class="modal-body">
<pre id="defense-text-body" class="pre-wrap"></pre>
</div>
</dialog>
<!-- Диалог импорта CSV -->
<dialog id="modal-defense-import" class="modal">
<div class="modal-header">
<h2 class="modal-title">Импорт заданий (CSV)</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-defense-import')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/defenses/import}" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="groupId" th:value="${group.id}">
<div><label class="label" for="cimport-lab">Лабораторная</label>
<select class="input" id="cimport-lab" name="labId">
<option value="">— определить из файла (3-й столбец) —</option>
<option th:each="lab : ${labs}" th:value="${lab.id}" th:text="|ЛР ${lab.number}. ${lab.title}|">—</option>
</select></div>
<div><label class="label" for="cimport-file">Файл CSV</label>
<input class="input" id="cimport-file" type="file" name="file" accept=".csv,.txt" required></div>
<p class="hint">
Разделитель <code class="code">;</code>, <code class="code">,</code> или таб; заголовок не обязателен; кавычки поддерживаются.<br>
<b>2 столбца</b> (нужна выбранная лаба): <code class="code">студенческий_номер;задание</code>.<br>
<b>3 столбца</b>: <code class="code">студенческий_номер;номер_ЛР;задание</code> — лабораторная берётся из файла.<br>
Переносы внутри задания — как <code class="code">\n</code>.
</p>
<button class="btn btn-primary" type="submit">Импортировать</button>
</form>
</div>
</dialog>
<!-- Диалог добавления студента -->
<dialog id="modal-add-student" class="modal">
<div class="modal-header">
<h2 class="modal-title">Добавить студента</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-add-student')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/groups/{gid}/students/create(gid=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div class="grid grid-2">
<div><label class="label" for="stNo">Студенческий номер (логин)</label>
<input class="input" id="stNo" name="studentNo" required></div>
<div><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="grid grid-2">
<div><label class="label" for="stFirst">Имя</label>
<input class="input" id="stFirst" name="firstName"></div>
<div><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>
<dialog id="modal-enroll-student" class="modal">
<div class="modal-header">
<h2 class="modal-title">Добавить существующего студента</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-enroll-student')">✕</button>
</div>
<div class="modal-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-primary" type="submit">Зачислить</button>
</form>
</div>
</dialog>
<div th:if="${createdStudent}" class="card import-card toast">
<div class="card-body">
<h2 class="section-title">Студент добавлен</h2>
@@ -121,128 +348,10 @@
<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>
<dialog id="modal-add-student" class="modal">
<div class="modal-header">
<h2 class="modal-title">Добавить студента</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-add-student')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/groups/{gid}/students/create(gid=${group.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<div class="grid grid-2">
<div><label class="label" for="stNo">Студенческий номер (логин)</label>
<input class="input" id="stNo" name="studentNo" required></div>
<div><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="grid grid-2">
<div><label class="label" for="stFirst">Имя</label>
<input class="input" id="stFirst" name="firstName"></div>
<div><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>
<dialog id="modal-grade" class="modal">
<div class="modal-header">
<h2 class="modal-title" id="grade-title">Оценка и комментарии</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-grade')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post"
onsubmit="return csSerialize()">
<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">
<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"></div>
<div id="cs-rows"></div>
<button class="btn btn-secondary btn-sm" type="button" onclick="csRow('','neutral')">+ комментарий</button>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<datalist id="cs-list"></datalist>
<script>
var CS_TEMPLATES = [];
</script>
<script th:inline="javascript">
CS_TEMPLATES = /*[[${commentOptions}]]*/ [];
function openGradeEditor(btn) {
var all = JSON.parse(btn.getAttribute('data-comments') || '[]');
var list = all[parseInt(btn.getAttribute('data-ci') || '0', 10)] || [];
document.getElementById('grade-student').value = btn.getAttribute('data-student');
document.getElementById('grade-lab').value = btn.getAttribute('data-lab');
document.getElementById('grade-who').value = btn.getAttribute('data-student-name');
document.getElementById('grade-title').textContent = btn.getAttribute('data-lab-name') + ' — ' + btn.getAttribute('data-student-name');
var sc = document.getElementById('grade-score');
sc.value = btn.getAttribute('data-score') || '';
var box = document.getElementById('cs-rows');
box.innerHTML = '';
list.forEach(function (c) { csRow(c.text, c.tone); });
if (!list.length) csRow('', 'neutral');
openModal('modal-grade');
}
function csRow(text, tone) {
var box = document.getElementById('cs-rows');
var row = document.createElement('div');
row.className = 'cs-row';
var inp = document.createElement('input');
inp.className = 'input cs-text'; inp.placeholder = 'Комментарий (подскажем частые)';
inp.setAttribute('list', 'cs-list');
inp.value = text;
inp.addEventListener('input', csFilter);
var sel = document.createElement('select');
sel.className = 'input cs-tone'
[['positive', '+'], ['neutral', 'o'], ['negative', '−']].forEach(function (o) {
var op = document.createElement('option'); op.value = o[0]; op.textContent = o[1]; sel.appendChild(op);
});
sel.value = tone || 'neutral';
var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'btn btn-danger btn-sm cs-del'; rm.textContent = '✕';
rm.addEventListener('click', function () { row.remove(); });
row.appendChild(inp); row.appendChild(sel); row.appendChild(rm);
box.appendChild(row);
}
function csFilter(ta) {
var list = document.getElementById('cs-list');
list.innerHTML = '';
var q = (ta.target.value || '').toLowerCase().trim();
CS_TEMPLATES
.filter(function (t) { return !q || t.text.toLowerCase().indexOf(q) !== -1; })
.slice(0, 8)
.forEach(function (t) {
var op = document.createElement('option');
op.value = t.text;
list.appendChild(op);
});
}
function csSerialize() {
var out = [];
document.querySelectorAll('#cs-rows .cs-row').forEach(function (r) {
var text = r.querySelector('.cs-text').value.trim();
if (text) out.push({ text: text, tone: r.querySelector('.cs-tone').value });
});
document.getElementById('grade-comments').value = JSON.stringify(out);
return true;
}
</script>
</div>
</div>
<script src="/js/admin-group.js" defer></script>
</div>
</body>
</html>
</html>