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:
@@ -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.scenarios import list_scenarios as _list_scenarios
|
||||||
from app.routers.transactions import count_transactions, list_transactions, normalize_sort
|
from app.routers.transactions import count_transactions, list_transactions, normalize_sort
|
||||||
from app.services.balances import account_balance, total_balance
|
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.planning_chart import refresh_planning_chart
|
||||||
from app.services.projection_service import refresh_stale_projections
|
from app.services.projection_service import refresh_stale_projections
|
||||||
from app.version import get_version
|
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)
|
recurring = _list_recurring(session=session)
|
||||||
planned = _list_planned(session=session)
|
planned = _list_planned(session=session)
|
||||||
loans = _list_loans(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", {
|
return templates.TemplateResponse(request, "planning.html", {
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
"category_names": {c.id: c.name for c in 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,
|
"loans": loans,
|
||||||
"rhythms": ["monthly", "quarterly", "yearly"],
|
"rhythms": ["monthly", "quarterly", "yearly"],
|
||||||
"repayment_types": ["annuity", "bullet"],
|
"repayment_types": ["annuity", "bullet"],
|
||||||
|
"doppelt_posten": doppelt_posten,
|
||||||
|
"doppelt_kredit": doppelt_kredit,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
})();
|
||||||
@@ -226,3 +226,13 @@ label:has(input:disabled), label:has(select:disabled) {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Doppelzählung Kredit/Fixposten (Ausbaustufe 12). */
|
||||||
|
.warn-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
#doppelzaehlung-dialog {
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<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>
|
<script src="/static/sort.js"></script>
|
||||||
|
<script src="/static/doppelzaehlung.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<nav>
|
||||||
@@ -26,8 +27,18 @@
|
|||||||
<main>
|
<main>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</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>
|
<script>
|
||||||
document.body.addEventListener('htmx:responseError', function (e) {
|
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));
|
alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,6 +83,10 @@
|
|||||||
out[field.name] = field.checked;
|
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');
|
xhr.overrideMimeType('text/json');
|
||||||
return JSON.stringify(out);
|
return JSON.stringify(out);
|
||||||
}
|
}
|
||||||
@@ -82,6 +97,8 @@
|
|||||||
document.getElementById(prefix + '-row-' + id).hidden = editing;
|
document.getElementById(prefix + '-row-' + id).hidden = editing;
|
||||||
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
|
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.FBDoppel) { window.FBDoppel.init(document); }
|
||||||
</script>
|
</script>
|
||||||
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
|
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% 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 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 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>
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% 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 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 data-sort="{{ l.principal }}">{{ l.principal|eur }} €</td>
|
||||||
<td>{{ l.annual_rate_pct|eur }} %</td>
|
<td>{{ l.annual_rate_pct|eur }} %</td>
|
||||||
<td data-sort="{{ l.term_months }}">{{ l.term_months }} Monate</td>
|
<td data-sort="{{ l.term_months }}">{{ l.term_months }} Monate</td>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
'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/doppelzaehlung.js'), 'utf8');
|
||||||
|
|
||||||
|
const HTML = `
|
||||||
|
<form id="f" hx-post="/api/recurring"><input name="name" value="Rate Auto"></form>
|
||||||
|
<dialog id="doppelzaehlung-dialog">
|
||||||
|
<p class="dz-meldung"></p><ul class="dz-konflikte"></ul>
|
||||||
|
<button type="button" id="dz-abbrechen">Abbrechen</button>
|
||||||
|
<button type="button" id="dz-bestaetigen">Trotzdem speichern</button>
|
||||||
|
</dialog>`;
|
||||||
|
const BODY = JSON.stringify({ detail: { code: 'doppelzaehlung_kredit', meldung: 'Erklärung',
|
||||||
|
konflikte: [{ posten: 'Rate Auto', posten_betrag: '-100.00', kredit: 'Auto', kredit_rate: '100.00' }] } });
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
const dom = new JSDOM(`<!doctype html><body>${HTML}</body>`, { runScripts: 'outside-only' });
|
||||||
|
const w = dom.window;
|
||||||
|
const calls = [];
|
||||||
|
w.htmx = { trigger: (el, name) => calls.push([el.id, name]) };
|
||||||
|
const dlg = w.document.getElementById('doppelzaehlung-dialog');
|
||||||
|
dlg.showModal = function () { this.setAttribute('open', ''); };
|
||||||
|
dlg.close = function () { this.removeAttribute('open'); };
|
||||||
|
w.eval(SRC);
|
||||||
|
w.FBDoppel.init(w.document);
|
||||||
|
const form = w.document.getElementById('f');
|
||||||
|
const evt = { detail: { xhr: { status: 409, responseText: BODY }, elt: form } };
|
||||||
|
return { w, dlg, form, evt, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('409 öffnet Dialog mit Erklärung und Konfliktliste', { skip: !JSDOM }, () => {
|
||||||
|
const { w, dlg, evt } = load();
|
||||||
|
assert.equal(w.FBDoppel.handle(evt), true);
|
||||||
|
assert.ok(dlg.hasAttribute('open'));
|
||||||
|
assert.equal(dlg.querySelector('.dz-meldung').textContent, 'Erklärung');
|
||||||
|
assert.equal(dlg.querySelectorAll('.dz-konflikte li').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('andere Fehler werden nicht abgefangen', { skip: !JSDOM }, () => {
|
||||||
|
const { w, form } = load();
|
||||||
|
assert.equal(w.FBDoppel.handle({ detail: { xhr: { status: 422, responseText: '{}' }, elt: form } }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Abbrechen speichert nicht', { skip: !JSDOM }, () => {
|
||||||
|
const { w, dlg, form, evt, calls } = load();
|
||||||
|
w.FBDoppel.handle(evt);
|
||||||
|
w.document.getElementById('dz-abbrechen').click();
|
||||||
|
assert.ok(!dlg.hasAttribute('open'));
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
assert.ok(!form.hasAttribute('data-doppelzaehlung-bestaetigt'));
|
||||||
|
assert.equal(form.querySelector('input').value, 'Rate Auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Trotzdem speichern sendet erneut mit Flag', { skip: !JSDOM }, () => {
|
||||||
|
const { w, dlg, form, evt, calls } = load();
|
||||||
|
w.FBDoppel.handle(evt);
|
||||||
|
w.document.getElementById('dz-bestaetigen').click();
|
||||||
|
assert.ok(!dlg.hasAttribute('open'));
|
||||||
|
assert.equal(form.getAttribute('data-doppelzaehlung-bestaetigt'), '1');
|
||||||
|
assert.deepEqual(calls, [['f', 'submit']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Flag nach Request entfernt', { skip: !JSDOM }, () => {
|
||||||
|
const { w, form, evt } = load();
|
||||||
|
w.FBDoppel.handle(evt);
|
||||||
|
w.document.getElementById('dz-bestaetigen').click();
|
||||||
|
const e = new w.CustomEvent('htmx:afterRequest', { bubbles: true, detail: { elt: form } });
|
||||||
|
form.dispatchEvent(e);
|
||||||
|
assert.ok(!form.hasAttribute('data-doppelzaehlung-bestaetigt'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
'use strict';
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const D = require('../../app/static/doppelzaehlung.js');
|
||||||
|
|
||||||
|
const body = { detail: { code: 'doppelzaehlung_kredit', meldung: 'M',
|
||||||
|
konflikte: [{ posten: 'Rate Auto', posten_betrag: '-1234.50', kredit: 'Auto', kredit_rate: '1234.50' }] } };
|
||||||
|
|
||||||
|
test('parseDetail erkennt nur 409 mit Code', () => {
|
||||||
|
assert.equal(D.parseDetail({ status: 409, responseText: JSON.stringify(body) }).code, 'doppelzaehlung_kredit');
|
||||||
|
assert.equal(D.parseDetail({ status: 409, responseText: '{"detail":"Kategorie existiert bereits"}' }), null);
|
||||||
|
assert.equal(D.parseDetail({ status: 422, responseText: JSON.stringify(body) }), null);
|
||||||
|
assert.equal(D.parseDetail({ status: 409, responseText: 'kein json' }), null);
|
||||||
|
assert.equal(D.parseDetail(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('konfliktText deutsch formatiert', () => {
|
||||||
|
assert.equal(D.konfliktText(body.detail.konflikte[0]),
|
||||||
|
'Posten „Rate Auto“ (-1.234,50 €) ↔ Kredit „Auto“ (Rate 1.234,50 €)');
|
||||||
|
});
|
||||||
@@ -588,3 +588,46 @@ def test_planungs_refresh_fehler_bricht_seiten_nicht(client, monkeypatch):
|
|||||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
for path in ("/", "/planung"):
|
for path in ("/", "/planung"):
|
||||||
assert client.get(path).status_code == 200, path
|
assert client.get(path).status_code == 200, path
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_hat_doppelzaehlungs_dialog(client):
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
html = client.get("/").text
|
||||||
|
assert '<script src="/static/doppelzaehlung.js"></script>' in html
|
||||||
|
assert '<dialog id="doppelzaehlung-dialog">' in html
|
||||||
|
assert 'id="dz-abbrechen" autofocus' in html
|
||||||
|
assert ">Trotzdem speichern</button>" in html
|
||||||
|
assert "FBDoppel.handle(e)" in html
|
||||||
|
assert "doppelzaehlung_bestaetigt = true" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_planung_zeigt_doppelzaehlungs_hinweis(client, db):
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
db.add_all([
|
||||||
|
Loan(name="Auto", principal=Decimal("1200.00"), annual_rate_pct=Decimal("0"),
|
||||||
|
term_months=12, payout_date=date(2026, 1, 15), repayment_type="annuity"),
|
||||||
|
RecurringItem(name="Rate Auto", amount=Decimal("-100.00"), rhythm="monthly", due_day=15),
|
||||||
|
RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
html = client.get("/planung").text
|
||||||
|
assert "⚠ zählt evtl. doppelt mit Kredit „Auto“" in html
|
||||||
|
assert "⚠ zählt evtl. doppelt mit Posten „Rate Auto“" in html
|
||||||
|
assert html.count("⚠ zählt evtl. doppelt") == 2
|
||||||
|
assert '<td data-sort="Rate Auto">' in html
|
||||||
|
assert '<td data-sort="Auto">' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_planung_ohne_konflikt_kein_hinweis(client, db):
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
db.add(RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1))
|
||||||
|
db.commit()
|
||||||
|
assert "zählt evtl. doppelt" not in client.get("/planung").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_vorschlag_formular_nutzt_json_form_post(client):
|
||||||
|
# Review Focus 5: „Vorschlag übernehmen" läuft über denselben Warnweg.
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
src = Path("app/templates/planning.html").read_text(encoding="utf-8")
|
||||||
|
assert re.search(r'class="inline-form" hx-ext="json-form" hx-post="/api/recurring"', src)
|
||||||
|
|||||||
Reference in New Issue
Block a user