From 23efce3196aa8984e87a1452eb985f4340c84b7a Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 18 Aug 2026 13:12:12 +0200 Subject: [PATCH] Fix #161: Restposten aus Tech-Debt-Analyse (O-CHAT.html, fints.php/bankimport.php, Request-Klasse) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (a) public/O-CHAT.html nach BEISPIELE/ verschoben, Test 93 hält den Webroot sauber. (b) bank/fints.php in Teil-Views + public/static/js/fints.js aufgeteilt. (c) settings/bankimport.php in Teil-Views + public/static/js/bankimport.js aufgeteilt. (d) src/Request.php angelegt, BankController als erster Controller umgestellt. --- {public => BEISPIELE}/O-CHAT.html | 0 Changes.md | 7 + public/static/js/bankimport.js | 293 +++++++ public/static/js/fints.js | 568 ++++++++++++ src/Controllers/BankController.php | 105 +-- src/Request.php | 71 ++ src/Views/bank/_connection_grid.php | 75 ++ src/Views/bank/_modal_account_select.php | 14 + src/Views/bank/_modal_edit_connection.php | 128 +++ src/Views/bank/_modal_new_connection.php | 162 ++++ src/Views/bank/_modal_run_log.php | 17 + src/Views/bank/_modal_tan_input.php | 21 + src/Views/bank/fints.php | 998 +--------------------- src/Views/settings/_format_card.php | 181 ++++ src/Views/settings/_suggestion_card.php | 172 ++++ src/Views/settings/bankimport.php | 652 +------------- tests/93_WebrootSauberkeitTest.php | 38 + tests/94_RequestHelperTest.php | 96 +++ tests/95_FinTsViewStrukturTest.php | 78 ++ tests/96_BankimportViewStrukturTest.php | 76 ++ 20 files changed, 2066 insertions(+), 1686 deletions(-) rename {public => BEISPIELE}/O-CHAT.html (100%) create mode 100644 public/static/js/bankimport.js create mode 100644 public/static/js/fints.js create mode 100644 src/Request.php create mode 100644 src/Views/bank/_connection_grid.php create mode 100644 src/Views/bank/_modal_account_select.php create mode 100644 src/Views/bank/_modal_edit_connection.php create mode 100644 src/Views/bank/_modal_new_connection.php create mode 100644 src/Views/bank/_modal_run_log.php create mode 100644 src/Views/bank/_modal_tan_input.php create mode 100644 src/Views/settings/_format_card.php create mode 100644 src/Views/settings/_suggestion_card.php create mode 100644 tests/93_WebrootSauberkeitTest.php create mode 100644 tests/94_RequestHelperTest.php create mode 100644 tests/95_FinTsViewStrukturTest.php create mode 100644 tests/96_BankimportViewStrukturTest.php diff --git a/public/O-CHAT.html b/BEISPIELE/O-CHAT.html similarity index 100% rename from public/O-CHAT.html rename to BEISPIELE/O-CHAT.html diff --git a/Changes.md b/Changes.md index b2cb9fa..d2a47be 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,13 @@ --- +## 2026-08-18 – Restposten: O-CHAT.html, fints.php/bankimport.php, Request-Abstraktion (#161) + +- **(a) `public/O-CHAT.html`:** Testartefakt einer eigenständigen Ollama-Chat-Oberfläche wurde von Apache direkt ausgeliefert (`.htaccess` routet nur nicht-existierende Dateien auf `index.php`) und lief damit an der Authentifizierung vorbei. Nach `BEISPIELE/O-CHAT.html` verschoben (gitignored, wird nicht ausgeliefert). **`tests/93_WebrootSauberkeitTest.php`** (neu) hält `public/` dauerhaft sauber. +- **(b) `src/Views/bank/fints.php`** (986 → unter 300 Zeilen): Markup in Teil-Views (`_connection_grid.php`, `_modal_new_connection.php`, `_modal_edit_connection.php`, `_modal_account_select.php`, `_modal_tan_input.php`, `_modal_run_log.php`), die 517 Zeilen Inline-JavaScript nach `public/static/js/fints.js`. Die Verbindungsliste wird nicht mehr per PHP-Interpolation ins Script geschrieben, sondern über `data-connections` auf einem eigenen Datencontainer-Element gelesen. Keine Verhaltensänderung am FinTS-Ablauf (TAN-Eingabe, Kontoauswahl, Drag&Drop-Sortierung, Abbruch). +- **(c) `src/Views/settings/bankimport.php`** (797 → unter 300 Zeilen): Markup in Teil-Views (`_suggestion_card.php`, `_format_card.php`), die 293 Zeilen JavaScript nach `public/static/js/bankimport.js`. Datumsformat-Optionen für die Inline-Bearbeitung wandern über `data-date-format-options` statt per `json_encode()` direkt ins Script. +- **(d) `src/Request.php`** (neu): schlanke statische Hilfsklasse für Zugriffe auf `$_POST`/`$_GET` – `post()`, `postInt()`, `postFloat()` (deutsches Dezimalkomma), `postDate()` (liefert `null` bei ungültigem Format, normalisiert auf `Y-m-d`), `get()`, `has()`. Kein Framework, keine neue Abhängigkeit. `BankController` ist als erster Controller umgestellt (180 → 126 direkte `$_POST`/`$_GET`-Zugriffe in `src/Controllers/`); verbliebene Array-Zugriffe (`$_GET` als Filter-Array, `dup_idx[]`, `computeFilterFromPost($_POST)`) bleiben unverändert, da Request nur einzelne Werte kapselt. Bisheriges Verhalten bei fehlenden/ungültigen Werten bleibt exakt erhalten. Die übrigen Controller folgen schrittweise in eigenen Tickets. **`tests/94_RequestHelperTest.php`** (neu). + ## 2026-08-18 – Modale Dialoge: Label und Feld in einer Zeile, Hilfetexte als Tooltip (#189) - Einige Dialoge (v.a. „Neue Direktverbindung"/„Verbindung bearbeiten" unter Bank, „Rechnung per E-Mail senden"/„Zahlungen" unter Rechnungen) waren durch zweizeilige Formularfelder (Bezeichnung über dem Feld) höher als ein Standardbildschirm und mussten gescrollt werden. diff --git a/public/static/js/bankimport.js b/public/static/js/bankimport.js new file mode 100644 index 0000000..8cbe12f --- /dev/null +++ b/public/static/js/bankimport.js @@ -0,0 +1,293 @@ +'use strict'; + +(function () { + const DATE_FORMAT_OPTIONS = JSON.parse(document.getElementById('bankimport-data').dataset.dateFormatOptions); + + function badgeTextColor(hex) { + const r = parseInt(hex.slice(1,3),16); + const g = parseInt(hex.slice(3,5),16); + const b = parseInt(hex.slice(5,7),16); + return (0.299*r + 0.587*g + 0.114*b) > 128 ? '#1e293b' : '#ffffff'; + } + + function updatePreview(colorInput, hex) { + const preview = colorInput.closest('div')?.querySelector('.bi-kuerzel-preview'); + if (!preview) return; + preview.style.background = hex; + preview.style.color = badgeTextColor(hex); + } + + // ── Hilfsfunktionen ────────────────────────────────────────────── + + function getFormatId(cell) { + return cell.closest('[data-format-id]')?.dataset.formatId; + } + + function showError(cell, msg) { + cell.classList.add('bi-cell--error'); + let err = cell.parentElement.querySelector('.bi-error-msg'); + if (!err) { err = document.createElement('span'); err.className = 'bi-error-msg'; cell.after(err); } + err.textContent = msg; + setTimeout(() => { cell.classList.remove('bi-cell--error'); err.remove(); }, 3000); + } + + async function saveField(formatId, field, value) { + const body = new URLSearchParams({ field, value: value ?? '' }); + const res = await fetch(`/settings/bankimport/${formatId}/field`, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + return res.json(); + } + + function displayForCell(type, value, field) { + if (type === 'number' || type === 'nullint') { + if (value === '' || value === null || value === undefined) { + return '–'; + } + return `${value}`; + } + if (type === 'char' || type === 'char0') { + if (value === '') return 'keines'; + return `${value.replace(/&/g,'&').replace(//g,'>')}`; + } + if (type === 'dateformat') { + const example = DATE_FORMAT_OPTIONS[value] || '?'; + return `${value}→ ${example}`; + } + if (type === 'template') { + if (!value) return 'nicht gesetzt'; + const esc = value.replace(/&/g,'&').replace(//g,'>'); + return `${esc}`; + } + if (type === 'text') { + return value.replace(/&/g,'&').replace(//g,'>'); + } + return value; + } + + // ── Klick-Handler für .bi-cell ─────────────────────────────────── + document.addEventListener('click', (e) => { + const cell = e.target.closest('.bi-cell'); + if (!cell || cell.querySelector('input,select')) return; + + const field = cell.dataset.field; + const type = cell.dataset.type; + const rawValue = cell.dataset.value ?? ''; + const formatId = getFormatId(cell); + if (!formatId) return; + + const originalHTML = cell.innerHTML; + cell.innerHTML = ''; + cell.classList.remove('bi-cell--error'); + + let el; + + if (type === 'number' || type === 'nullint') { + el = document.createElement('input'); + el.type = 'number'; + el.min = cell.dataset.min ?? '0'; + el.max = cell.dataset.max ?? '99'; + el.value = rawValue; + } else if (type === 'char') { + el = document.createElement('input'); + el.type = 'text'; + el.maxLength = 1; + el.value = rawValue; + } else if (type === 'char0') { + el = document.createElement('input'); + el.type = 'text'; + el.maxLength = 1; + el.value = rawValue; + el.placeholder = '(leer)'; + el.style.width = '70px'; + } else if (type === 'dateformat') { + el = document.createElement('select'); + for (const [fmt, example] of Object.entries(DATE_FORMAT_OPTIONS)) { + const opt = document.createElement('option'); + opt.value = fmt; + opt.textContent = `${fmt} → ${example}`; + if (fmt === rawValue) opt.selected = true; + el.appendChild(opt); + } + } else if (type === 'template') { + el = document.createElement('input'); + el.type = 'text'; + el.value = rawValue; + el.style.width = '100%'; + el.style.boxSizing = 'border-box'; + } else { + el = document.createElement('input'); + el.type = 'text'; + el.value = rawValue; + el.style.width = '180px'; + } + + cell.appendChild(el); + el.focus(); + if (el.tagName === 'INPUT' && el.type === 'text') el.select(); + + let done = false; + + async function commit() { + if (done) return; + done = true; + + const newVal = el.tagName === 'SELECT' ? el.value : el.value; + cell.classList.add('bi-cell--saving'); + cell.innerHTML = ''; + + let json; + try { + json = await saveField(formatId, field, newVal); + } catch (_) { + json = { error: 'Verbindungsfehler.' }; + } + + cell.classList.remove('bi-cell--saving'); + + if (json.error) { + cell.innerHTML = originalHTML; + showError(cell, json.error); + return; + } + + cell.dataset.value = newVal; + cell.innerHTML = displayForCell(type, newVal, field); + + // Formatname im Card-Header synchronisieren + if (field === 'name') { + const title = cell.closest('.card')?.querySelector('.card-title'); + if (title) title.dataset.value = newVal; + } + // Kürzel-Vorschau-Badge aktualisieren + if (field === 'kuerzel') { + const preview = cell.closest('div')?.querySelector('.bi-kuerzel-preview'); + if (preview) preview.textContent = newVal; + } + } + + function cancel() { + if (done) return; + done = true; + cell.innerHTML = originalHTML; + } + + if (el.tagName === 'SELECT') { + el.addEventListener('change', commit); + el.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { ev.stopPropagation(); cancel(); } }); + el.addEventListener('blur', () => { if (!done) commit(); }); + } else { + el.addEventListener('blur', commit); + el.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { ev.preventDefault(); el.blur(); } + if (ev.key === 'Escape') { ev.preventDefault(); cancel(); } + }); + } + }); + + // ── Colorpicker für farbe ──────────────────────────────────────── + document.addEventListener('input', (e) => { + const ci = e.target.closest('.bi-color'); + if (ci) updatePreview(ci, ci.value); + }); + + document.addEventListener('change', async (e) => { + const ci = e.target.closest('.bi-color'); + if (!ci) return; + + const formatId = getFormatId(ci); + const field = ci.dataset.field; + const value = ci.value; + ci.disabled = true; + + let json; + try { json = await saveField(formatId, field, value); } + catch (_) { json = { error: 'Verbindungsfehler.' }; } + + ci.disabled = false; + + if (json.error) { + showError(ci, json.error); + } else { + updatePreview(ci, value); + } + }); + + // ── Format löschen ────────────────────────────────────────────── + document.addEventListener('click', async (e) => { + const btn = e.target.closest('.bi-delete-format'); + if (!btn) return; + + const formatId = btn.dataset.formatId; + const formatName = btn.dataset.formatName; + + if (!confirm(`Format „${formatName}" wirklich löschen?`)) return; + + btn.disabled = true; + + let json; + try { + const res = await fetch(`/settings/bankimport/${formatId}/delete`, { method: 'POST' }); + json = await res.json(); + } catch (_) { + json = { error: 'Verbindungsfehler.' }; + } + + if (json.error) { + alert(json.error); + btn.disabled = false; + return; + } + + btn.closest('.card').remove(); + }); + + // ── Vorschlags-Card verwerfen ──────────────────────────────────── + const dismissBtn = document.getElementById('suggestion-dismiss'); + if (dismissBtn) { + dismissBtn.addEventListener('click', async () => { + dismissBtn.disabled = true; + try { + await fetch('/settings/bankimport/dismiss-suggestion', { method: 'POST' }); + } catch (_) {} + const card = document.getElementById('suggestion-card'); + if (card) card.remove(); + }); + } + + // ── Checkboxen für has_header_row ──────────────────────────────── + document.addEventListener('change', async (e) => { + const cb = e.target.closest('.bi-checkbox'); + if (!cb) return; + + const label = cb.closest('.bi-checkbox-label'); + const textSpan = label?.querySelector('.bi-checkbox-text'); + const field = cb.dataset.field; + const formatId = getFormatId(cb); + if (!formatId) return; + + const newVal = cb.checked ? '1' : '0'; + cb.disabled = true; + + let json; + try { + json = await saveField(formatId, field, newVal); + } catch (_) { + json = { error: 'Verbindungsfehler.' }; + } + + cb.disabled = false; + + if (json.error) { + cb.checked = !cb.checked; // Revert + if (textSpan) textSpan.textContent = cb.checked ? 'Ja' : 'Nein'; + alert(json.error); + return; + } + + if (textSpan) textSpan.textContent = cb.checked ? 'Ja' : 'Nein'; + }); + +})(); diff --git a/public/static/js/fints.js b/public/static/js/fints.js new file mode 100644 index 0000000..6f5da56 --- /dev/null +++ b/public/static/js/fints.js @@ -0,0 +1,568 @@ +'use strict'; + +(function () { + const connections = JSON.parse(document.getElementById('fints-data').dataset.connections); + const csrfToken = (document.querySelector('meta[name="csrf-token"]') || {}).content || ''; + + // ── NEU-Modal ──────────────────────────────────────────────── + const modalNew = document.getElementById('modal-new-connection'); + const btnNew = document.getElementById('btn-new-connection'); + const btnClose = document.getElementById('btn-modal-close'); + const btnCancel = document.getElementById('btn-modal-cancel'); + const selectNew = document.getElementById('fc-provider'); + const apiNew = document.getElementById('api-fields'); + const fintsNew = document.getElementById('fints-fields'); + const csvNew = document.getElementById('csv-fields'); + const newBankWrap = document.getElementById('fc-new-bank-wrap'); + const newBankInput = document.getElementById('fc-new-bank-name'); + + function openNew() { modalNew.style.display = 'flex'; } + function closeNew() { modalNew.style.display = 'none'; } + + if (btnNew) btnNew.addEventListener('click', openNew); + if (btnClose) btnClose.addEventListener('click', closeNew); + if (btnCancel) btnCancel.addEventListener('click', closeNew); + modalNew.addEventListener('click', (e) => { if (e.target === modalNew) closeNew(); }); + + // ── Bankleitzahlen-Verzeichnis: Suche + Formular vorbefüllen (Fix #187) ── + (function () { + const blzField = document.getElementById('fc-blz'); + const urlField = document.getElementById('fc-fints-url'); + const searchInput = document.getElementById('fc-blz-search'); + const resultsBox = document.getElementById('fc-blz-results'); + if (!searchInput || !resultsBox) return; + + function esc(str) { + return String(str ?? '').replace(/&/g,'&').replace(//g,'>'); + } + + let debounceTimer = null; + searchInput.addEventListener('input', () => { + clearTimeout(debounceTimer); + const q = searchInput.value.trim(); + if (q.length < 2) { resultsBox.style.display = 'none'; resultsBox.innerHTML = ''; return; } + debounceTimer = setTimeout(async () => { + let rows; + try { + const res = await fetch('/bankinstitute/search?q=' + encodeURIComponent(q)); + rows = await res.json(); + } catch (_) { rows = []; } + + if (!Array.isArray(rows) || rows.length === 0) { + resultsBox.innerHTML = '
Keine Treffer
'; + resultsBox.style.display = 'block'; + return; + } + + resultsBox.innerHTML = rows.map((r, i) => ` +
+ ${esc(r.blz)} ${esc(r.name)} · ${esc(r.ort)} + ${r.pin_tan_url ? '' : ' (kein PIN/TAN-Zugang)'} +
+ `).join(''); + resultsBox.style.display = 'block'; + + resultsBox.querySelectorAll('.fc-blz-result').forEach((el) => { + el.addEventListener('click', () => { + const row = rows[parseInt(el.dataset.idx, 10)]; + blzField.value = row.blz; + urlField.value = row.pin_tan_url || ''; + resultsBox.style.display = 'none'; + searchInput.value = ''; + }); + }); + }, 300); + }); + })(); + + function syncTypFields(typ) { + apiNew.style.display = (typ === 'API') ? 'block' : 'none'; + fintsNew.style.display = (typ === 'FinTS') ? 'block' : 'none'; + } + + selectNew.addEventListener('change', () => { + const opt = selectNew.options[selectNew.selectedIndex]; + const isNew = opt.value === '__new__'; + const isCsv = opt.value === '__csv__'; + newBankWrap.style.display = isNew ? 'block' : 'none'; + newBankInput.required = isNew; + csvNew.style.display = isCsv ? 'block' : 'none'; + if (isNew) { + const selected = modalNew.querySelector('input[name="new_bank_typ"]:checked'); + syncTypFields(selected ? selected.value : 'FinTS'); + } else if (isCsv) { + syncTypFields(''); + } else { + syncTypFields(opt.dataset.typ); + } + }); + + modalNew.querySelectorAll('input[name="new_bank_typ"]').forEach(radio => { + radio.addEventListener('change', () => { + if (selectNew.value === '__new__') { + syncTypFields(radio.value); + } + }); + }); + + // ── Bearbeiten-Modal ───────────────────────────────────────── + const modalEdit = document.getElementById('modal-edit-connection'); + const btnEditClose = document.getElementById('btn-edit-close'); + const btnEditCancel= document.getElementById('btn-edit-cancel'); + const formEdit = document.getElementById('form-edit-connection'); + const formDelete = document.getElementById('form-edit-delete'); + const btnEditDelete = document.getElementById('btn-edit-delete'); + const editName = document.getElementById('edit-provider-name'); + const editBez = document.getElementById('edit-bezeichnung'); + const editApiFields = document.getElementById('edit-api-fields'); + const editFintsFields = document.getElementById('edit-fints-fields'); + const editAppName = document.getElementById('edit-app-name'); + const editClientId = document.getElementById('edit-client-id'); + const editSecret = document.getElementById('edit-secret'); + const editSandbox = document.getElementById('edit-sandbox'); + const editKuerzel = document.getElementById('edit-kuerzel'); + const editFarbe = document.getElementById('edit-farbe'); + const editBlz = document.getElementById('edit-blz'); + const editBenutzer = document.getElementById('edit-benutzerkennung'); + const editPin = document.getElementById('edit-pin'); + const editFintsUrl = document.getElementById('edit-fints-url'); + const editTanModeId = document.getElementById('edit-tan-mode-id'); + const editAccountList = document.getElementById('edit-account-list'); + const editResetAcc = document.getElementById('edit-reset-accounts'); + const editFetchRangeApi = document.getElementById('edit-fetch-range-api'); + const editFetchRangeFints= document.getElementById('edit-fetch-range-fints'); + + function closeEdit() { modalEdit.style.display = 'none'; } + + if (btnEditClose) btnEditClose.addEventListener('click', closeEdit); + if (btnEditCancel) btnEditCancel.addEventListener('click', closeEdit); + modalEdit.addEventListener('click', (e) => { if (e.target === modalEdit) closeEdit(); }); + + btnEditDelete.addEventListener('click', () => { + if (!confirm('Verbindung wirklich löschen?')) return; + formDelete.submit(); + }); + + // ── Abruf-Modal ────────────────────────────────────────────── + const modalRun = document.getElementById('modal-run-log'); + const runLogArea = document.getElementById('run-log-area'); + const runTitle = document.getElementById('run-log-title'); + const runSpinner = document.getElementById('run-log-spinner'); + const btnRunClose= document.getElementById('btn-run-close'); + let activeEs = null; + let runWasOk = false; + + function closeRun() { + if (activeEs) { activeEs.close(); activeEs = null; } + if (runWasOk) { + window.location.href = '/bank'; + return; + } + modalRun.style.display = 'none'; + runLogArea.textContent = ''; + } + + btnRunClose.addEventListener('click', closeRun); + modalRun.addEventListener('click', (e) => { if (e.target === modalRun && !btnRunClose.disabled) closeRun(); }); + + // ── Konto-Auswahl-Modal ────────────────────────────────────── + const modalAcc = document.getElementById('modal-account-select'); + const accList = document.getElementById('account-list'); + const accError = document.getElementById('account-error'); + const btnAccSubmit = document.getElementById('btn-account-submit'); + const btnAccCancel = document.getElementById('btn-account-cancel'); + let currentAccToken = null; + + function openAccountModal(payload) { + currentAccToken = payload.token; + accList.innerHTML = ''; + accError.style.display = 'none'; + payload.accounts.forEach((acc) => { + const row = document.createElement('label'); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '.5rem'; + row.style.cursor = 'pointer'; + row.style.padding = '.35rem .5rem'; + row.style.border = '1px solid #e2e8f0'; + row.style.borderRadius = 'var(--radius)'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; cb.value = acc.id; cb.checked = true; + cb.style.margin = '0'; + const label = document.createElement('span'); + label.textContent = acc.label; + row.appendChild(cb); + row.appendChild(label); + accList.appendChild(row); + }); + modalAcc.style.display = 'flex'; + } + function closeAccountModal() { + modalAcc.style.display = 'none'; + currentAccToken = null; + } + + btnAccCancel.addEventListener('click', () => { + closeAccountModal(); + if (activeEs) { activeEs.close(); activeEs = null; } + runTitle.textContent = 'Abruf abgebrochen'; + runSpinner.textContent = '✗ abgebrochen'; + btnRunClose.disabled = false; + }); + + btnAccSubmit.addEventListener('click', async () => { + const ids = Array.from(accList.querySelectorAll('input[type=checkbox]:checked')).map(cb => cb.value); + if (ids.length === 0) { + accError.textContent = 'Mindestens ein Konto auswählen.'; + accError.style.display = 'block'; + return; + } + btnAccSubmit.disabled = true; + try { + const form = new FormData(); + form.append('token', currentAccToken); + form.append('ids', JSON.stringify(ids)); + const res = await fetch('/bank/direkt/' + currentSourceId + '/accounts', { method: 'POST', body: form }); + const data = await res.json(); + if (!res.ok || data.error) { + accError.textContent = data.error || 'Übermittlung fehlgeschlagen.'; + accError.style.display = 'block'; + return; + } + closeAccountModal(); + } catch (e) { + accError.textContent = 'Netzwerkfehler: ' + e.message; + accError.style.display = 'block'; + } finally { + btnAccSubmit.disabled = false; + } + }); + + // ── TAN-Modal ──────────────────────────────────────────────── + const modalTan = document.getElementById('modal-tan-input'); + const tanContext = document.getElementById('tan-context'); + const tanChallenge = document.getElementById('tan-challenge'); + const tanImageWrap = document.getElementById('tan-image-wrap'); + const tanMedium = document.getElementById('tan-medium'); + const tanInput = document.getElementById('tan-input'); + const tanError = document.getElementById('tan-error'); + const btnTanSubmit = document.getElementById('btn-tan-submit'); + const btnTanCancel = document.getElementById('btn-tan-cancel'); + let currentSourceId = null; + let currentTanToken = null; + + function openTanModal(payload) { + currentTanToken = payload.token; + tanContext.textContent = payload.context ? 'Konto: ' + payload.context : ''; + tanChallenge.textContent = payload.challenge || ''; + tanMedium.textContent = payload.mediumName ? 'TAN-Medium: ' + payload.mediumName : ''; + tanImageWrap.innerHTML = ''; + if (payload.imageData && payload.imageMime) { + const img = new Image(); + img.src = 'data:' + payload.imageMime + ';base64,' + payload.imageData; + img.style.maxWidth = '320px'; + tanImageWrap.appendChild(img); + } + tanInput.value = ''; + tanError.style.display = 'none'; + modalTan.style.display = 'flex'; + setTimeout(() => tanInput.focus(), 50); + } + function closeTanModal() { + modalTan.style.display = 'none'; + currentTanToken = null; + } + + btnTanCancel.addEventListener('click', () => { + closeTanModal(); + if (activeEs) { activeEs.close(); activeEs = null; } + runTitle.textContent = 'Abruf abgebrochen'; + runSpinner.textContent = '✗ abgebrochen'; + btnRunClose.disabled = false; + }); + + async function submitTan() { + const tan = tanInput.value.trim(); + if (!tan) { tanError.textContent = 'Bitte TAN eingeben.'; tanError.style.display = 'block'; return; } + if (!currentSourceId || !currentTanToken) return; + btnTanSubmit.disabled = true; + try { + const form = new FormData(); + form.append('token', currentTanToken); + form.append('tan', tan); + const res = await fetch('/bank/direkt/' + currentSourceId + '/tan', { method: 'POST', body: form }); + const data = await res.json(); + if (!res.ok || data.error) { + tanError.textContent = data.error || 'TAN-Übermittlung fehlgeschlagen.'; + tanError.style.display = 'block'; + return; + } + closeTanModal(); + } catch (e) { + tanError.textContent = 'Netzwerkfehler: ' + e.message; + tanError.style.display = 'block'; + } finally { + btnTanSubmit.disabled = false; + } + } + btnTanSubmit.addEventListener('click', submitTan); + tanInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitTan(); }); + + // ── Drag & Drop für Kachel-Reihenfolge ────────────────────── + const grid = document.getElementById('connection-grid'); + let dragSrc = null; + let didDrag = false; + if (grid) { + grid.querySelectorAll('.drag-handle').forEach(handle => { + handle.addEventListener('dragstart', (e) => { + dragSrc = handle.closest('.card'); + didDrag = false; + if (!dragSrc) return; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', dragSrc.dataset.id || ''); + // Komplette Kachel als Drag-Image, am Mauszeiger an der Klick-Position + const rect = dragSrc.getBoundingClientRect(); + e.dataTransfer.setDragImage(dragSrc, e.clientX - rect.left, e.clientY - rect.top); + // Ursprung subtil markieren, erst nach Snapshot (sonst landet das Highlight im Ghost) + setTimeout(() => { + if (dragSrc) { + dragSrc.style.outline = '2px dashed #6366f1'; + dragSrc.style.outlineOffset = '2px'; + } + }, 0); + }); + handle.addEventListener('dragend', () => { + if (dragSrc) { + dragSrc.style.outline = ''; + dragSrc.style.outlineOffset = ''; + } + grid.querySelectorAll('.drop-before, .drop-after').forEach(c => { + c.classList.remove('drop-before', 'drop-after'); + }); + dragSrc = null; + setTimeout(() => { didDrag = false; }, 50); + }); + }); + function clearDropMarkers() { + grid.querySelectorAll('.drop-before, .drop-after').forEach(c => { + c.classList.remove('drop-before', 'drop-after'); + }); + } + + function findDropTarget(clientX, clientY) { + const cards = [...grid.querySelectorAll('.card')].filter(c => c !== dragSrc); + // Erst: Karte unter dem Cursor (bevorzugt) + for (const card of cards) { + const r = card.getBoundingClientRect(); + if (clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom) { + return { card, side: (clientX < r.left + r.width / 2) ? 'before' : 'after' }; + } + } + // Fallback: nächste Karte in derselben Zeile (anhand Y) + let best = null, bestDist = Infinity; + for (const card of cards) { + const r = card.getBoundingClientRect(); + if (clientY >= r.top && clientY <= r.bottom) { + const center = r.left + r.width / 2; + const dist = Math.abs(clientX - center); + if (dist < bestDist) { + bestDist = dist; + best = { card, side: (clientX < center) ? 'before' : 'after' }; + } + } + } + return best; + } + + grid.addEventListener('dragover', (e) => { + if (!dragSrc) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const target = findDropTarget(e.clientX, e.clientY); + if (!target) { clearDropMarkers(); return; } + const cls = target.side === 'before' ? 'drop-before' : 'drop-after'; + // Nur neu setzen wenn nötig (vermeidet Flackern) + if (target.card.classList.contains(cls)) return; + clearDropMarkers(); + target.card.classList.add(cls); + }); + grid.addEventListener('dragleave', (e) => { + // Nur wenn der Grid komplett verlassen wird + if (e.target === grid && !grid.contains(e.relatedTarget)) { + clearDropMarkers(); + } + }); + grid.addEventListener('drop', (e) => { + if (!dragSrc) return; + e.preventDefault(); + e.stopPropagation(); + const marker = grid.querySelector('.drop-before, .drop-after'); + if (marker) { + const before = marker.classList.contains('drop-before'); + marker.parentNode.insertBefore(dragSrc, before ? marker : marker.nextSibling); + didDrag = true; + persistOrder(); + } + clearDropMarkers(); + }); + } + async function persistOrder() { + const ids = Array.from(grid.querySelectorAll('.card[data-id]')).map(c => c.dataset.id); + const form = new FormData(); + form.append('ids', JSON.stringify(ids)); + try { + await fetch('/bank/direkt/_order', { method: 'POST', body: form }); + } catch (e) { /* still local */ } + } + + document.querySelectorAll('.btn-run-connection').forEach(card => { + card.addEventListener('click', (e) => { + if (didDrag) { didDrag = false; return; } + if (e.target.closest('.btn-edit-connection')) return; + if (e.target.closest('.drag-handle')) return; + + const id = card.dataset.id; + currentSourceId = id; + runLogArea.textContent = ''; + runTitle.textContent = 'Abruf läuft…'; + runSpinner.textContent = '⏳ wird abgerufen…'; + btnRunClose.disabled = true; + runWasOk = false; + modalRun.style.display = 'flex'; + + if (activeEs) activeEs.close(); + activeEs = new EventSource('/bank/direkt/' + id + '/run?csrf_token=' + encodeURIComponent(csrfToken)); + + activeEs.addEventListener('log', (ev) => { + runLogArea.textContent += ev.data + '\n'; + runLogArea.scrollTop = runLogArea.scrollHeight; + }); + + activeEs.addEventListener('tan-required', (ev) => { + try { + openTanModal(JSON.parse(ev.data)); + } catch (e) { + runLogArea.textContent += '\n[TAN-Anforderung konnte nicht geparst werden]\n'; + } + }); + + activeEs.addEventListener('accounts-required', (ev) => { + try { + openAccountModal(JSON.parse(ev.data)); + } catch (e) { + runLogArea.textContent += '\n[Konto-Auswahl konnte nicht geparst werden]\n'; + } + }); + + activeEs.addEventListener('done', (ev) => { + activeEs.close(); + activeEs = null; + runWasOk = ev.data === 'ok'; + runTitle.textContent = runWasOk ? 'Abruf abgeschlossen' : 'Abruf mit Fehlern beendet'; + runSpinner.textContent = runWasOk ? '✓ fertig' : '⚠ Fehler'; + btnRunClose.disabled = false; + }); + + activeEs.addEventListener('error', () => { + activeEs.close(); + activeEs = null; + runLogArea.textContent += '\n[Verbindung unterbrochen]\n'; + runLogArea.scrollTop = runLogArea.scrollHeight; + runTitle.textContent = 'Abruf unterbrochen'; + runSpinner.textContent = '✗ unterbrochen'; + btnRunClose.disabled = false; + }); + }); + }); + + document.querySelectorAll('.btn-edit-connection').forEach(btn => { + btn.addEventListener('click', () => { + const id = parseInt(btn.dataset.id, 10); + const conn = connections.find(c => c.id === id); + if (!conn) return; + + formEdit.action = '/bank/direkt/' + id; + formDelete.action = '/bank/direkt/' + id + '/delete'; + + editName.textContent = conn.provider_name; + editBez.value = conn.bezeichnung; + editKuerzel.value = conn.kuerzel || ''; + editFarbe.value = conn.farbe || '#6366f1'; + + const isApi = conn.type === 'api'; + const isFints = conn.type === 'fints'; + + editApiFields.style.display = isApi ? 'block' : 'none'; + editFintsFields.style.display = isFints ? 'block' : 'none'; + + // fetch_range steht in beiden Type-Blöcken; nur der zum aktiven Typ + // gehörige Select wird enabled (disabled Felder werden nicht submitted). + const fetchVal = conn.fetch_range || 'last_fetched'; + editFetchRangeApi.value = fetchVal; + editFetchRangeFints.value = fetchVal; + editFetchRangeApi.disabled = !isApi; + editFetchRangeFints.disabled= !isFints; + + if (isApi) { + editAppName.value = conn.app_name || ''; + editClientId.value = ''; + editSecret.value = ''; + editSandbox.checked = conn.sandbox === true; + } + if (isFints) { + editBlz.value = conn.blz || ''; + editBenutzer.value = conn.benutzerkennung || ''; + editPin.value = ''; + editFintsUrl.value = conn.fints_url || ''; + editTanModeId.value = conn.tan_mode_id != null ? conn.tan_mode_id : ''; + editResetAcc.checked = false; + + // Konto-Checkboxen aus account_filter aufbauen + editAccountList.innerHTML = ''; + let accounts = []; + if (conn.account_filter) { + try { + const arr = JSON.parse(conn.account_filter); + if (Array.isArray(arr)) accounts = arr.map(String); + } catch (e) { /* ignore */ } + } + const editAccountEmpty = document.getElementById('edit-account-empty'); + if (accounts.length === 0) { + editAccountList.style.display = 'none'; + editAccountEmpty.style.display = 'block'; + } else { + editAccountList.style.display = 'flex'; + editAccountEmpty.style.display = 'none'; + accounts.forEach(id => { + const row = document.createElement('label'); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '.5rem'; + row.style.cursor = 'pointer'; + row.style.fontWeight = 'normal'; + row.style.margin = '0'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.name = 'active_accounts[]'; + cb.value = id; + cb.checked = true; + cb.style.width = 'auto'; + cb.style.margin = '0'; + const span = document.createElement('span'); + // IBAN lesbarer machen (4er-Gruppen), AN:-Identifier wie geliefert + span.textContent = /^[A-Z]{2}\d{2}/.test(id) + ? id.replace(/(.{4})/g, '$1 ').trim() + : id; + span.style.fontFamily = 'monospace'; + span.style.fontSize = '.85rem'; + row.appendChild(cb); + row.appendChild(span); + editAccountList.appendChild(row); + }); + } + } + + modalEdit.style.display = 'flex'; + }); + }); +})(); diff --git a/src/Controllers/BankController.php b/src/Controllers/BankController.php index f674a78..8181829 100644 --- a/src/Controllers/BankController.php +++ b/src/Controllers/BankController.php @@ -12,6 +12,7 @@ use Steuerkram\Models\BankTransaction; use Steuerkram\Controllers\InvoiceController; use Steuerkram\Models\Beleg; use Steuerkram\Models\Invoice; +use Steuerkram\Request; use Steuerkram\Services\BankImportParser; use Steuerkram\Services\FinTsFetchService; use Steuerkram\Services\PayPalNvpFetchService; @@ -29,7 +30,7 @@ class BankController extends BaseController $filter['status'] = 'pending'; } - $page = max(1, (int)($_GET['page'] ?? 1)); + $page = max(1, (int)Request::get('page', 1)); $result = BankTransaction::all($filter, $page); $formats = BankSource::all('csv'); @@ -120,7 +121,7 @@ class BankController extends BaseController public function upload(array $params): void { - $formatId = (int)($_POST['format_id'] ?? 0); + $formatId = Request::postInt('format_id'); $format = BankSource::find($formatId); if (!$format || $format['type'] !== 'csv') { @@ -243,7 +244,7 @@ class BankController extends BaseController public function updateStatus(array $params): void { $id = (int)($params['id'] ?? 0); - $status = $_POST['status'] ?? ''; + $status = Request::post('status'); if (!in_array($status, ['offen', 'teilweise', 'gebucht', 'ignoriert'], true)) { View::redirect('/bank', 'Ungültiger Status.', 'error'); @@ -257,8 +258,8 @@ class BankController extends BaseController public function linkBeleg(array $params): void { $id = (int)($params['id'] ?? 0); - $belegId = (int)($_POST['beleg_id'] ?? 0); - $betrag = round((float)str_replace(',', '.', $_POST['alloc_betrag'] ?? ''), 2); + $belegId = Request::postInt('beleg_id'); + $betrag = round(Request::postFloat('alloc_betrag'), 2); if (!$belegId) { View::redirect("/bank/$id", 'Kein Beleg ausgewählt.', 'error'); @@ -273,8 +274,8 @@ class BankController extends BaseController public function linkInvoice(array $params): void { $id = (int)($params['id'] ?? 0); - $invoiceId = (int)($_POST['invoice_id'] ?? 0); - $betrag = round((float)str_replace(',', '.', $_POST['alloc_betrag'] ?? ''), 2); + $invoiceId = Request::postInt('invoice_id'); + $betrag = round(Request::postFloat('alloc_betrag'), 2); if (!$invoiceId) { View::redirect("/bank/$id", 'Keine Rechnung ausgewählt.', 'error'); @@ -303,8 +304,8 @@ class BankController extends BaseController public function unlinkBeleg(array $params): void { $id = (int)($params['id'] ?? 0); - $belegId = (int)($_POST['beleg_id'] ?? 0); - $redirectTo = $_POST['redirect_to'] ?? ''; + $belegId = Request::postInt('beleg_id'); + $redirectTo = Request::post('redirect_to'); BankTransaction::unlinkBeleg($id, $belegId); @@ -393,8 +394,8 @@ class BankController extends BaseController public function direktStore(array $params): void { - $providerRaw = (string)($_POST['provider_id'] ?? ''); - $bezeichnung = trim($_POST['bezeichnung'] ?? ''); + $providerRaw = (string)Request::post('provider_id'); + $bezeichnung = trim((string)Request::post('bezeichnung')); if ($bezeichnung === '') { View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error'); @@ -425,27 +426,27 @@ class BankController extends BaseController private function storeCsvFormat(string $bezeichnung): void { - $kuerzel = trim($_POST['kuerzel'] ?? ''); + $kuerzel = trim((string)Request::post('kuerzel')); if ($kuerzel === '' || mb_strlen($kuerzel) > 10) { View::redirect('/bank/direkt', 'Bitte ein Kürzel angeben (max. 10 Zeichen).', 'error'); return; } $dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y']; - $delimiter = (string)($_POST['csv_delimiter'] ?? ';'); - $enclosure = (string)($_POST['csv_enclosure'] ?? '"'); - $dateFmt = (string)($_POST['csv_date_format'] ?? 'd.m.Y'); - $decSep = (string)($_POST['csv_decimal_separator'] ?? ','); - $thouSep = (string)($_POST['csv_thousands_separator'] ?? '.'); - $farbe = (string)($_POST['farbe'] ?? ''); + $delimiter = (string)Request::post('csv_delimiter', ';'); + $enclosure = (string)Request::post('csv_enclosure', '"'); + $dateFmt = (string)Request::post('csv_date_format', 'd.m.Y'); + $decSep = (string)Request::post('csv_decimal_separator', ','); + $thouSep = (string)Request::post('csv_thousands_separator', '.'); + $farbe = (string)Request::post('farbe'); $id = BankSource::create([ 'type' => 'csv', 'bezeichnung' => $bezeichnung, 'kuerzel' => $kuerzel, 'farbe' => preg_match('/^#[0-9a-fA-F]{6}$/', $farbe) ? $farbe : '#6366f1', - 'skip_rows' => max(0, min(99, (int)($_POST['csv_skip_rows'] ?? 0))), - 'has_header_row' => ($_POST['csv_has_header_row'] ?? '1') === '0' ? 0 : 1, + 'skip_rows' => max(0, min(99, Request::postInt('csv_skip_rows'))), + 'has_header_row' => Request::post('csv_has_header_row', '1') === '0' ? 0 : 1, 'delimiter' => mb_strlen($delimiter) === 1 ? $delimiter : ';', 'enclosure' => mb_strlen($enclosure) === 1 ? $enclosure : '"', 'date_format' => in_array($dateFmt, $dateFormats, true) ? $dateFmt : 'd.m.Y', @@ -465,8 +466,8 @@ class BankController extends BaseController private function resolveProviderId(string $providerRaw): ?int { if ($providerRaw === '__new__') { - $newName = trim((string)($_POST['new_bank_name'] ?? '')); - $newTyp = ($_POST['new_bank_typ'] ?? 'FinTS') === 'API' ? 'API' : 'FinTS'; + $newName = trim((string)Request::post('new_bank_name')); + $newTyp = Request::post('new_bank_typ', 'FinTS') === 'API' ? 'API' : 'FinTS'; if ($newName === '') { View::redirect('/bank/direkt', 'Bitte den Banknamen angeben.', 'error'); return null; @@ -490,7 +491,7 @@ class BankController extends BaseController $isApi = $provider['verbindungstyp'] === 'API'; $isFints = $provider['verbindungstyp'] === 'FinTS'; - $fintsUrl = $isFints ? (trim($_POST['fints_url'] ?? '') ?: null) : null; + $fintsUrl = $isFints ? (trim((string)Request::post('fints_url')) ?: null) : null; if ($fintsUrl !== null) { try { SsrfGuard::assertPublicHttpsUrl($fintsUrl); @@ -504,17 +505,17 @@ class BankController extends BaseController 'type' => $isApi ? 'api' : 'fints', 'provider_id' => $providerId, 'bezeichnung' => $bezeichnung, - 'kuerzel' => trim($_POST['kuerzel'] ?? '') ?: null, - 'farbe' => trim($_POST['farbe'] ?? '') ?: '#6366f1', - 'sandbox' => $isApi ? (isset($_POST['sandbox']) ? 1 : 0) : 0, - 'app_name' => $isApi ? (trim($_POST['app_name'] ?? '') ?: null) : null, - 'client_id' => $isApi ? (trim($_POST['client_id'] ?? '') ?: null) : null, - 'secret' => $isApi ? (trim($_POST['secret'] ?? '') ?: null) : null, - 'blz' => $isFints ? (trim($_POST['blz'] ?? '') ?: null) : null, + 'kuerzel' => trim((string)Request::post('kuerzel')) ?: null, + 'farbe' => trim((string)Request::post('farbe')) ?: '#6366f1', + 'sandbox' => $isApi ? (Request::has('sandbox') ? 1 : 0) : 0, + 'app_name' => $isApi ? (trim((string)Request::post('app_name')) ?: null) : null, + 'client_id' => $isApi ? (trim((string)Request::post('client_id')) ?: null) : null, + 'secret' => $isApi ? (trim((string)Request::post('secret')) ?: null) : null, + 'blz' => $isFints ? (trim((string)Request::post('blz')) ?: null) : null, 'fints_url' => $fintsUrl, - 'benutzerkennung' => $isFints ? (trim($_POST['benutzerkennung'] ?? '') ?: null) : null, - 'pin' => $isFints ? (trim($_POST['pin'] ?? '') ?: null) : null, - 'tan_mode_id' => $isFints ? (($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : null) : null, + 'benutzerkennung' => $isFints ? (trim((string)Request::post('benutzerkennung')) ?: null) : null, + 'pin' => $isFints ? (trim((string)Request::post('pin')) ?: null) : null, + 'tan_mode_id' => $isFints ? (Request::post('tan_mode_id') !== '' ? Request::postInt('tan_mode_id') : null) : null, 'account_filter' => null, // Konten werden beim ersten Abruf ausgewählt ]); @@ -523,7 +524,7 @@ class BankController extends BaseController public function direktRun(array $params): void { - Auth::validateCsrfToken((string)($_GET['csrf_token'] ?? '')); + Auth::validateCsrfToken((string)Request::get('csrf_token')); $id = (int)($params['id'] ?? 0); $source = BankSource::find($id); @@ -578,7 +579,7 @@ class BankController extends BaseController public function direktUpdate(array $params): void { $id = (int)($params['id'] ?? 0); - $bezeichnung = trim($_POST['bezeichnung'] ?? ''); + $bezeichnung = trim((string)Request::post('bezeichnung')); if (!$id || $bezeichnung === '') { View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error'); @@ -607,11 +608,11 @@ class BankController extends BaseController $data = [ 'bezeichnung' => $bezeichnung, - 'kuerzel' => trim($_POST['kuerzel'] ?? '') ?: null, - 'farbe' => trim($_POST['farbe'] ?? '') ?: '#6366f1', + 'kuerzel' => trim((string)Request::post('kuerzel')) ?: null, + 'farbe' => trim((string)Request::post('farbe')) ?: '#6366f1', ]; - $rangePost = (string)($_POST['fetch_range'] ?? ''); + $rangePost = (string)Request::post('fetch_range'); if (in_array($rangePost, BankSource::FETCH_RANGES, true)) { $data['fetch_range'] = $rangePost; } @@ -631,11 +632,11 @@ class BankController extends BaseController private function buildApiUpdateData(array $source): array { - $newPwd = trim($_POST['client_id'] ?? ''); - $newSecret = trim($_POST['secret'] ?? ''); + $newPwd = trim((string)Request::post('client_id')); + $newSecret = trim((string)Request::post('secret')); return [ - 'sandbox' => isset($_POST['sandbox']) ? 1 : 0, - 'app_name' => trim($_POST['app_name'] ?? '') ?: null, + 'sandbox' => Request::has('sandbox') ? 1 : 0, + 'app_name' => trim((string)Request::post('app_name')) ?: null, 'client_id' => $newPwd !== '' ? $newPwd : $source['client_id'], 'secret' => $newSecret !== '' ? $newSecret : $source['secret'], ]; @@ -643,8 +644,8 @@ class BankController extends BaseController private function buildFintsUpdateData(array $source): ?array { - $newPin = trim($_POST['pin'] ?? ''); - $fintsUrl = trim($_POST['fints_url'] ?? '') ?: null; + $newPin = trim((string)Request::post('pin')); + $fintsUrl = trim((string)Request::post('fints_url')) ?: null; if ($fintsUrl !== null) { try { SsrfGuard::assertPublicHttpsUrl($fintsUrl); @@ -654,11 +655,11 @@ class BankController extends BaseController } } $data = [ - 'blz' => trim($_POST['blz'] ?? '') ?: null, + 'blz' => trim((string)Request::post('blz')) ?: null, 'fints_url' => $fintsUrl, - 'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null, + 'benutzerkennung' => trim((string)Request::post('benutzerkennung')) ?: null, 'pin' => $newPin !== '' ? $newPin : $source['pin'], - 'tan_mode_id' => ($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : null, + 'tan_mode_id' => Request::post('tan_mode_id') !== '' ? Request::postInt('tan_mode_id') : null, ]; $filterUpdate = BankSource::computeFilterFromPost( $source['account_filter'] ?? null, @@ -682,7 +683,7 @@ class BankController extends BaseController { header('Content-Type: application/json'); - $raw = $_POST['ids'] ?? '[]'; + $raw = Request::post('ids', '[]'); $ids = is_array($raw) ? $raw : (json_decode((string)$raw, true) ?: []); if (!is_array($ids)) { http_response_code(400); @@ -698,8 +699,8 @@ class BankController extends BaseController { header('Content-Type: application/json'); - $token = trim((string)($_POST['token'] ?? '')); - $tan = trim((string)($_POST['tan'] ?? '')); + $token = trim((string)Request::post('token')); + $tan = trim((string)Request::post('tan')); if ($tan === '') { http_response_code(400); @@ -719,8 +720,8 @@ class BankController extends BaseController { header('Content-Type: application/json'); - $token = trim((string)($_POST['token'] ?? '')); - $raw = $_POST['ids'] ?? '[]'; + $token = trim((string)Request::post('token')); + $raw = Request::post('ids', '[]'); $ids = is_array($raw) ? $raw : (json_decode((string)$raw, true) ?: []); if (!is_array($ids) || empty($ids)) { diff --git a/src/Request.php b/src/Request.php new file mode 100644 index 0000000..61e4679 --- /dev/null +++ b/src/Request.php @@ -0,0 +1,71 @@ +format('Y-m-d'); + } + + public static function get(string $key, mixed $default = ''): mixed + { + return $_GET[$key] ?? $default; + } + + public static function has(string $key): bool + { + return isset($_POST[$key]); + } +} diff --git a/src/Views/bank/_connection_grid.php b/src/Views/bank/_connection_grid.php new file mode 100644 index 0000000..a1154f7 --- /dev/null +++ b/src/Views/bank/_connection_grid.php @@ -0,0 +1,75 @@ + + + + +
+
+ Noch keine Direktverbindungen konfiguriert. Klicke oben rechts auf +, um eine hinzuzufügen. +
+
+ + +
+ +
+ ⠿ +
+
+
+
+ + + + +
+
+
+ + + + +
+
Abruf:
+
Kontostand
+
+
+
+ +
+ Abruf: ' ?> +
+ +
+ +
+
+
+ +
+ diff --git a/src/Views/bank/_modal_account_select.php b/src/Views/bank/_modal_account_select.php new file mode 100644 index 0000000..de03948 --- /dev/null +++ b/src/Views/bank/_modal_account_select.php @@ -0,0 +1,14 @@ + + diff --git a/src/Views/bank/_modal_edit_connection.php b/src/Views/bank/_modal_edit_connection.php new file mode 100644 index 0000000..ac11447 --- /dev/null +++ b/src/Views/bank/_modal_edit_connection.php @@ -0,0 +1,128 @@ + + + + + diff --git a/src/Views/bank/_modal_new_connection.php b/src/Views/bank/_modal_new_connection.php new file mode 100644 index 0000000..b6dcd61 --- /dev/null +++ b/src/Views/bank/_modal_new_connection.php @@ -0,0 +1,162 @@ + + diff --git a/src/Views/bank/_modal_run_log.php b/src/Views/bank/_modal_run_log.php new file mode 100644 index 0000000..a72460a --- /dev/null +++ b/src/Views/bank/_modal_run_log.php @@ -0,0 +1,17 @@ + + diff --git a/src/Views/bank/_modal_tan_input.php b/src/Views/bank/_modal_tan_input.php new file mode 100644 index 0000000..ff6b269 --- /dev/null +++ b/src/Views/bank/_modal_tan_input.php @@ -0,0 +1,21 @@ + + diff --git a/src/Views/bank/fints.php b/src/Views/bank/fints.php index 0ccc066..6c99793 100644 --- a/src/Views/bank/fints.php +++ b/src/Views/bank/fints.php @@ -40,993 +40,15 @@ $csvDateFormatOptions = [ 'Y.m.d' => '2026.05.15', 'd-m-Y' => '15-05-2026', ]; + +$av = defined('ASSET_VERSION') ? ASSET_VERSION : 'dev'; ?> - -
-
- Noch keine Direktverbindungen konfiguriert. Klicke oben rechts auf +, um eine hinzuzufügen. -
-
- - -
- -
- ⠿ -
-
-
-
- - - - -
-
-
- - - - -
-
Abruf:
-
Kontostand
-
-
-
- -
- Abruf: ' ?> -
- -
- -
-
-
- -
- - - - - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/src/Views/settings/_format_card.php b/src/Views/settings/_format_card.php new file mode 100644 index 0000000..42c6d66 --- /dev/null +++ b/src/Views/settings/_format_card.php @@ -0,0 +1,181 @@ + +
+
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Anzeige-Kürzel + +
+ + + +
+
Übersprungene Zeilen
Headerzeile vorhanden + +
Trennzeichen
Anführungszeichen
Datumsformat + + + → + +
Dezimaltrennzeichen
Tausendertrennzeichen + + + + keines + +
+ + + + + + + + + + + + + + + + + + + + + + + + (Betrag < 0)', $fmt['buchungstext_template_debit'] ?? ''], + ['buchungstext_template_credit', 'Buchungstext (Betrag ≥ 0)', $fmt['buchungstext_template_credit'] ?? ''], + ] as [$tplField, $tplLabel, $tplValue]): + ?> + + + + + + + + + + + + + + +
DatenbankfeldSpalte (0-basiert)CSV-Überschrift
+ + + + + – + + + + + + + – + +
+ + + + + nicht gesetzt + + +
+ Platzhalter {CSV-Überschrift} werden durch den Wert der jeweiligen Spalte ersetzt. +
+ +
+
diff --git a/src/Views/settings/_suggestion_card.php b/src/Views/settings/_suggestion_card.php new file mode 100644 index 0000000..cb5e169 --- /dev/null +++ b/src/Views/settings/_suggestion_card.php @@ -0,0 +1,172 @@ + 'Buchungstag', + 'wertstellung' => 'Wertstellung', + 'vorgang' => 'Vorgang / Umsatztyp', + 'wert' => 'Betrag', + 'zahlungspflichtiger' => 'Zahlungspflichtiger', + 'zahlungsempfaenger' => 'Zahlungsempfänger', + 'verwendungszweck' => 'Verwendungszweck', +]; +?> +
+
+
Neues Format (automatischer Vorschlag)
+ Nicht gespeichert +
+
+
+ +

+ Das Programm hat die Headerzeile in der CSV gefunden und Spalten automatisch zugeordnet. + Bitte prüfe die Einstellungen und klicke auf Speichern, um das neue Format anzulegen. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name
Anzeige-Kürzel
Übersprungene Zeilen
Headerzeile vorhanden + +
Trennzeichen
Anführungszeichen" maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace">
Datumsformat + +
Dezimaltrennzeichen
Tausendertrennzeichen
+ + + +
+ $col): ?> + : | + +
+ +
Erste Datenzeile
+
+ $val): ?> + : | + +
+ + + + + + + + + + + + + + $label): + $colKey = 'col_' . $field; + $colIdx = $sugMapping[$colKey] ?? null; + $headerVal = ($colIdx !== null && isset($sugHeader[$colIdx])) ? $sugHeader[$colIdx] : ''; + $isNullInt = in_array($field, ['zahlungspflichtiger', 'zahlungsempfaenger', 'verwendungszweck'], true); + ?> + + + + + + + + + + + + + + + + + + + + +
DatenbankfeldSpalte (0-basiert)Erkannte CSV-Überschrift
+ + style="width:65px;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"> + + + + + (Sp.) + + + – + +
Buchungstext (Betrag < 0) + +
Buchungstext (Betrag > 0) + +
+ Platzhalter {CSV-Überschrift} werden durch den Wert der jeweiligen Spalte ersetzt. + Optional: Zahlungspflichtiger, Zahlungsempfänger, Verwendungszweck (für Templates). +
+ +
+ + +
+ +
+
+
diff --git a/src/Views/settings/bankimport.php b/src/Views/settings/bankimport.php index 0b9b1b8..84cfd3c 100644 --- a/src/Views/settings/bankimport.php +++ b/src/Views/settings/bankimport.php @@ -29,6 +29,8 @@ $dateFormatOptions = [ 'Y.m.d' => '2026.05.15', 'd-m-Y' => '15-05-2026', ]; + +$av = defined('ASSET_VERSION') ? ASSET_VERSION : 'dev'; ?> @@ -40,178 +42,7 @@ $dateFormatOptions = [ - 'Buchungstag', - 'wertstellung' => 'Wertstellung', - 'vorgang' => 'Vorgang / Umsatztyp', - 'wert' => 'Betrag', - 'zahlungspflichtiger' => 'Zahlungspflichtiger', - 'zahlungsempfaenger' => 'Zahlungsempfänger', - 'verwendungszweck' => 'Verwendungszweck', -]; -?> -
-
-
Neues Format (automatischer Vorschlag)
- Nicht gespeichert -
-
-
- -

- Das Programm hat die Headerzeile in der CSV gefunden und Spalten automatisch zugeordnet. - Bitte prüfe die Einstellungen und klicke auf Speichern, um das neue Format anzulegen. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Name
Anzeige-Kürzel
Übersprungene Zeilen
Headerzeile vorhanden - -
Trennzeichen
Anführungszeichen" maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace">
Datumsformat - -
Dezimaltrennzeichen
Tausendertrennzeichen
- - - -
- $col): ?> - : | - -
- -
Erste Datenzeile
-
- $val): ?> - : | - -
- - - - - - - - - - - - - - $label): - $colKey = 'col_' . $field; - $colIdx = $sugMapping[$colKey] ?? null; - $headerVal = ($colIdx !== null && isset($sugHeader[$colIdx])) ? $sugHeader[$colIdx] : ''; - $isNullInt = in_array($field, ['zahlungspflichtiger', 'zahlungsempfaenger', 'verwendungszweck'], true); - ?> - - - - - - - - - - - - - - - - - - - - -
DatenbankfeldSpalte (0-basiert)Erkannte CSV-Überschrift
- - style="width:65px;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"> - - - - - (Sp.) - - - – - -
Buchungstext (Betrag < 0) - -
Buchungstext (Betrag > 0) - -
- Platzhalter {CSV-Überschrift} werden durch den Wert der jeweiligen Spalte ersetzt. - Optional: Zahlungspflichtiger, Zahlungsempfänger, Verwendungszweck (für Templates). -
- -
- - -
- -
-
-
+ @@ -225,187 +56,7 @@ $allMappingFields = [

- -
-
-
- -
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Anzeige-Kürzel - -
- - - -
-
Übersprungene Zeilen
Headerzeile vorhanden - -
Trennzeichen
Anführungszeichen
Datumsformat - - - → - -
Dezimaltrennzeichen
Tausendertrennzeichen - - - - keines - -
- - - - - - - - - - - - - - - - - - - - - - - - (Betrag < 0)', $fmt['buchungstext_template_debit'] ?? ''], - ['buchungstext_template_credit', 'Buchungstext (Betrag ≥ 0)', $fmt['buchungstext_template_credit'] ?? ''], - ] as [$tplField, $tplLabel, $tplValue]): - ?> - - - - - - - - - - - - - - -
DatenbankfeldSpalte (0-basiert)CSV-Überschrift
- - - - - – - - - - - - - – - -
- - - - - nicht gesetzt - - -
- Platzhalter {CSV-Überschrift} werden durch den Wert der jeweiligen Spalte ersetzt. -
- -
-
+ @@ -501,296 +152,5 @@ $mappingRowsAfter = [ } - + + diff --git a/tests/93_WebrootSauberkeitTest.php b/tests/93_WebrootSauberkeitTest.php new file mode 100644 index 0000000..6fb55c7 --- /dev/null +++ b/tests/93_WebrootSauberkeitTest.php @@ -0,0 +1,38 @@ +assert(is_dir($publicDir), 'public/ existiert'); + + $erlaubt = ['index.php', '.htaccess', 'static']; + $eintraege = array_diff(scandir($publicDir), ['.', '..']); + + foreach ($eintraege as $eintrag) { + $this->assert( + in_array($eintrag, $erlaubt, true), + "Unerwarteter Eintrag im Webroot: public/$eintrag" + ); + } + + $this->assert( + !file_exists($publicDir . '/O-CHAT.html'), + 'public/O-CHAT.html ist entfernt' + ); + } +} diff --git a/tests/94_RequestHelperTest.php b/tests/94_RequestHelperTest.php new file mode 100644 index 0000000..d0b68bd --- /dev/null +++ b/tests/94_RequestHelperTest.php @@ -0,0 +1,96 @@ +assertEquals('', Request::post('fehlt'), 'post(): fehlender Wert liefert Default ""'); + $this->assertEquals('x', Request::post('fehlt', 'x'), 'post(): fehlender Wert liefert übergebenen Default'); + $_POST['feld'] = 'wert'; + $this->assertEquals('wert', Request::post('feld'), 'post(): vorhandener Wert wird durchgereicht'); + + // ── postInt() ──────────────────────────────────────────── + unset($_POST['n']); + $this->assertEquals(0, Request::postInt('n'), 'postInt(): fehlender Wert liefert Default 0'); + $this->assertEquals(7, Request::postInt('n', 7), 'postInt(): fehlender Wert liefert übergebenen Default'); + + $_POST['n'] = ''; + $this->assertEquals(7, Request::postInt('n', 7), 'postInt(): leerer String liefert Default'); + + $_POST['n'] = 'abc'; + $this->assertEquals(0, Request::postInt('n', 7), 'postInt(): "abc" wird zu 0 (kein Default, da Wert gesetzt ist)'); + + $_POST['n'] = '12'; + $this->assertEquals(12, Request::postInt('n'), 'postInt(): "12" wird zu 12'); + + $_POST['n'] = '12.7'; + $this->assertEquals(12, Request::postInt('n'), 'postInt(): "12.7" wird zu 12 (int-Cast schneidet ab)'); + + // ── postFloat() ────────────────────────────────────────── + unset($_POST['betrag']); + $this->assertEquals(0.0, Request::postFloat('betrag'), 'postFloat(): fehlender Wert liefert Default 0.0'); + $this->assertEquals(9.5, Request::postFloat('betrag', 9.5), 'postFloat(): fehlender Wert liefert übergebenen Default'); + + $_POST['betrag'] = ''; + $this->assertEquals(9.5, Request::postFloat('betrag', 9.5), 'postFloat(): leerer String liefert Default'); + + $_POST['betrag'] = '1234,56'; + $this->assertEquals(1234.56, Request::postFloat('betrag'), 'postFloat(): deutsches Dezimalkomma wird korrekt geparst'); + + $_POST['betrag'] = '-119,00'; + $this->assertEquals(-119.0, Request::postFloat('betrag'), 'postFloat(): negativer Betrag mit Komma'); + + // ── postDate() ─────────────────────────────────────────── + unset($_POST['datum']); + $this->assertEquals(null, Request::postDate('datum'), 'postDate(): fehlender Wert liefert null'); + + $_POST['datum'] = ''; + $this->assertEquals(null, Request::postDate('datum'), 'postDate(): leerer Wert liefert null'); + + $_POST['datum'] = '2026-05-15'; + $this->assertEquals('2026-05-15', Request::postDate('datum'), 'postDate(): gültiges Format (Y-m-d) wird normalisiert zurückgegeben'); + + $_POST['datum'] = 'nicht-valide'; + $this->assertEquals(null, Request::postDate('datum'), 'postDate(): ungültiges Format liefert null'); + + $_POST['datum'] = '15.05.2026'; + $this->assertEquals('2026-05-15', Request::postDate('datum', 'd.m.Y'), 'postDate(): abweichendes Format wird korrekt geparst und normalisiert'); + $this->assertEquals(null, Request::postDate('datum'), 'postDate(): Wert passt nicht zum Default-Format Y-m-d'); + + // ── get() ──────────────────────────────────────────────── + $this->assertEquals('', Request::get('fehlt'), 'get(): fehlender Wert liefert Default ""'); + $this->assertEquals(1, Request::get('seite', 1), 'get(): fehlender Wert liefert übergebenen Default'); + $_GET['seite'] = '3'; + $this->assertEquals('3', Request::get('seite', 1), 'get(): vorhandener Wert wird durchgereicht'); + + // ── has() ──────────────────────────────────────────────── + unset($_POST['sandbox']); + $this->assert(Request::has('sandbox') === false, 'has(): fehlendes Feld liefert false'); + $_POST['sandbox'] = '1'; + $this->assert(Request::has('sandbox') === true, 'has(): vorhandenes Feld liefert true'); + $_POST['leer'] = ''; + $this->assert(Request::has('leer') === true, 'has(): auch ein leerer, aber gesetzter Wert zählt als vorhanden'); + + $_POST = []; + $_GET = []; + } +} diff --git a/tests/95_FinTsViewStrukturTest.php b/tests/95_FinTsViewStrukturTest.php new file mode 100644 index 0000000..5f2f3f5 --- /dev/null +++ b/tests/95_FinTsViewStrukturTest.php @@ -0,0 +1,78 @@ +-Tag, das Markup steckt + * in Teil-Views, die Logik in einer eigenständigen JS-Datei. + */ +class FinTsViewStrukturTest extends TestCase +{ + protected string $testName = 'Test 95: Struktur von bank/fints.php'; + + public function run(): void + { + $viewDir = __DIR__ . '/../src/Views/bank'; + $jsDir = __DIR__ . '/../public/static/js'; + $viewFile = $viewDir . '/fints.php'; + $content = file_get_contents($viewFile); + + $this->assert($content !== false, 'bank/fints.php ist lesbar'); + + $lines = substr_count($content, "\n") + 1; + $this->assert( + $lines < 300, + "bank/fints.php bleibt unter 300 Zeilen (erhalten: $lines)" + ); + + $this->assert( + preg_match('/