feat: Warnfenster und Planungs-Hinweis bei möglicher Doppelzählung (A12-T4)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 07:35:16 +02:00
co-authored by Claude Opus 5.5
parent 9e856ec655
commit e665180b1f
8 changed files with 264 additions and 2 deletions
+8
View File
@@ -27,6 +27,7 @@ from app.routers.planning import recurring_suggestions as _recurring_suggestions
from app.routers.scenarios import list_scenarios as _list_scenarios
from app.routers.transactions import count_transactions, list_transactions, normalize_sort
from app.services.balances import account_balance, total_balance
from app.services.double_count import all_conflicts
from app.services.planning_chart import refresh_planning_chart
from app.services.projection_service import refresh_stale_projections
from app.version import get_version
@@ -337,6 +338,11 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
recurring = _list_recurring(session=session)
planned = _list_planned(session=session)
loans = _list_loans(session=session)
doppelt_posten: dict[int, list[str]] = {}
doppelt_kredit: dict[int, list[str]] = {}
for k in all_conflicts(session):
doppelt_posten.setdefault(k.posten_id, []).append(k.kredit_name)
doppelt_kredit.setdefault(k.kredit_id, []).append(k.posten_name)
return templates.TemplateResponse(request, "planning.html", {
"categories": categories,
"category_names": {c.id: c.name for c in categories},
@@ -347,6 +353,8 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
"loans": loans,
"rhythms": ["monthly", "quarterly", "yearly"],
"repayment_types": ["annuity", "bullet"],
"doppelt_posten": doppelt_posten,
"doppelt_kredit": doppelt_kredit,
})
+89
View File
@@ -0,0 +1,89 @@
/* Warnfenster bei möglicher Doppelzählung Kredit ↔ Fixposten (Ausbaustufe 12).
*
* Die API antwortet mit 409 und detail.code "doppelzaehlung_kredit", wenn ein
* Posten vermutlich einer Kreditrate entspricht. handle() fängt genau diesen
* Fall im globalen htmx:responseError-Handler ab (alle anderen Fehler laufen
* weiter über den alert) und zeigt den <dialog>. „Trotzdem speichern" markiert
* das auslösende Formular (data-doppelzaehlung-bestaetigt="1") und sendet es
* erneut; die json-form-Extension ergänzt dann doppelzaehlung_bestaetigt=true.
* Nach jedem Request wird die Markierung entfernt, damit ein späteres
* Speichern die Warnung nicht stillschweigend umgeht.
*/
(function () {
'use strict';
var CODE = 'doppelzaehlung_kredit';
var ATTR = 'data-doppelzaehlung-bestaetigt';
var fmt = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function parseDetail(xhr) {
if (!xhr || xhr.status !== 409) { return null; }
var body;
try { body = JSON.parse(xhr.responseText); } catch (e) { return null; }
var d = body && body.detail;
return d && d.code === CODE ? d : null;
}
function eur(s) { return fmt.format(parseFloat(s)) + ' €'; }
function konfliktText(k) {
return 'Posten „' + k.posten + '“ (' + eur(k.posten_betrag) + ') ↔ Kredit „'
+ k.kredit + '“ (Rate ' + eur(k.kredit_rate) + ')';
}
function handle(evt) {
var d = parseDetail(evt && evt.detail && evt.detail.xhr);
var elt = evt && evt.detail && evt.detail.elt;
var form = elt && elt.closest ? elt.closest('form') : null;
if (!d || !form) { return false; }
var doc = form.ownerDocument;
var dlg = doc.getElementById('doppelzaehlung-dialog');
if (!dlg) { return false; }
dlg.querySelector('.dz-meldung').textContent = d.meldung;
var list = dlg.querySelector('.dz-konflikte');
list.textContent = '';
(d.konflikte || []).forEach(function (k) {
var li = doc.createElement('li');
li.textContent = konfliktText(k);
list.appendChild(li);
});
dlg.fbForm = form;
dlg.showModal();
return true;
}
function abbrechen(dlg) {
dlg.fbForm = null;
dlg.close();
}
function bestaetigen(dlg) {
var form = dlg.fbForm;
dlg.fbForm = null;
dlg.close();
if (!form) { return; }
form.setAttribute(ATTR, '1');
form.ownerDocument.defaultView.htmx.trigger(form, 'submit');
}
function init(doc) {
var dlg = doc.getElementById('doppelzaehlung-dialog');
if (!dlg) { return; }
doc.getElementById('dz-abbrechen').addEventListener('click', function () { abbrechen(dlg); });
doc.getElementById('dz-bestaetigen').addEventListener('click', function () { bestaetigen(dlg); });
doc.body.addEventListener('htmx:afterRequest', function (e) {
var elt = e.detail && e.detail.elt;
var form = elt && elt.closest ? elt.closest('form') : null;
if (form && form.getAttribute(ATTR) === '1') {
// Erst nach dem Request mit Flag entfernen: der 409-Request selbst
// hat die Markierung nie gesetzt.
form.removeAttribute(ATTR);
}
});
}
var api = { parseDetail: parseDetail, konfliktText: konfliktText, handle: handle,
abbrechen: abbrechen, bestaetigen: bestaetigen, init: init };
if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
if (typeof window !== 'undefined') { window.FBDoppel = api; }
})();
+10
View File
@@ -226,3 +226,13 @@ label:has(input:disabled), label:has(select:disabled) {
color: inherit;
font-weight: bold;
}
/* Doppelzählung Kredit/Fixposten (Ausbaustufe 12). */
.warn-hint {
display: block;
font-size: 0.8em;
color: #b45309;
}
#doppelzaehlung-dialog {
max-width: 32rem;
}
+17
View File
@@ -7,6 +7,7 @@
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
<script src="/static/sort.js"></script>
<script src="/static/doppelzaehlung.js"></script>
</head>
<body>
<nav>
@@ -26,8 +27,18 @@
<main>
{% block content %}{% endblock %}
</main>
<dialog id="doppelzaehlung-dialog">
<h2>Mögliche Doppelzählung</h2>
<p class="dz-meldung"></p>
<ul class="dz-konflikte"></ul>
<p>Trotzdem speichern?</p>
<button type="button" id="dz-abbrechen" autofocus>Abbrechen</button>
<button type="button" id="dz-bestaetigen">Trotzdem speichern</button>
</dialog>
<script>
document.body.addEventListener('htmx:responseError', function (e) {
// Mögliche Doppelzählung Kredit/Fixposten: eigenes Warnfenster statt alert.
if (window.FBDoppel && window.FBDoppel.handle(e)) { return; }
alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status));
});
@@ -72,6 +83,10 @@
out[field.name] = field.checked;
});
}
// Bestätigte Doppelzählung (Warnfenster „Trotzdem speichern"):
if (elt.getAttribute && elt.getAttribute('data-doppelzaehlung-bestaetigt') === '1') {
out.doppelzaehlung_bestaetigt = true;
}
xhr.overrideMimeType('text/json');
return JSON.stringify(out);
}
@@ -82,6 +97,8 @@
document.getElementById(prefix + '-row-' + id).hidden = editing;
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
}
if (window.FBDoppel) { window.FBDoppel.init(document); }
</script>
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
</body>
+2 -2
View File
@@ -12,7 +12,7 @@
<tbody>
{% for r in recurring %}
<tr id="rec-row-{{ r.id }}">
<td>{{ r.name }}</td>
<td data-sort="{{ r.name }}">{{ r.name }}{% for kn in doppelt_posten.get(r.id, []) %}<span class="warn-hint">⚠ zählt evtl. doppelt mit Kredit „{{ kn }}“</span>{% endfor %}</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.due_day }}</td>
@@ -197,7 +197,7 @@
<tbody>
{% for l in loans %}
<tr id="loan-row-{{ l.id }}">
<td>{{ l.name }}</td>
<td data-sort="{{ l.name }}">{{ l.name }}{% for pn in doppelt_kredit.get(l.id, []) %}<span class="warn-hint">⚠ zählt evtl. doppelt mit Posten „{{ pn }}“</span>{% endfor %}</td>
<td data-sort="{{ l.principal }}">{{ l.principal|eur }} €</td>
<td>{{ l.annual_rate_pct|eur }} %</td>
<td data-sort="{{ l.term_months }}">{{ l.term_months }} Monate</td>