diff --git a/finance/app/routers/gui.py b/finance/app/routers/gui.py index 0f2fe6e..36753f2 100644 --- a/finance/app/routers/gui.py +++ b/finance/app/routers/gui.py @@ -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, }) diff --git a/finance/app/static/doppelzaehlung.js b/finance/app/static/doppelzaehlung.js new file mode 100644 index 0000000..cb63243 --- /dev/null +++ b/finance/app/static/doppelzaehlung.js @@ -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 . „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; } +})(); diff --git a/finance/app/static/style.css b/finance/app/static/style.css index a999fc4..55b7e93 100644 --- a/finance/app/static/style.css +++ b/finance/app/static/style.css @@ -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; +} diff --git a/finance/app/templates/base.html b/finance/app/templates/base.html index d91bb4b..663e4ff 100644 --- a/finance/app/templates/base.html +++ b/finance/app/templates/base.html @@ -7,6 +7,7 @@ +