Phase 1 fixes: broken dialogs, score width, status badge, MSK date round-trip

- group/lab/labs: move dialogs/scripts INSIDE the #content fragment (they were
  outside — the layout fragment dropped them, which is why 'add student' did nothing)
- group.html toast had swallowed the dialog (th:if createdStudent) — split properly
- .input-score width no longer overridden by .inline-form .input-sm (10rem min)
- lab page: remove separate 'change status' button — status badge is now a toggle
  button itself (confirm dialog kept)
- DateUtils.localInput/toUtcIso: dates entered in datetime-local are Moscow-local,
  stored as UTC instant, shown back MSK — correct round-trip both ways
- academic parseInstant: naive local datetimes treated as Europe/Moscow
- README/module docs left as-is
This commit is contained in:
2026-09-11 23:11:40 +03:00
parent 05ceac93af
commit eb55f47f47
7 changed files with 215 additions and 190 deletions

View File

@@ -106,7 +106,6 @@
</tbody>
</table>
</div>
</div>
<div th:if="${createdStudent}" class="card import-card toast">
<div class="card-body">
@@ -115,34 +114,36 @@
<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-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>
</div>
</body>
</html>
</html>

View File

@@ -8,10 +8,10 @@
<div>
<h1 class="page-title" th:text="|ЛР ${lab.number}. ${lab.title}|">ЛР 1</h1>
<p class="text-sm text-muted">
Статус: <span class="badge" th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</span>
<form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post">
Статус: <form class="inline-form" th:action="@{/admin/labs/{id}/toggle-status(id=${lab.id})}" method="post">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<button class="btn btn-secondary btn-sm ml-2" onclick="return confirm('Сменить статус лабораторной?')" type="submit">Сменить статус</button>
<button class="badge badge-btn" type="submit" onclick="return confirm('Сменить статус лабораторной?')"
th:classappend="${lab.status == 'published'} ? 'badge-ok' : 'badge-warn'" th:text="${lab.status}">—</button>
</form>
<span th:if="${deadlineMsk != null}"> · Срок: <span th:text="${deadlineMsk} + ' МСК'"></span></span>
<span th:if="${defenseMsk != null}"> · Защита: <span th:text="${defenseMsk} + ' МСК'"></span></span>
@@ -136,126 +136,127 @@
</table>
</div>
</div>
</div>
<dialog id="modal-meta" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка лабораторной</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-meta')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}" method="post">
<div><label class="label" for="metaTitle">Название</label>
<input class="input" id="metaTitle" name="title" th:value="${lab.title}" required></div>
<div class="grid grid-2">
<div><label class="label" for="metaDeadline">Срок сдачи</label>
<input class="input" id="metaDeadline" name="deadlineAt" type="datetime-local" th:value="${deadlineLocal}"></div>
<div><label class="label" for="metaDefense">Дата защиты</label>
<input class="input" id="metaDefense" name="defenseAt" type="datetime-local" th:value="${defenseLocal}"></div>
</div>
<div class="grid grid-2">
<div><label class="label" for="metaPoints">Максимум баллов</label>
<input class="input" id="metaPoints" name="pointsMax" type="number" step="0.1" min="0" th:value="${lab.pointsMax}" required></div>
<div><label class="label" for="metaStatus">Статус</label>
<select class="input" id="metaStatus" name="status">
<option value="draft" th:selected="${lab.status == 'draft'}">draft</option>
<option value="published" th:selected="${lab.status == 'published'}">published</option>
<option value="archived" th:selected="${lab.status == 'archived'}">archived</option>
</select></div>
</div>
<div><label class="label" for="metaDesc">Условие (markdown)</label>
<textarea class="input" id="metaDesc" name="descriptionMd" rows="12" th:text="${lab.descriptionMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog id="modal-add-level" class="modal">
<div class="modal-header">
<h2 class="modal-title">Добавить уровень</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-add-level')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
<div class="grid grid-2">
<div><label class="label" for="lvlCode">Код</label>
<input class="input" id="lvlCode" name="code" placeholder="pro" required></div>
<div><label class="label" for="lvlTitle">Название</label>
<input class="input" id="lvlTitle" name="title" placeholder="Профессиональный" required></div>
</div>
<button class="btn btn-primary" type="submit">Добавить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog th:each="level : ${lab.levels}" th:id="|modal-rename-level-${level.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать уровень</h2>
<button class="modal-close" type="button" th:attr="data-modal=${'modal-rename-level-' + level.id}" onclick="closeModalData(this)">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
<input type="hidden" name="levelId" th:value="${level.id}">
<input type="hidden" name="code" th:value="${level.code}">
<div><label class="label">Название</label>
<input class="input" name="title" th:value="${level.title}" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog th:each="level : ${lab.levels}" th:id="|modal-add-variant-${level.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новый вариант — <span th:text="${level.title}">уровень</span></h2>
<button class="modal-close" type="button" th:attr="data-modal=${'modal-add-variant-' + level.id}" onclick="closeModalData(this)">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
<input type="hidden" name="labLevelId" th:value="${level.id}">
<div class="grid grid-2">
<div><label class="label">Номер</label><input class="input" name="number" required></div>
<div><label class="label">Название</label><input class="input" name="title"></div>
</div>
<div><label class="label">Условие (markdown)</label>
<textarea class="input" name="statementMd" rows="8"></textarea></div>
<button class="btn btn-primary" type="submit">Добавить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<th:block th:each="level : ${lab.levels}">
<dialog th:each="v : ${level.variants}" th:id="|modal-edit-variant-${v.id}|" class="modal">
<dialog id="modal-meta" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка варианта</h2>
<button class="modal-close" type="button" th:attr="data-modal=${'modal-edit-variant-' + v.id}" onclick="closeModalData(this)">✕</button>
<h2 class="modal-title">Правка лабораторной</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-meta')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
<input type="hidden" name="variantId" th:value="${v.id}">
<input type="hidden" name="labLevelId" th:value="${level.id}">
<form class="form-stack" th:action="@{/admin/labs/{id}/edit(id=${lab.id})}" method="post">
<div><label class="label" for="metaTitle">Название</label>
<input class="input" id="metaTitle" name="title" th:value="${lab.title}" required></div>
<div class="grid grid-2">
<div><label class="label">Номер</label><input class="input" name="number" th:value="${v.number}" required></div>
<div><label class="label">Название</label><input class="input" name="title" th:value="${v.title}"></div>
<div><label class="label" for="metaDeadline">Срок сдачи</label>
<input class="input" id="metaDeadline" name="deadlineAt" type="datetime-local" th:value="${deadlineLocal}"></div>
<div><label class="label" for="metaDefense">Дата защиты</label>
<input class="input" id="metaDefense" name="defenseAt" type="datetime-local" th:value="${defenseLocal}"></div>
</div>
<div><label class="label">Условие (markdown)</label>
<textarea class="input" name="statementMd" rows="8" th:text="${v.statementMd}"></textarea></div>
<div class="grid grid-2">
<div><label class="label" for="metaPoints">Максимум баллов</label>
<input class="input" id="metaPoints" name="pointsMax" type="number" step="0.1" min="0" th:value="${lab.pointsMax}" required></div>
<div><label class="label" for="metaStatus">Статус</label>
<select class="input" id="metaStatus" name="status">
<option value="draft" th:selected="${lab.status == 'draft'}">draft</option>
<option value="published" th:selected="${lab.status == 'published'}">published</option>
<option value="archived" th:selected="${lab.status == 'archived'}">archived</option>
</select></div>
</div>
<div><label class="label" for="metaDesc">Условие (markdown)</label>
<textarea class="input" id="metaDesc" name="descriptionMd" rows="12" th:text="${lab.descriptionMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
</th:block>
<script>
function filterGroups(name) {
document.querySelectorAll('.group-section').forEach(function (el) {
el.classList.toggle('hidden', !!name && el.getAttribute('data-group') !== name);
});
}
</script>
<dialog id="modal-add-level" class="modal">
<div class="modal-header">
<h2 class="modal-title">Добавить уровень</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-add-level')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
<div class="grid grid-2">
<div><label class="label" for="lvlCode">Код</label>
<input class="input" id="lvlCode" name="code" placeholder="pro" required></div>
<div><label class="label" for="lvlTitle">Название</label>
<input class="input" id="lvlTitle" name="title" placeholder="Профессиональный" required></div>
</div>
<button class="btn btn-primary" type="submit">Добавить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog th:each="level : ${lab.levels}" th:id="|modal-rename-level-${level.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Переименовать уровень</h2>
<button class="modal-close" type="button" th:attr="data-modal=${'modal-rename-level-' + level.id}" onclick="closeModalData(this)">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/levels/save(labId=${lab.id})}" method="post">
<input type="hidden" name="levelId" th:value="${level.id}">
<input type="hidden" name="code" th:value="${level.code}">
<div><label class="label">Название</label>
<input class="input" name="title" th:value="${level.title}" required></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog th:each="level : ${lab.levels}" th:id="|modal-add-variant-${level.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новый вариант — <span th:text="${level.title}">уровень</span></h2>
<button class="modal-close" type="button" th:attr="data-modal=${'modal-add-variant-' + level.id}" onclick="closeModalData(this)">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
<input type="hidden" name="labLevelId" th:value="${level.id}">
<div class="grid grid-2">
<div><label class="label">Номер</label><input class="input" name="number" required></div>
<div><label class="label">Название</label><input class="input" name="title"></div>
</div>
<div><label class="label">Условие (markdown)</label>
<textarea class="input" name="statementMd" rows="8"></textarea></div>
<button class="btn btn-primary" type="submit">Добавить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<th:block th:each="level : ${lab.levels}">
<dialog th:each="v : ${level.variants}" th:id="|modal-edit-variant-${v.id}|" class="modal">
<div class="modal-header">
<h2 class="modal-title">Правка варианта</h2>
<button class="modal-close" type="button" th:attr="data-modal=${'modal-edit-variant-' + v.id}" onclick="closeModalData(this)">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/{labId}/variants/save(labId=${lab.id})}" method="post">
<input type="hidden" name="variantId" th:value="${v.id}">
<input type="hidden" name="labLevelId" th:value="${level.id}">
<div class="grid grid-2">
<div><label class="label">Номер</label><input class="input" name="number" th:value="${v.number}" required></div>
<div><label class="label">Название</label><input class="input" name="title" th:value="${v.title}"></div>
</div>
<div><label class="label">Условие (markdown)</label>
<textarea class="input" name="statementMd" rows="8" th:text="${v.statementMd}"></textarea></div>
<button class="btn btn-primary" type="submit">Сохранить</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
</th:block>
<script>
function filterGroups(name) {
document.querySelectorAll('.group-section').forEach(function (el) {
el.classList.toggle('hidden', !!name && el.getAttribute('data-group') !== name);
});
}
</script>
</div>
</body>
</html>

View File

@@ -34,43 +34,44 @@
</tbody>
</table>
</div>
</div>
<dialog id="modal-create-lab" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая лабораторная</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-create-lab')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/create}" method="post">
<div class="grid grid-2">
<div><label class="label" for="labNumber">Номер</label>
<input class="input" id="labNumber" name="number" type="number" min="1" required></div>
<div><label class="label" for="labPoints">Максимум баллов</label>
<input class="input" id="labPoints" name="pointsMax" type="number" step="0.1" min="0" value="5" required></div>
</div>
<div><label class="label" for="labTitle">Название</label>
<input class="input" id="labTitle" name="title" required></div>
<div class="grid grid-2">
<div><label class="label" for="labDeadline">Срок сдачи</label>
<input class="input" id="labDeadline" name="deadlineAt" type="datetime-local"></div>
<div><label class="label" for="labDefense">Дата защиты</label>
<input class="input" id="labDefense" name="defenseAt" type="datetime-local"></div>
</div>
<div><label class="label" for="labStatus">Статус</label>
<select class="input" id="labStatus" name="status">
<option value="draft">draft</option>
<option value="published">published</option>
</select></div>
<div><label class="label" for="labLevels">Уровни (по одному на строку: код; название)</label>
<textarea class="input" id="labLevels" name="levels" rows="4" placeholder="basic; Основная группа&#10;advanced; Повышенная сложность"></textarea>
<p class="hint">Если оставить пустым, будет создан уровень «Основная группа». Варианты добавите на странице лабы.</p></div>
<div><label class="label" for="labDesc">Условие (markdown, можно позже)</label>
<textarea class="input" id="labDesc" name="descriptionMd" rows="6"></textarea></div>
<button class="btn btn-primary" type="submit">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
<dialog id="modal-create-lab" class="modal">
<div class="modal-header">
<h2 class="modal-title">Новая лабораторная</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-create-lab')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" th:action="@{/admin/labs/create}" method="post">
<div class="grid grid-2">
<div><label class="label" for="labNumber">Номер</label>
<input class="input" id="labNumber" name="number" type="number" min="1" required></div>
<div><label class="label" for="labPoints">Максимум баллов</label>
<input class="input" id="labPoints" name="pointsMax" type="number" step="0.1" min="0" value="5" required></div>
</div>
<div><label class="label" for="labTitle">Название</label>
<input class="input" id="labTitle" name="title" required></div>
<div class="grid grid-2">
<div><label class="label" for="labDeadline">Срок сдачи</label>
<input class="input" id="labDeadline" name="deadlineAt" type="datetime-local"></div>
<div><label class="label" for="labDefense">Дата защиты</label>
<input class="input" id="labDefense" name="defenseAt" type="datetime-local"></div>
</div>
<div><label class="label" for="labStatus">Статус</label>
<select class="input" id="labStatus" name="status">
<option value="draft">draft</option>
<option value="published">published</option>
</select></div>
<div><label class="label" for="labLevels">Уровни (по одному на строку: код; название)</label>
<textarea class="input" id="labLevels" name="levels" rows="4" placeholder="basic; Основная группа&#10;advanced; Повышенная сложность"></textarea>
<p class="hint">Если оставить пустым, будет создан уровень «Основная группа». Варианты добавите на странице лабы.</p></div>
<div><label class="label" for="labDesc">Условие (markdown, можно позже)</label>
<textarea class="input" id="labDesc" name="descriptionMd" rows="6"></textarea></div>
<button class="btn btn-primary" type="submit">Создать</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
</div>
</dialog>
</div>
</body>
</html>