` (текст/число/дата)
+- **Number input:** спиннеры скрыты глобально через CSS (`service.css`), `type="number"` выглядит как обычный input
---
diff --git a/max_bot/version.txt b/max_bot/version.txt
index ff2fd4f..9eadd6b 100644
--- a/max_bot/version.txt
+++ b/max_bot/version.txt
@@ -1 +1 @@
-1.8.5
\ No newline at end of file
+1.8.6
\ No newline at end of file
diff --git a/py_service/CHANGELOG.md b/py_service/CHANGELOG.md
index 8a17d9c..d776e70 100644
--- a/py_service/CHANGELOG.md
+++ b/py_service/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog — AegisOne Service Portal
+## 1.8.6 (02.06.2026)
+### Новые функции
+- **sort-table.js:** универсальный модуль сортировки таблиц по столбцам (текст/число/дата), подключён глобально
+- **Number input:** спиннеры скрыты через CSS (`appearance: textfield`) на всех страницах
+
+### Исправления
+- **customers.html:** восстановлена сломанная HTML-структура — `.card` > `.card-header`, поиск в `.filter-bar`
+- **bot_dialogues.html:** обёрнут в `.card` > `.card-header`, фильтры в `.filter-bar`, удалены кастомные `.page-header`/`.header-controls`
+- **checklist.html:** `
` обёрнут в `.card-header`
+- **18 страниц:** `modal-actions` → `form-actions` (единый класс для кнопок в модалках)
+- **charts.html:** `btn-xs` → `btn-sm`
+- **assignments.html:** добавлена модалка подтверждения отвязки сотрудника
+- **4 AJAX-страницы:** `sortTables()` вызывается после загрузки данных (bot_kb, bot_unknown, bot_consent, bot_tickets)
+
## 1.8.5 (02.06.2026)
### Новые функции
- **AGENTS.md (раздел 12):** правила по Идеям — агент может видеть, добавлять, менять статус, предлагать к реализации, реализовывать
diff --git a/py_service/SERVICE_STYLE_GUIDE.md b/py_service/SERVICE_STYLE_GUIDE.md
index 4acac07..f9b5121 100644
--- a/py_service/SERVICE_STYLE_GUIDE.md
+++ b/py_service/SERVICE_STYLE_GUIDE.md
@@ -227,4 +227,6 @@ showNotification('Текст уведомления', 'info'); // сине
- [ ] Кнопки редактирования: `✎` (btn-secondary)
- [ ] Порядок кнопок в модалках: Действие → Отмена
- [ ] Уведомления через `showNotification()`
+- [ ] Таблицы используют `sort-table.js` (автоматически через base.html)
+- [ ] Number input без спиннеров (CSS в service.css)
- [ ] CSS-классы модалок: `.modal-overlay` / `.modal`
diff --git a/py_service/app/static/css/service.css b/py_service/app/static/css/service.css
index 87585d5..e83536e 100644
--- a/py_service/app/static/css/service.css
+++ b/py_service/app/static/css/service.css
@@ -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;
diff --git a/py_service/app/static/js/sort-table.js b/py_service/app/static/js/sort-table.js
new file mode 100644
index 0000000..f4bf8b7
--- /dev/null
+++ b/py_service/app/static/js/sort-table.js
@@ -0,0 +1,164 @@
+/**
+ * sort-table.js — Универсальный модуль сортировки таблиц.
+ *
+ * Автоматически находит все
на странице и делает
+ * заголовки
кликабельными для сортировки по столбцам.
+ *
+ * Использование:
+ * 1. Подключить
+ * 2. Таблицы автоматически получают сортировку при загрузке
+ * 3. Для AJAX-таблиц: вызвать sortTables() после заполнения
+ */
+(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;
+})();
diff --git a/py_service/app/templates/base.html b/py_service/app/templates/base.html
index afc25b6..d2b5be9 100644
--- a/py_service/app/templates/base.html
+++ b/py_service/app/templates/base.html
@@ -55,6 +55,7 @@
+
{% endblock %}
diff --git a/py_service/app/templates/pages/blog.html b/py_service/app/templates/pages/blog.html
index 32053f0..c86830b 100644
--- a/py_service/app/templates/pages/blog.html
+++ b/py_service/app/templates/pages/blog.html
@@ -39,7 +39,7 @@
будет удалён навсегда. Это действие нельзя отменить.
-
+
diff --git a/py_service/app/templates/pages/bot_consent.html b/py_service/app/templates/pages/bot_consent.html
index 12a4916..053a015 100644
--- a/py_service/app/templates/pages/bot_consent.html
+++ b/py_service/app/templates/pages/bot_consent.html
@@ -36,7 +36,7 @@
.modal { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:24px; width:420px; max-width:90vw; }
.modal h2 { color:var(--text-primary); }
.modal p { color:var(--text-secondary); }
-.modal-actions { display:flex; gap:10px; }
+.form-actions { display:flex; gap:10px; }
.btn-danger { background:var(--danger, #dc3545); color:#fff; border:none; padding:8px 16px; border-radius:6px; cursor:pointer; font-weight:600; }
.btn { background:var(--bg-input); color:var(--text-primary); border:1px solid var(--border); padding:8px 16px; border-radius:6px; cursor:pointer; }
@@ -109,7 +109,7 @@
Все его диалоги и сообщения будут удалены. Заявки останутся.
Это действие нельзя отменить.
-
будет удалён навсегда. Это действие нельзя отменить.
-
+
diff --git a/py_service/version.txt b/py_service/version.txt
index ff2fd4f..9eadd6b 100644
--- a/py_service/version.txt
+++ b/py_service/version.txt
@@ -1 +1 @@
-1.8.5
\ No newline at end of file
+1.8.6
\ No newline at end of file