Tables: initial ФИО order, click-to-sort columns, substring search
- identity group detail (and thus every student list) is ordered by full name; defense rows on the group page sorted by name + lab number - tablekit.js: click a header to sort (numeric-aware, ru locale), second click flips direction (▲/▼ indicator); search input with data-target filters rows by substring live - tables wired: group matrix (with data-sort on variant cells), defense table (data-sort on status/date), students table, lab assignment lists per group, groups list and labs list; name/number columns pre-marked ascending - non-sortable columns marked data-nosort (checkbox/actions)
This commit is contained in:
@@ -222,7 +222,7 @@ class GroupService(
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
tempPassword = account.tempPassword,
|
||||
)
|
||||
}.sortedWith(Comparator { a, b -> studentNoComparator.compare(a.studentNo, b.studentNo) })
|
||||
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
|
||||
return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
|
||||
}
|
||||
|
||||
@@ -264,17 +264,4 @@ class GroupService(
|
||||
studentCount = enrollments.countByGroup_Id(group.id),
|
||||
taskDurationMin = group.taskDurationMin,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val studentNoComparator = Comparator<String> { a, b ->
|
||||
val left = a.toIntOrNull()
|
||||
val right = b.toIntOrNull()
|
||||
when {
|
||||
left != null && right != null -> left.compareTo(right)
|
||||
left != null -> -1
|
||||
right != null -> 1
|
||||
else -> a.compareTo(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ class AdminController(
|
||||
assignmentMd = d.assignmentMd, secondsLeft = d.secondsLeft,
|
||||
)
|
||||
}
|
||||
}
|
||||
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.labNumber }))
|
||||
|
||||
model.addAttribute("group", group)
|
||||
model.addAttribute("labs", labs)
|
||||
|
||||
@@ -329,3 +329,12 @@ details.sub-acc[open] > summary .sub-arrow { transform: rotate(-135deg); }
|
||||
.lab-head-row { display: flex; align-items: center; justify-content: center; gap: 0.3rem; }
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.actions-cell .inline-form { display: inline-block; }
|
||||
|
||||
/* tablekit: сортируемые таблицы и поиск */
|
||||
th.th-sortable { cursor: pointer; user-select: none; }
|
||||
th.th-sortable:hover { background: var(--c-surface); }
|
||||
th.th-sortable.is-asc::after { content: " ▲"; color: var(--c-text-muted); font-size: 0.7rem; }
|
||||
th.th-sortable.is-desc::after { content: " ▼"; color: var(--c-text-muted); font-size: 0.7rem; }
|
||||
.row-hidden { display: none !important; }
|
||||
.table-search { width: 14rem; }
|
||||
@media (max-width: 640px) { .table-search { width: 100%; } }
|
||||
|
||||
80
web/src/main/resources/static/js/tablekit.js
Normal file
80
web/src/main/resources/static/js/tablekit.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/* tablekit: сортировка по клику на заголовок + живой поиск по строке.
|
||||
Таблицу размечают: <table class="tablekit" id="...">, поле поиска
|
||||
<input class="table-search" data-target="id-таблицы">.
|
||||
Начальная сортировка (ФИО) выполняется на сервере; клик по заголовку меняет её. */
|
||||
(function () {
|
||||
function normalize(value) {
|
||||
return (value || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function cellValue(cell) {
|
||||
if (!cell) return '';
|
||||
if (cell.dataset && cell.dataset.sort !== undefined) return normalize(cell.dataset.sort);
|
||||
return normalize(cell.textContent);
|
||||
}
|
||||
|
||||
function compared(a, b) {
|
||||
var na = a.match(/-?\d+([.,]\d+)?/g);
|
||||
var nb = b.match(/-?\d+([.,]\d+)?/g);
|
||||
var fa = na ? parseFloat(na[0].replace(',', '.')) : NaN;
|
||||
var fb = nb ? parseFloat(nb[0].replace(',', '.')) : NaN;
|
||||
if (!isNaN(fa) && !isNaN(fb) && fa !== fb) return fa - fb;
|
||||
return a.localeCompare(b, 'ru', { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function clearMarkers(table, exceptColumn) {
|
||||
var cells = table.tHead.rows[table.tHead.rows.length - 1].cells;
|
||||
for (var i = 0; i < cells.length; i++) {
|
||||
if (i === exceptColumn) continue;
|
||||
cells[i].classList.remove('is-asc', 'is-desc');
|
||||
}
|
||||
}
|
||||
|
||||
function sortColumn(table, index, direction) {
|
||||
var body = table.tBodies[0];
|
||||
if (!body) return;
|
||||
var rows = Array.prototype.slice.call(body.rows);
|
||||
rows.sort(function (a, b) {
|
||||
return compared(cellValue(a.cells[index]), cellValue(b.cells[index]));
|
||||
});
|
||||
if (direction < 0) rows.reverse();
|
||||
rows.forEach(function (row) { body.appendChild(row); });
|
||||
}
|
||||
|
||||
function bind(table) {
|
||||
if (table.dataset.tablekitBound) return;
|
||||
table.dataset.tablekitBound = '1';
|
||||
var head = table.tHead;
|
||||
if (!head) return;
|
||||
var cells = head.rows[head.rows.length - 1].cells;
|
||||
Array.prototype.forEach.call(cells, function (th, index) {
|
||||
if (th.hasAttribute('data-nosort')) return;
|
||||
th.classList.add('th-sortable');
|
||||
th.title = th.title || 'Сортировать по этому столбцу';
|
||||
th.addEventListener('click', function (event) {
|
||||
if (event.target.closest('form, button, a, select, input')) return;
|
||||
var asc = !th.classList.contains('is-asc');
|
||||
clearMarkers(table, index);
|
||||
th.classList.add(asc ? 'is-asc' : 'is-desc');
|
||||
sortColumn(table, index, asc ? 1 : -1);
|
||||
th.title = (th.textContent.trim() || 'Столбец') + ' — ' + (asc ? 'по возрастанию' : 'по убыванию');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindSearch(input) {
|
||||
var table = document.getElementById(input.dataset.target);
|
||||
if (!table || !table.tBodies[0]) return;
|
||||
input.addEventListener('input', function () {
|
||||
var query = normalize(input.value);
|
||||
Array.prototype.forEach.call(table.tBodies[0].rows, function (row) {
|
||||
row.classList.toggle('row-hidden', !!query && normalize(row.textContent).indexOf(query) < 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('table.tablekit').forEach(bind);
|
||||
document.querySelectorAll('input.table-search').forEach(bindSearch);
|
||||
});
|
||||
})();
|
||||
@@ -29,6 +29,8 @@
|
||||
<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)}">
|
||||
<input class="input input-sm table-search" data-target="matrix-table"
|
||||
placeholder="Поиск по студенту…" aria-label="Поиск по студенту">
|
||||
<label class="label m-0" for="lab-focus">Показать:</label>
|
||||
<select class="input input-sm" id="lab-focus" onchange="focusLab()">
|
||||
<option value="">все лабораторные</option>
|
||||
@@ -44,10 +46,10 @@
|
||||
<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">
|
||||
<table class="matrix tablekit" id="matrix-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="matrix-sticky">Студент</th>
|
||||
<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">
|
||||
<div class="lab-head-row">
|
||||
@@ -73,7 +75,7 @@
|
||||
<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}">
|
||||
<td th:each="cell, iterStat : ${row.cells}" class="text-center cell-lab" th:attr="data-lab=${cell.labId},data-sort=${cell.variantNumber}">
|
||||
<div class="cell-chips">
|
||||
<span class="cell-chip"
|
||||
th:title="(${cell.variantNumber} ? 'Вариант ' + ${cell.variantNumber} : 'Вариант') + ' — ' + (${cell.variantManual} ? 'закреплён вручную (раздача его не тронет)' : 'выдан автоматически')">
|
||||
@@ -113,11 +115,13 @@
|
||||
</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>
|
||||
<input class="input input-sm table-search" data-target="defense-table"
|
||||
placeholder="Поиск по студенту…" aria-label="Поиск по заданиям защиты">
|
||||
</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>
|
||||
<table id="defense-table" class="tablekit">
|
||||
<thead><tr><th class="th-sortable is-asc" title="ФИО — по возрастанию">Студент</th><th>ЛР</th><th>Задание</th><th>Статус</th><th class="text-right" data-nosort>Действия</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="d : ${defenseRows}" th:attr="data-lab=${d.labId}">
|
||||
<td>
|
||||
@@ -162,10 +166,14 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Студенты</h2>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<h2 class="section-title m-0">Студенты</h2>
|
||||
<input class="input input-sm table-search" data-target="students-table"
|
||||
placeholder="Поиск по студенту…" aria-label="Поиск по студентам">
|
||||
</div>
|
||||
<div class="card card-flush">
|
||||
<table>
|
||||
<thead><tr><th>№</th><th>ФИО</th><th>Логин</th><th>Основа</th><th>Варианты</th><th>Пароль</th><th class="text-right">Действия</th></tr></thead>
|
||||
<table id="students-table" class="tablekit">
|
||||
<thead><tr><th data-nosort>№</th><th class="th-sortable is-asc" title="ФИО — по возрастанию">ФИО</th><th>Логин</th><th>Основа</th><th>Варианты</th><th>Пароль</th><th class="text-right" data-nosort>Действия</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="row, iter : ${studentGrades}">
|
||||
<td class="text-muted" th:text="${iter.count}">1</td>
|
||||
|
||||
@@ -22,9 +22,14 @@
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
</form>
|
||||
</div>
|
||||
<div class="card card-flush mt-8">
|
||||
<table>
|
||||
<thead><tr><th>Название</th><th>Студентов</th><th>Выгрузка</th></tr></thead>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2 mt-8">
|
||||
<h2 class="section-title m-0">Список групп</h2>
|
||||
<input class="input input-sm table-search" data-target="groups-table"
|
||||
placeholder="Поиск по названию…" aria-label="Поиск по группам">
|
||||
</div>
|
||||
<div class="card card-flush">
|
||||
<table id="groups-table" class="tablekit">
|
||||
<thead><tr><th class="th-sortable is-asc" title="Название — по возрастанию">Название</th><th>Студентов</th><th data-nosort>Выгрузка</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="group : ${groups}">
|
||||
<td><a th:href="@{/admin/groups/{id}(id=${group.id})}" th:text="${group.name}">—</a></td>
|
||||
|
||||
@@ -71,13 +71,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div th:each="g : ${assignmentGroups}" class="card mb-4" th:if="${!#lists.isEmpty(g.students)}">
|
||||
<div th:each="g, gStat : ${assignmentGroups}" class="card mb-4" th:if="${!#lists.isEmpty(g.students)}">
|
||||
<div class="card-body">
|
||||
<h3 class="group-title" th:text="${g.groupName}">Группа</h3>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<h3 class="group-title m-0" th:text="${g.groupName}">Группа</h3>
|
||||
<input class="input input-sm table-search" th:attr="data-target='assign-table-' + ${gStat.index}"
|
||||
placeholder="Поиск по студенту…" aria-label="Поиск по студентам группы">
|
||||
</div>
|
||||
<div class="scroll-x">
|
||||
<table>
|
||||
<table class="tablekit" th:attr="id='assign-table-' + ${gStat.index}">
|
||||
<thead><tr>
|
||||
<th class="th-w-sm">Выбор</th><th>Студент</th><th class="th-w-md">Уровень</th><th class="th-w-md">Вариант</th>
|
||||
<th class="th-w-sm" data-nosort>Выбор</th><th class="th-sortable is-asc" title="ФИО — по возрастанию">Студент</th><th class="th-w-md">Уровень</th><th class="th-w-md">Вариант</th>
|
||||
<th class="th-w-sm">Источник</th><th>Сдача</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<div class="flex items-center justify-between gap-4 mb-6">
|
||||
<h1 class="page-title">Лабораторные</h1>
|
||||
<div class="flex gap-2">
|
||||
<input class="input input-sm table-search" data-target="labs-table"
|
||||
placeholder="Поиск по названию…" aria-label="Поиск по лабораторным">
|
||||
<button class="btn btn-secondary" type="button" onclick="openModal('modal-import-lab')">Импорт</button>
|
||||
<button class="btn btn-primary" type="button" onclick="openModal('modal-create-lab')">+ Создать</button>
|
||||
</div>
|
||||
@@ -16,8 +18,8 @@
|
||||
<div th:if="${error}" class="alert alert-error" th:text="${error}"></div>
|
||||
|
||||
<div class="card card-flush">
|
||||
<table>
|
||||
<thead><tr><th>№</th><th>Название</th><th>Вариантов</th><th>Статус</th><th></th></tr></thead>
|
||||
<table id="labs-table" class="tablekit">
|
||||
<thead><tr><th class="th-sortable is-asc" title="Номер — по возрастанию">№</th><th>Название</th><th>Вариантов</th><th>Статус</th><th data-nosort></th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="lab : ${labs}">
|
||||
<td th:text="${lab.number}">1</td>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css}" disabled>
|
||||
<script th:src="@{/js/htmx.min.js}" defer></script>
|
||||
<script th:src="@{/js/highlight.min.js}" defer></script>
|
||||
<script th:src="@{/js/tablekit.js}" defer></script>
|
||||
<script>
|
||||
(function(){
|
||||
var t = localStorage.getItem('lms-theme') || 'light';
|
||||
|
||||
Reference in New Issue
Block a user