Files
bin/finance/app/static/sort.js
T
rdmsandClaude Opus 5.5 4458300f43 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>
2026-09-28 19:22:34 +02:00

135 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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); }
}
})();