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); }
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -210,3 +210,19 @@ label:has(input:disabled), label:has(select:disabled) {
|
|||||||
.project-block {
|
.project-block {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sortier-Buttons in Spaltenköpfen (Ausbaustufe 11, static/sort.js). */
|
||||||
|
.sort-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.sort-btn.active {
|
||||||
|
color: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ s.filename }}</td>
|
<td>{{ s.filename }}</td>
|
||||||
<td>{{ s.bank }}</td>
|
<td>{{ s.bank }}</td>
|
||||||
<td>
|
<td data-sort="{{ s.period_start.isoformat() if s.period_start else '' }}">
|
||||||
{% if s.period_start and s.period_end %}
|
{% if s.period_start and s.period_end %}
|
||||||
{{ s.period_start.strftime('%d.%m.%Y') }} – {{ s.period_end.strftime('%d.%m.%Y') }}
|
{{ s.period_start.strftime('%d.%m.%Y') }} – {{ s.period_end.strftime('%d.%m.%Y') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<td>{{ c.name }}</td>
|
<td>{{ c.name }}</td>
|
||||||
<td><button type="button" onclick="toggleEdit('cat', {{ c.id }}, true)">Bearbeiten</button></td>
|
<td><button type="button" onclick="toggleEdit('cat', {{ c.id }}, true)">Bearbeiten</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr id="cat-edit-{{ c.id }}" hidden>
|
<tr id="cat-edit-{{ c.id }}" hidden data-sort-follow>
|
||||||
<td colspan="2">
|
<td colspan="2">
|
||||||
<form hx-ext="json-form" hx-patch="/api/categories/{{ c.id }}" hx-swap="none"
|
<form hx-ext="json-form" hx-patch="/api/categories/{{ c.id }}" hx-swap="none"
|
||||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<title>{% block title %}Finanzberatung{% endblock %}</title>
|
<title>{% block title %}Finanzberatung{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
<script src="/static/sort.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<nav>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Konto</th><th>Bank</th><th>Saldo</th><th>Anker</th>
|
<th>Konto</th><th>Bank</th><th>Saldo</th><th>Anker</th>
|
||||||
<th>Umbenennen</th><th>Anker pflegen</th>
|
<th data-nosort>Umbenennen</th><th data-nosort>Anker pflegen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="account">{{ account.name }}</td>
|
<td class="account">{{ account.name }}</td>
|
||||||
<td>{{ account.bank }}</td>
|
<td>{{ account.bank }}</td>
|
||||||
<td class="{{ 'neg' if balance < 0 else '' }}">{{ balance|eur }} €</td>
|
<td class="{{ 'neg' if balance < 0 else '' }}" data-sort="{{ balance }}">{{ balance|eur }} €</td>
|
||||||
<td>
|
<td data-sort="{{ account.anchor_date.isoformat() if account.anchor_date is not none else '' }}">
|
||||||
{% if account.anchor_date is not none and account.anchor_balance is not none %}
|
{% if account.anchor_date is not none and account.anchor_balance is not none %}
|
||||||
Anker: {{ account.anchor_balance|eur }} € am {{ account.anchor_date.strftime('%d.%m.%Y') }}
|
Anker: {{ account.anchor_balance|eur }} € am {{ account.anchor_date.strftime('%d.%m.%Y') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
{% for r in recurring %}
|
{% for r in recurring %}
|
||||||
<tr id="rec-row-{{ r.id }}">
|
<tr id="rec-row-{{ r.id }}">
|
||||||
<td>{{ r.name }}</td>
|
<td>{{ r.name }}</td>
|
||||||
<td class="{{ 'neg' if r.amount < 0 else '' }}">{{ r.amount|eur }} €</td>
|
<td class="{{ 'neg' if r.amount < 0 else '' }}" data-sort="{{ r.amount }}">{{ r.amount|eur }} €</td>
|
||||||
<td>{{ r.rhythm|de_label }}</td>
|
<td>{{ r.rhythm|de_label }}</td>
|
||||||
<td>{{ r.due_day }}</td>
|
<td>{{ r.due_day }}</td>
|
||||||
<td>{{ r.start_date.strftime('%d.%m.%Y') if r.start_date else '–' }}</td>
|
<td>{{ r.start_date.strftime('%d.%m.%Y') if r.start_date else '–' }}</td>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr id="rec-edit-{{ r.id }}" hidden>
|
<tr id="rec-edit-{{ r.id }}" hidden data-sort-follow>
|
||||||
<td colspan="8">
|
<td colspan="8">
|
||||||
<form hx-ext="json-form" hx-patch="/api/recurring/{{ r.id }}" hx-swap="none"
|
<form hx-ext="json-form" hx-patch="/api/recurring/{{ r.id }}" hx-swap="none"
|
||||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
{% for s in suggestions %}
|
{% for s in suggestions %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ s.name }}</td>
|
<td>{{ s.name }}</td>
|
||||||
<td class="{{ 'neg' if s.amount < 0 else '' }}">
|
<td class="{{ 'neg' if s.amount < 0 else '' }}" data-sort="{{ s.amount }}">
|
||||||
{{ s.amount|eur }} €
|
{{ s.amount|eur }} €
|
||||||
{% if s.hinweis %}<span class="muted">{{ s.hinweis }}</span>{% endif %}
|
{% if s.hinweis %}<span class="muted">{{ s.hinweis }}</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
{% for p in planned %}
|
{% for p in planned %}
|
||||||
<tr id="pln-row-{{ p.id }}">
|
<tr id="pln-row-{{ p.id }}">
|
||||||
<td>{{ p.name }}</td>
|
<td>{{ p.name }}</td>
|
||||||
<td class="{{ 'neg' if p.amount < 0 else '' }}">{{ p.amount|eur }} €</td>
|
<td class="{{ 'neg' if p.amount < 0 else '' }}" data-sort="{{ p.amount }}">{{ p.amount|eur }} €</td>
|
||||||
<td>{{ p.due.strftime('%d.%m.%Y') }}</td>
|
<td>{{ p.due.strftime('%d.%m.%Y') }}</td>
|
||||||
<td>{{ category_names.get(p.category_id, '–') }}</td>
|
<td>{{ category_names.get(p.category_id, '–') }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr id="pln-edit-{{ p.id }}" hidden>
|
<tr id="pln-edit-{{ p.id }}" hidden data-sort-follow>
|
||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
<form hx-ext="json-form" hx-patch="/api/planned/{{ p.id }}" hx-swap="none"
|
<form hx-ext="json-form" hx-patch="/api/planned/{{ p.id }}" hx-swap="none"
|
||||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
@@ -198,9 +198,9 @@
|
|||||||
{% for l in loans %}
|
{% for l in loans %}
|
||||||
<tr id="loan-row-{{ l.id }}">
|
<tr id="loan-row-{{ l.id }}">
|
||||||
<td>{{ l.name }}</td>
|
<td>{{ l.name }}</td>
|
||||||
<td>{{ l.principal|eur }} €</td>
|
<td data-sort="{{ l.principal }}">{{ l.principal|eur }} €</td>
|
||||||
<td>{{ l.annual_rate_pct|eur }} %</td>
|
<td>{{ l.annual_rate_pct|eur }} %</td>
|
||||||
<td>{{ l.term_months }} Monate</td>
|
<td data-sort="{{ l.term_months }}">{{ l.term_months }} Monate</td>
|
||||||
<td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td>
|
<td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td>
|
||||||
<td>{{ l.repayment_type|de_label }}</td>
|
<td>{{ l.repayment_type|de_label }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -214,7 +214,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<!-- Hinweis: eine Kredit-Aenderung veraendert den Tilgungsplan; danach
|
<!-- Hinweis: eine Kredit-Aenderung veraendert den Tilgungsplan; danach
|
||||||
muessen betroffene Szenarien neu durchgerechnet werden. -->
|
muessen betroffene Szenarien neu durchgerechnet werden. -->
|
||||||
<tr id="loan-edit-{{ l.id }}" hidden>
|
<tr id="loan-edit-{{ l.id }}" hidden data-sort-follow>
|
||||||
<td colspan="7">
|
<td colspan="7">
|
||||||
<form hx-ext="json-form" hx-patch="/api/loans/{{ l.id }}" hx-swap="none"
|
<form hx-ext="json-form" hx-patch="/api/loans/{{ l.id }}" hx-swap="none"
|
||||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr id="loan-plan-{{ l.id }}" data-sort-follow>
|
||||||
<td colspan="7">
|
<td colspan="7">
|
||||||
<details ontoggle="if(this.open){loadLoanSchedule({{ l.id }})}">
|
<details ontoggle="if(this.open){loadLoanSchedule({{ l.id }})}">
|
||||||
<summary>Tilgungsplan</summary>
|
<summary>Tilgungsplan</summary>
|
||||||
|
|||||||
@@ -22,11 +22,13 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="2">Keine Konten angelegt.</td></tr>
|
<tr><td colspan="2">Keine Konten angelegt.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
<tr class="total-row">
|
<tr class="total-row">
|
||||||
<td><strong>Gesamt</strong></td>
|
<td><strong>Gesamt</strong></td>
|
||||||
<td class="amount {{ 'neg' if total < 0 else '' }}"><strong>{{ total|eur }} €</strong></td>
|
<td class="amount {{ 'neg' if total < 0 else '' }}"><strong>{{ total|eur }} €</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h2>Monatsanfangs-Übersicht</h2>
|
<h2>Monatsanfangs-Übersicht</h2>
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for row in months %}
|
{% for row in months %}
|
||||||
<tr data-month="{{ row['month'].isoformat() }}">
|
<tr data-month="{{ row['month'].isoformat() }}">
|
||||||
<td>{{ row['month'].strftime('%m.%Y') }}</td>
|
<td data-sort="{{ row['month'].isoformat() }}">{{ row['month'].strftime('%m.%Y') }}</td>
|
||||||
{% for account, value in row['values'] %}
|
{% for account, value in row['values'] %}
|
||||||
<td class="amount {{ 'neg' if value < 0 else '' }}">{{ value|eur }} €</td>
|
<td class="amount {{ 'neg' if value < 0 else '' }}">{{ value|eur }} €</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr id="mod-edit-{{ m.id }}" hidden>
|
<tr id="mod-edit-{{ m.id }}" hidden data-sort-follow>
|
||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr id="spi-edit-{{ sp.id }}" hidden>
|
<tr id="spi-edit-{{ sp.id }}" hidden data-sort-follow>
|
||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
|
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
|
||||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<button type="submit">Filtern</button>
|
<button type="submit">Filtern</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<table>
|
<table data-sort-mode="server">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Datum</th><th>Konto</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th>
|
<th>Datum</th><th>Konto</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use strict';
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
let JSDOM = null;
|
||||||
|
try { ({ JSDOM } = require('jsdom')); } catch (e) { /* jsdom fehlt -> skip */ }
|
||||||
|
const SRC = fs.readFileSync(path.join(__dirname, '../../app/static/sort.js'), 'utf8');
|
||||||
|
|
||||||
|
function load(html) {
|
||||||
|
const dom = new JSDOM(`<!doctype html><body>${html}</body>`, { runScripts: 'outside-only' });
|
||||||
|
dom.window.eval(SRC);
|
||||||
|
dom.window.FBSort.init(dom.window.document.body);
|
||||||
|
return dom.window.document;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABLE = `
|
||||||
|
<table id="t"><thead><tr><th>Name</th><th>Betrag</th><th>Aktion</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr id="r1"><td>B</td><td>10,00 €</td><td>x</td><td></td></tr>
|
||||||
|
<tr id="r1-edit" hidden data-sort-follow><td colspan="4">edit1</td></tr>
|
||||||
|
<tr id="r2"><td>A</td><td>-5,00 €</td><td>x</td><td></td></tr>
|
||||||
|
<tr id="r2-edit" hidden data-sort-follow><td colspan="4">edit2</td></tr>
|
||||||
|
<tr id="r3"><td>C</td><td>–</td><td>x</td><td></td></tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot><tr id="sum"><td>Gesamt</td><td>5,00 €</td><td></td><td></td></tr></tfoot>
|
||||||
|
</table>
|
||||||
|
<table data-sort-mode="server" id="s"><thead><tr><th>Datum</th></tr></thead><tbody></tbody></table>`;
|
||||||
|
|
||||||
|
const ids = (doc) => [...doc.querySelectorAll('#t tbody tr')].map((r) => r.id);
|
||||||
|
|
||||||
|
test('Buttons nur an Spalten mit Text, nicht an server-Tabellen', { skip: !JSDOM }, () => {
|
||||||
|
const doc = load(TABLE.replace('<th>Aktion</th>', '<th data-nosort>Aktion</th>'));
|
||||||
|
assert.equal(doc.querySelectorAll('#t .sort-btn').length, 2);
|
||||||
|
assert.equal(doc.querySelectorAll('#s .sort-btn').length, 0);
|
||||||
|
assert.equal(doc.querySelector('#t .sort-btn').textContent, '⇅');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Klick sortiert auf/ab, Folgezeilen wandern mit, Leerwert am Ende, tfoot bleibt', { skip: !JSDOM }, () => {
|
||||||
|
const doc = load(TABLE);
|
||||||
|
const btn = doc.querySelectorAll('#t .sort-btn')[1]; // Betrag
|
||||||
|
btn.click();
|
||||||
|
assert.equal(btn.textContent, '▲');
|
||||||
|
assert.deepEqual(ids(doc), ['r2', 'r2-edit', 'r1', 'r1-edit', 'r3']);
|
||||||
|
btn.click();
|
||||||
|
assert.equal(btn.textContent, '▼');
|
||||||
|
assert.deepEqual(ids(doc), ['r1', 'r1-edit', 'r2', 'r2-edit', 'r3']);
|
||||||
|
assert.equal(doc.querySelector('#t tfoot tr').id, 'sum');
|
||||||
|
const nameBtn = doc.querySelectorAll('#t .sort-btn')[0];
|
||||||
|
nameBtn.click();
|
||||||
|
assert.equal(btn.textContent, '⇅');
|
||||||
|
assert.deepEqual(ids(doc), ['r2', 'r2-edit', 'r1', 'r1-edit', 'r3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('init zweimal erzeugt keine doppelten Buttons', { skip: !JSDOM }, () => {
|
||||||
|
const doc = load(TABLE);
|
||||||
|
doc.defaultView.FBSort.init(doc.body);
|
||||||
|
assert.equal(doc.querySelectorAll('#t .sort-btn').length, 3);
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use strict';
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const S = require('../../app/static/sort.js');
|
||||||
|
|
||||||
|
const items = (vals, ds) => vals.map((v, i) => ({ key: S.sortKey(v, !!ds), index: i, label: v }));
|
||||||
|
const labels = (arr) => arr.map((i) => i.label);
|
||||||
|
|
||||||
|
test('Datum TT.MM.JJJJ', () => {
|
||||||
|
const k = S.sortKey('05.03.2026', false);
|
||||||
|
assert.equal(k.kind, 'date');
|
||||||
|
assert.equal(k.value, 20260305);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deutsche Beträge und Zahlen', () => {
|
||||||
|
assert.equal(S.sortKey('-1.234,56 €', false).value, -1234.56);
|
||||||
|
assert.equal(S.sortKey('5,00 %', false).value, 5);
|
||||||
|
assert.equal(S.sortKey('15', false).kind, 'number');
|
||||||
|
assert.equal(S.sortKey('1.234', false).value, 1234);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data-sort: Punkt-Dezimal und ISO-Datum', () => {
|
||||||
|
assert.equal(S.sortKey('-1234.56', true).value, -1234.56);
|
||||||
|
assert.equal(S.sortKey('2026-06-30', true).kind, 'text');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Leerwerte sind null', () => {
|
||||||
|
assert.equal(S.sortKey('', false), null);
|
||||||
|
assert.equal(S.sortKey(' ', false), null);
|
||||||
|
assert.equal(S.sortKey('–', false), null);
|
||||||
|
assert.equal(S.sortKey(undefined, false), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zahlen numerisch statt alphabetisch', () => {
|
||||||
|
assert.deepEqual(labels(S.sortItems(items(['10,00', '9,00', '-100,00']), 'asc')),
|
||||||
|
['-100,00', '9,00', '10,00']);
|
||||||
|
assert.deepEqual(labels(S.sortItems(items(['10,00', '9,00', '-100,00']), 'desc')),
|
||||||
|
['10,00', '9,00', '-100,00']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Datum chronologisch', () => {
|
||||||
|
assert.deepEqual(labels(S.sortItems(items(['01.02.2026', '15.01.2026', '31.12.2025']), 'asc')),
|
||||||
|
['31.12.2025', '15.01.2026', '01.02.2026']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Leerwerte immer am Ende, beide Richtungen', () => {
|
||||||
|
const v = ['01.02.2026', '–', '15.01.2026', ''];
|
||||||
|
assert.deepEqual(labels(S.sortItems(items(v), 'asc')), ['15.01.2026', '01.02.2026', '–', '']);
|
||||||
|
assert.deepEqual(labels(S.sortItems(items(v), 'desc')), ['01.02.2026', '15.01.2026', '–', '']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gemischte Typen -> Text', () => {
|
||||||
|
assert.equal(S.columnKind([S.sortKey('10,00'), S.sortKey('abc'), null]), 'text');
|
||||||
|
assert.equal(S.columnKind([S.sortKey('10,00'), null]), 'number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Text deutsch (Umlaute, Groß/klein)', () => {
|
||||||
|
assert.deepEqual(labels(S.sortItems(items(['Zahnarzt', 'Ärztin', 'miete']), 'asc')),
|
||||||
|
['Ärztin', 'miete', 'Zahnarzt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stabil bei gleichen Werten', () => {
|
||||||
|
const it = [{ key: S.sortKey('5'), index: 0, label: 'a' }, { key: S.sortKey('5'), index: 1, label: 'b' }];
|
||||||
|
assert.deepEqual(labels(S.sortItems(it, 'desc')), ['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('groupRows: Folgezeilen hängen an der Vorzeile', () => {
|
||||||
|
const rows = [{ id: 'a' }, { id: 'a-edit', f: true }, { id: 'a-plan', f: true },
|
||||||
|
{ id: 'b' }, { id: 'b-edit', f: true }];
|
||||||
|
assert.deepEqual(S.groupRows(rows, (r) => !!r.f).map((g) => g.map((r) => r.id)),
|
||||||
|
[['a', 'a-edit', 'a-plan'], ['b', 'b-edit']]);
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -55,7 +56,10 @@ def test_gui_zeigt_deutsche_betragsformate(client, db):
|
|||||||
db.commit()
|
db.commit()
|
||||||
r = client.get("/")
|
r = client.get("/")
|
||||||
assert "12.345,60" in r.text
|
assert "12.345,60" in r.text
|
||||||
assert "12345.60" not in r.text
|
# Punkt-Dezimal darf nur im maschinenlesbaren data-sort-Attribut
|
||||||
|
# (Ausbaustufe 11, static/sort.js) stehen, nicht im sichtbaren Text.
|
||||||
|
visible = re.sub(r' data-sort="[^"]*"', '', r.text)
|
||||||
|
assert "12345.60" not in visible
|
||||||
|
|
||||||
|
|
||||||
def test_planung_zeigt_deutsche_labels(client, db):
|
def test_planung_zeigt_deutsche_labels(client, db):
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Führt die JS-Tests von static/sort.js aus, falls `node` installiert ist.
|
||||||
|
Auf dem Host fehlt node derzeit - dann skip; das Fable-Gate führt die Tests
|
||||||
|
per Podman aus (siehe Plan A11 Task 4, Fable-Gate)."""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
NODE = shutil.which("node")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(NODE is None, reason="node nicht installiert")
|
||||||
|
def test_sort_js():
|
||||||
|
r = subprocess.run([NODE, "--test", str(ROOT / "tests" / "js")],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
assert r.returncode == 0, r.stdout + r.stderr
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.models.tables import (Account, Category, Loan, PlannedItem,
|
||||||
|
RecurringItem, Scenario, ScenarioModifier,
|
||||||
|
ScenarioPlannedItem)
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client):
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
|
||||||
|
|
||||||
|
def _follow_ids(html):
|
||||||
|
return re.findall(r'<tr id="([a-z]+-(?:edit|plan)-\d+)"[^>]*data-sort-follow', html)
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_bindet_sort_js_ein(client):
|
||||||
|
_login(client)
|
||||||
|
assert '<script src="/static/sort.js"></script>' in client.get("/").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_planung_folgezeilen_markiert(client, db):
|
||||||
|
_login(client)
|
||||||
|
c = Category(name="K")
|
||||||
|
db.add(c)
|
||||||
|
db.flush()
|
||||||
|
db.add_all([
|
||||||
|
RecurringItem(name="R", amount=Decimal("-1.00"), rhythm="monthly", due_day=1, category_id=c.id),
|
||||||
|
PlannedItem(name="P", amount=Decimal("-1.00"), due=date(2030, 1, 1)),
|
||||||
|
Loan(name="L", principal=Decimal("100.00"), annual_rate_pct=Decimal("1"),
|
||||||
|
term_months=2, payout_date=date(2030, 1, 1), repayment_type="annuity"),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
kinds = sorted(i.split("-")[0] + "-" + i.split("-")[1] for i in _follow_ids(client.get("/planung").text))
|
||||||
|
assert kinds == ["loan-edit", "loan-plan", "pln-edit", "rec-edit"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_szenarien_und_admin_folgezeilen_markiert(client, db):
|
||||||
|
_login(client)
|
||||||
|
c = Category(name="K")
|
||||||
|
sc = Scenario(name="S", description="")
|
||||||
|
db.add_all([c, sc])
|
||||||
|
db.flush()
|
||||||
|
db.add_all([
|
||||||
|
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=c.id,
|
||||||
|
kind="percent", value=Decimal("10")),
|
||||||
|
ScenarioPlannedItem(scenario_id=sc.id, name="E", amount=Decimal("-1.00"), due=date(2030, 1, 1)),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
assert sorted(i.rsplit("-", 1)[0] for i in _follow_ids(client.get("/szenarien").text)) == ["mod-edit", "spi-edit"]
|
||||||
|
assert [i.rsplit("-", 1)[0] for i in _follow_ids(client.get("/admin").text)] == ["cat-edit"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_salden_gesamtzeile_im_tfoot(client, db):
|
||||||
|
_login(client)
|
||||||
|
db.add(Account(bank="t", iban="DE-S-1", name="Konto", type="giro"))
|
||||||
|
db.commit()
|
||||||
|
html = client.get("/salden").text
|
||||||
|
assert re.search(r'<tfoot>\s*<tr class="total-row">', html)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_aktionsspalten_ohne_sortierung(client):
|
||||||
|
_login(client)
|
||||||
|
html = client.get("/").text
|
||||||
|
assert "<th data-nosort>Umbenennen</th>" in html
|
||||||
|
assert "<th data-nosort>Anker pflegen</th>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_buchungen_tabelle_ist_serverseitig(client):
|
||||||
|
_login(client)
|
||||||
|
assert 'data-sort-mode="server"' in client.get("/buchungen").text
|
||||||
Reference in New Issue
Block a user