v1.8.6: сортировка таблиц, спиннеры number input, единый стиль — fix broken HTML, modal-actions → form-actions, card-wrapper
Tests / test (push) Has been cancelled
Tests / test-max-bot (push) Has been cancelled

This commit is contained in:
2026-06-02 21:25:12 +03:00
parent b2a270cb2e
commit 1199407a72
27 changed files with 279 additions and 60 deletions
+11
View File
@@ -164,6 +164,17 @@ img { max-width:100%; height:auto; }
resize: vertical;
}
/* Скрытие стандартных спиннеров на number input */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
.btn {
display: inline-flex;
align-items: center;
+164
View File
@@ -0,0 +1,164 @@
/**
* sort-table.js — Универсальный модуль сортировки таблиц.
*
* Автоматически находит все <table> на странице и делает
* заголовки <th> кликабельными для сортировки по столбцам.
*
* Использование:
* 1. Подключить <script src="/service/static/js/sort-table.js"></script>
* 2. Таблицы автоматически получают сортировку при загрузке
* 3. Для AJAX-таблиц: вызвать sortTables() после заполнения <tbody>
*/
(function(){
'use strict';
var SORT_CSS = [
'.sort-th { cursor:pointer; user-select:none; position:relative; padding-right:18px !important; }',
'.sort-th:hover { color:var(--accent); }',
'.sort-th::after { content:""; position:absolute; right:6px; top:50%; transform:translateY(-50%); width:0; height:0; border-left:4px solid transparent; border-right:4px solid transparent; border-top:5px solid var(--text-muted); opacity:0.4; }',
'.sort-th.sort-asc::after { border-top:none; border-bottom:5px solid var(--accent); opacity:1; }',
'.sort-th.sort-desc::after { border-top:5px solid var(--accent); opacity:1; }'
].join('\n');
/* Встраиваем стили один раз */
function injectStyles(){
if(document.getElementById('sort-table-styles')) return;
var style = document.createElement('style');
style.id = 'sort-table-styles';
style.textContent = SORT_CSS;
document.head.appendChild(style);
}
/* Определение типа значения ячейки */
function getCellValue(tr, idx){
var td = tr.children[idx];
if(!td) return '';
var text = (td.textContent || '').trim();
return text;
}
/* Парсинг числа из текста */
function parseNumber(text){
if(!text || text === '—' || text === '-') return NaN;
/* Убираем пробелы,.currency-символы, % */
var cleaned = text.replace(/[^\d,\.\-]/g, '').replace(',', '.');
var num = parseFloat(cleaned);
return isNaN(num) ? NaN : num;
}
/* Парсинг даты из текста (DD.MM.YYYY или YYYY-MM-DD) */
function parseDate(text){
if(!text) return NaN;
/* DD.MM.YYYY HH:MM */
var m = text.match(/(\d{2})\.(\d{2})\.(\d{4})/);
if(m) return new Date(m[3], m[2]-1, m[1]).getTime();
/* YYYY-MM-DD */
m = text.match(/(\d{4})-(\d{2})-(\d{2})/);
if(m) return new Date(m[1], m[2]-1, m[3]).getTime();
return NaN;
}
/* Сравнение двух значений */
function compare(a, b, type){
if(type === 'number'){
var na = parseNumber(a), nb = parseNumber(b);
if(isNaN(na) && isNaN(nb)) return 0;
if(isNaN(na)) return 1;
if(isNaN(nb)) return -1;
return na - nb;
}
if(type === 'date'){
var da = parseDate(a), db = parseDate(b);
if(isNaN(da) && isNaN(db)) return 0;
if(isNaN(da)) return 1;
if(isNaN(db)) return -1;
return da - db;
}
/* Текст */
return a.localeCompare(b, 'ru');
}
/* Определение типа столбца по первым строкам */
function detectColumnType(tbody, idx){
var rows = tbody.children;
var samples = [];
var limit = Math.min(rows.length, 10);
for(var i = 0; i < limit; i++){
samples.push(getCellValue(rows[i], idx));
}
var numCount = 0, dateCount = 0;
for(var j = 0; j < samples.length; j++){
if(parseNumber(samples[j]) !== NaN && samples[j] !== '') numCount++;
if(parseDate(samples[j]) !== NaN && samples[j] !== '') dateCount++;
}
if(samples.length > 0 && numCount / samples.length > 0.6) return 'number';
if(samples.length > 0 && dateCount / samples.length > 0.6) return 'date';
return 'text';
}
/* Привязка сортировки к таблице */
function attachSort(table){
if(table.dataset.sortAttached) return;
table.dataset.sortAttached = '1';
var thead = table.querySelector('thead');
var tbody = table.querySelector('tbody');
if(!thead || !tbody) return;
var headers = thead.querySelectorAll('th');
headers.forEach(function(th, idx){
/* Пропускаем колонку действий */
if(th.classList.contains('actions')) return;
th.classList.add('sort-th');
th.dataset.sortIdx = idx;
th.dataset.sortDir = 'none';
th.addEventListener('click', function(){
var colIdx = parseInt(this.dataset.sortIdx);
var currentDir = this.dataset.sortDir;
var newDir = currentDir === 'asc' ? 'desc' : 'asc';
/* Сброс других столбцов */
headers.forEach(function(h){ h.classList.remove('sort-asc', 'sort-desc'); h.dataset.sortDir = 'none'; });
this.classList.remove('sort-asc', 'sort-desc');
this.classList.add('sort-' + newDir);
this.dataset.sortDir = newDir;
/* Определение типа данных */
var colType = detectColumnType(tbody, colIdx);
/* Сбор строк с значениями */
var rowsArray = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
rowsArray.sort(function(rowA, rowB){
var valA = getCellValue(rowA, colIdx);
var valB = getCellValue(rowB, colIdx);
var result = compare(valA, valB, colType);
return newDir === 'asc' ? result : -result;
});
/* Перестановка строк */
rowsArray.forEach(function(row){ tbody.appendChild(row); });
});
});
}
/* Публичная функция: инициализация/переинициализация всех таблиц */
function sortTables(){
injectStyles();
var tables = document.querySelectorAll('table');
for(var i = 0; i < tables.length; i++){
attachSort(tables[i]);
}
}
/* Автозапуск после загрузки DOM */
if(document.readyState === 'loading'){
document.addEventListener('DOMContentLoaded', sortTables);
} else {
sortTables();
}
/* Экспорт в глобальную область */
window.sortTables = sortTables;
})();