feat: Tabellen per Spaltenkopf sortierbar (A11-T4)
Neues static/sort.js sortiert Tabellen clientseitig per Klick auf den Spaltenkopf (⇅/▲/▼): Datum TT.MM.JJJJ, deutsche Zahlen/Beträge, sonst Text (Intl.Collator 'de'). Sortierwert optional per td[data-sort] (Punkt-Dezimal/ISO) überschreibbar, th[data-nosort] bekommt keinen Button, table[data-sort-mode="server"] wird übersprungen (Buchungen sortiert serverseitig, folgt in Task 5). Bearbeiten-/Tilgungsplan- Zeilen (tr[data-sort-follow]) wandern mit ihrer Vorzeile mit. Initialisierung über htmx.onLoad, greift auch bei nachgeladenen Fragmenten. Templates entsprechend annotiert (Planung, Szenarien, Admin, Index, Salden, Import-Liste, Buchungen); Salden-Gesamtzeile dafür in ein tfoot verschoben. test_gui.py::test_gui_zeigt_deutsche_betragsformate angepasst, da das neue machinenlesbare data-sort-Attribut bewusst Punkt-Dezimal führt (Interface-Konvention) - die Prüfung auf deutsches Format im sichtbaren Text bleibt bestehen. JS-Unit-/DOM-Tests unter tests/js (node --test, DOM-Teil optional via jsdom), pytest-Wrapper skip ohne node (Fable-Gate führt sie per Podman aus). Suite: 248 passed, 3 skipped (vorher 242/2). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
/* Tabellensortierung per Spaltenkopf (Ausbaustufe 11).
|
||||
*
|
||||
* Jede Tabelle mit <thead> bekommt je Spaltenkopf mit Text einen Button
|
||||
* (⇅ unsortiert, ▲ aufsteigend, ▼ absteigend). Ausnahmen: th[data-nosort],
|
||||
* table[data-sort-mode="server"] (z.B. /buchungen, sortiert serverseitig).
|
||||
* Sortierwert: td[data-sort] (Zahl Punkt-dezimal / ISO-Datum) oder der
|
||||
* angezeigte Text (Datum TT.MM.JJJJ, deutscher Betrag 1.234,56, sonst Text).
|
||||
* Leere Werte und „–" stehen in beiden Richtungen am Ende. Zeilen mit
|
||||
* data-sort-follow (Bearbeiten-/Tilgungsplan-Zeilen) wandern mit ihrer
|
||||
* Vorzeile. Nur <tbody> wird sortiert, <tfoot> bleibt unberührt.
|
||||
* Initialisierung über htmx.onLoad - greift auch bei nachgeladenen Fragmenten.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var DE_DATE = /^(\d{2})\.(\d{2})\.(\d{4})$/;
|
||||
var DE_NUMBER = /^-?(\d{1,3}(\.\d{3})+|\d+)(,\d+)?$/;
|
||||
var PLAIN_NUMBER = /^-?\d+(\.\d+)?$/;
|
||||
var ICON = { none: '⇅', asc: '▲', desc: '▼' };
|
||||
var collator = new Intl.Collator('de', { sensitivity: 'base', numeric: true });
|
||||
|
||||
function sortKey(raw, fromDataSort) {
|
||||
var text = String(raw == null ? '' : raw).trim();
|
||||
if (text === '' || text === '–' || text === '-') { return null; }
|
||||
if (fromDataSort && PLAIN_NUMBER.test(text)) {
|
||||
return { kind: 'number', value: parseFloat(text), text: text };
|
||||
}
|
||||
var m = DE_DATE.exec(text);
|
||||
if (m) { return { kind: 'date', value: parseInt(m[3] + m[2] + m[1], 10), text: text }; }
|
||||
var num = text.replace(/\s*[€%]$/, '').replace(/\s/g, '');
|
||||
if (DE_NUMBER.test(num)) {
|
||||
return { kind: 'number', value: parseFloat(num.replace(/\./g, '').replace(',', '.')), text: text };
|
||||
}
|
||||
return { kind: 'text', value: text, text: text };
|
||||
}
|
||||
|
||||
function columnKind(keys) {
|
||||
var kind = null;
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
if (!keys[i]) { continue; }
|
||||
if (kind === null) { kind = keys[i].kind; } else if (kind !== keys[i].kind) { return 'text'; }
|
||||
}
|
||||
return kind || 'text';
|
||||
}
|
||||
|
||||
function sortItems(items, dir) {
|
||||
var kind = columnKind(items.map(function (it) { return it.key; }));
|
||||
var sign = dir === 'desc' ? -1 : 1;
|
||||
return items.slice().sort(function (a, b) {
|
||||
if (!a.key || !b.key) {
|
||||
if (a.key) { return -1; }
|
||||
if (b.key) { return 1; }
|
||||
return a.index - b.index;
|
||||
}
|
||||
var c = kind === 'text' ? collator.compare(a.key.text, b.key.text)
|
||||
: a.key.value - b.key.value;
|
||||
return c !== 0 ? sign * c : a.index - b.index;
|
||||
});
|
||||
}
|
||||
|
||||
function groupRows(rows, isFollow) {
|
||||
var groups = [];
|
||||
rows.forEach(function (row) {
|
||||
if (isFollow(row) && groups.length) { groups[groups.length - 1].push(row); }
|
||||
else { groups.push([row]); }
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function sortTable(table, col, dir) {
|
||||
var tbody = table.tBodies[0];
|
||||
if (!tbody) { return; }
|
||||
var groups = groupRows(Array.prototype.slice.call(tbody.rows), function (r) {
|
||||
return r.hasAttribute('data-sort-follow');
|
||||
});
|
||||
var items = groups.map(function (g, i) {
|
||||
var cell = g[0].cells[col];
|
||||
var key = null;
|
||||
if (cell && cell.colSpan === 1) {
|
||||
key = cell.hasAttribute('data-sort')
|
||||
? sortKey(cell.getAttribute('data-sort'), true)
|
||||
: sortKey(cell.textContent, false);
|
||||
}
|
||||
return { key: key, index: i, rows: g };
|
||||
});
|
||||
sortItems(items, dir).forEach(function (it) {
|
||||
it.rows.forEach(function (r) { tbody.appendChild(r); });
|
||||
});
|
||||
}
|
||||
|
||||
function initTable(table) {
|
||||
if (table.hasAttribute('data-sort-init') || !table.tHead || !table.tHead.rows.length
|
||||
|| table.getAttribute('data-sort-mode') === 'server') { return; }
|
||||
table.setAttribute('data-sort-init', '');
|
||||
var doc = table.ownerDocument;
|
||||
Array.prototype.forEach.call(table.tHead.rows[0].cells, function (th) {
|
||||
var label = th.textContent.trim();
|
||||
if (!label || th.hasAttribute('data-nosort')) { return; }
|
||||
var btn = doc.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'sort-btn';
|
||||
btn.textContent = ICON.none;
|
||||
btn.setAttribute('aria-label', 'Nach ' + label + ' sortieren');
|
||||
btn.addEventListener('click', function () {
|
||||
var dir = btn.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc';
|
||||
Array.prototype.forEach.call(table.tHead.querySelectorAll('.sort-btn'), function (b) {
|
||||
b.removeAttribute('data-dir');
|
||||
b.classList.remove('active');
|
||||
b.textContent = ICON.none;
|
||||
});
|
||||
btn.setAttribute('data-dir', dir);
|
||||
btn.classList.add('active');
|
||||
btn.textContent = ICON[dir];
|
||||
sortTable(table, th.cellIndex, dir);
|
||||
});
|
||||
th.appendChild(doc.createTextNode(' '));
|
||||
th.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function init(root) {
|
||||
if (!root || !root.querySelectorAll) { return; }
|
||||
if (root.tagName === 'TABLE') { initTable(root); }
|
||||
Array.prototype.forEach.call(root.querySelectorAll('table'), initTable);
|
||||
}
|
||||
|
||||
var api = { sortKey: sortKey, columnKind: columnKind, sortItems: sortItems,
|
||||
groupRows: groupRows, sortTable: sortTable, initTable: initTable, init: init };
|
||||
if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
|
||||
if (typeof window !== 'undefined') {
|
||||
window.FBSort = api;
|
||||
if (window.htmx) { window.htmx.onLoad(init); }
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user