Fix #161: Restposten aus Tech-Debt-Analyse (O-CHAT.html, fints.php/bankimport.php, Request-Klasse)

(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.
This commit is contained in:
fds2610-bot 2026-08-18 13:12:12 +02:00
parent 6ef12bafee
commit 23efce3196
20 changed files with 2066 additions and 1686 deletions

View file

@ -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) ## 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. - 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.

View file

@ -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 '<span style="color:var(--color-muted);font-style:italic">–</span>';
}
return `<code>${value}</code>`;
}
if (type === 'char' || type === 'char0') {
if (value === '') return '<span style="color:var(--color-muted);font-style:italic">keines</span>';
return `<code>${value.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</code>`;
}
if (type === 'dateformat') {
const example = DATE_FORMAT_OPTIONS[value] || '?';
return `<code>${value}</code><span style="color:var(--color-muted);margin-left:.4rem">→ ${example}</span>`;
}
if (type === 'template') {
if (!value) return '<span style="color:var(--color-muted);font-style:italic">nicht gesetzt</span>';
const esc = value.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return `<code style="word-break:break-all">${esc}</code>`;
}
if (type === 'text') {
return value.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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';
});
})();

568
public/static/js/fints.js Normal file
View file

@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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 = '<div style="padding:.4rem .6rem;font-size:.8rem;color:var(--color-muted)">Keine Treffer</div>';
resultsBox.style.display = 'block';
return;
}
resultsBox.innerHTML = rows.map((r, i) => `
<div class="fc-blz-result" data-idx="${i}" style="padding:.35rem .6rem;font-size:.8rem;cursor:pointer;border-bottom:1px solid var(--color-border)">
<strong>${esc(r.blz)}</strong> ${esc(r.name)} · ${esc(r.ort)}
${r.pin_tan_url ? '' : '<span style="color:var(--color-muted)"> (kein PIN/TAN-Zugang)</span>'}
</div>
`).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';
});
});
})();

View file

@ -12,6 +12,7 @@ use Steuerkram\Models\BankTransaction;
use Steuerkram\Controllers\InvoiceController; use Steuerkram\Controllers\InvoiceController;
use Steuerkram\Models\Beleg; use Steuerkram\Models\Beleg;
use Steuerkram\Models\Invoice; use Steuerkram\Models\Invoice;
use Steuerkram\Request;
use Steuerkram\Services\BankImportParser; use Steuerkram\Services\BankImportParser;
use Steuerkram\Services\FinTsFetchService; use Steuerkram\Services\FinTsFetchService;
use Steuerkram\Services\PayPalNvpFetchService; use Steuerkram\Services\PayPalNvpFetchService;
@ -29,7 +30,7 @@ class BankController extends BaseController
$filter['status'] = 'pending'; $filter['status'] = 'pending';
} }
$page = max(1, (int)($_GET['page'] ?? 1)); $page = max(1, (int)Request::get('page', 1));
$result = BankTransaction::all($filter, $page); $result = BankTransaction::all($filter, $page);
$formats = BankSource::all('csv'); $formats = BankSource::all('csv');
@ -120,7 +121,7 @@ class BankController extends BaseController
public function upload(array $params): void public function upload(array $params): void
{ {
$formatId = (int)($_POST['format_id'] ?? 0); $formatId = Request::postInt('format_id');
$format = BankSource::find($formatId); $format = BankSource::find($formatId);
if (!$format || $format['type'] !== 'csv') { if (!$format || $format['type'] !== 'csv') {
@ -243,7 +244,7 @@ class BankController extends BaseController
public function updateStatus(array $params): void public function updateStatus(array $params): void
{ {
$id = (int)($params['id'] ?? 0); $id = (int)($params['id'] ?? 0);
$status = $_POST['status'] ?? ''; $status = Request::post('status');
if (!in_array($status, ['offen', 'teilweise', 'gebucht', 'ignoriert'], true)) { if (!in_array($status, ['offen', 'teilweise', 'gebucht', 'ignoriert'], true)) {
View::redirect('/bank', 'Ungültiger Status.', 'error'); View::redirect('/bank', 'Ungültiger Status.', 'error');
@ -257,8 +258,8 @@ class BankController extends BaseController
public function linkBeleg(array $params): void public function linkBeleg(array $params): void
{ {
$id = (int)($params['id'] ?? 0); $id = (int)($params['id'] ?? 0);
$belegId = (int)($_POST['beleg_id'] ?? 0); $belegId = Request::postInt('beleg_id');
$betrag = round((float)str_replace(',', '.', $_POST['alloc_betrag'] ?? ''), 2); $betrag = round(Request::postFloat('alloc_betrag'), 2);
if (!$belegId) { if (!$belegId) {
View::redirect("/bank/$id", 'Kein Beleg ausgewählt.', 'error'); View::redirect("/bank/$id", 'Kein Beleg ausgewählt.', 'error');
@ -273,8 +274,8 @@ class BankController extends BaseController
public function linkInvoice(array $params): void public function linkInvoice(array $params): void
{ {
$id = (int)($params['id'] ?? 0); $id = (int)($params['id'] ?? 0);
$invoiceId = (int)($_POST['invoice_id'] ?? 0); $invoiceId = Request::postInt('invoice_id');
$betrag = round((float)str_replace(',', '.', $_POST['alloc_betrag'] ?? ''), 2); $betrag = round(Request::postFloat('alloc_betrag'), 2);
if (!$invoiceId) { if (!$invoiceId) {
View::redirect("/bank/$id", 'Keine Rechnung ausgewählt.', 'error'); View::redirect("/bank/$id", 'Keine Rechnung ausgewählt.', 'error');
@ -303,8 +304,8 @@ class BankController extends BaseController
public function unlinkBeleg(array $params): void public function unlinkBeleg(array $params): void
{ {
$id = (int)($params['id'] ?? 0); $id = (int)($params['id'] ?? 0);
$belegId = (int)($_POST['beleg_id'] ?? 0); $belegId = Request::postInt('beleg_id');
$redirectTo = $_POST['redirect_to'] ?? ''; $redirectTo = Request::post('redirect_to');
BankTransaction::unlinkBeleg($id, $belegId); BankTransaction::unlinkBeleg($id, $belegId);
@ -393,8 +394,8 @@ class BankController extends BaseController
public function direktStore(array $params): void public function direktStore(array $params): void
{ {
$providerRaw = (string)($_POST['provider_id'] ?? ''); $providerRaw = (string)Request::post('provider_id');
$bezeichnung = trim($_POST['bezeichnung'] ?? ''); $bezeichnung = trim((string)Request::post('bezeichnung'));
if ($bezeichnung === '') { if ($bezeichnung === '') {
View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error'); View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error');
@ -425,27 +426,27 @@ class BankController extends BaseController
private function storeCsvFormat(string $bezeichnung): void private function storeCsvFormat(string $bezeichnung): void
{ {
$kuerzel = trim($_POST['kuerzel'] ?? ''); $kuerzel = trim((string)Request::post('kuerzel'));
if ($kuerzel === '' || mb_strlen($kuerzel) > 10) { if ($kuerzel === '' || mb_strlen($kuerzel) > 10) {
View::redirect('/bank/direkt', 'Bitte ein Kürzel angeben (max. 10 Zeichen).', 'error'); View::redirect('/bank/direkt', 'Bitte ein Kürzel angeben (max. 10 Zeichen).', 'error');
return; return;
} }
$dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y']; $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'] ?? ';'); $delimiter = (string)Request::post('csv_delimiter', ';');
$enclosure = (string)($_POST['csv_enclosure'] ?? '"'); $enclosure = (string)Request::post('csv_enclosure', '"');
$dateFmt = (string)($_POST['csv_date_format'] ?? 'd.m.Y'); $dateFmt = (string)Request::post('csv_date_format', 'd.m.Y');
$decSep = (string)($_POST['csv_decimal_separator'] ?? ','); $decSep = (string)Request::post('csv_decimal_separator', ',');
$thouSep = (string)($_POST['csv_thousands_separator'] ?? '.'); $thouSep = (string)Request::post('csv_thousands_separator', '.');
$farbe = (string)($_POST['farbe'] ?? ''); $farbe = (string)Request::post('farbe');
$id = BankSource::create([ $id = BankSource::create([
'type' => 'csv', 'type' => 'csv',
'bezeichnung' => $bezeichnung, 'bezeichnung' => $bezeichnung,
'kuerzel' => $kuerzel, 'kuerzel' => $kuerzel,
'farbe' => preg_match('/^#[0-9a-fA-F]{6}$/', $farbe) ? $farbe : '#6366f1', 'farbe' => preg_match('/^#[0-9a-fA-F]{6}$/', $farbe) ? $farbe : '#6366f1',
'skip_rows' => max(0, min(99, (int)($_POST['csv_skip_rows'] ?? 0))), 'skip_rows' => max(0, min(99, Request::postInt('csv_skip_rows'))),
'has_header_row' => ($_POST['csv_has_header_row'] ?? '1') === '0' ? 0 : 1, 'has_header_row' => Request::post('csv_has_header_row', '1') === '0' ? 0 : 1,
'delimiter' => mb_strlen($delimiter) === 1 ? $delimiter : ';', 'delimiter' => mb_strlen($delimiter) === 1 ? $delimiter : ';',
'enclosure' => mb_strlen($enclosure) === 1 ? $enclosure : '"', 'enclosure' => mb_strlen($enclosure) === 1 ? $enclosure : '"',
'date_format' => in_array($dateFmt, $dateFormats, true) ? $dateFmt : 'd.m.Y', '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 private function resolveProviderId(string $providerRaw): ?int
{ {
if ($providerRaw === '__new__') { if ($providerRaw === '__new__') {
$newName = trim((string)($_POST['new_bank_name'] ?? '')); $newName = trim((string)Request::post('new_bank_name'));
$newTyp = ($_POST['new_bank_typ'] ?? 'FinTS') === 'API' ? 'API' : 'FinTS'; $newTyp = Request::post('new_bank_typ', 'FinTS') === 'API' ? 'API' : 'FinTS';
if ($newName === '') { if ($newName === '') {
View::redirect('/bank/direkt', 'Bitte den Banknamen angeben.', 'error'); View::redirect('/bank/direkt', 'Bitte den Banknamen angeben.', 'error');
return null; return null;
@ -490,7 +491,7 @@ class BankController extends BaseController
$isApi = $provider['verbindungstyp'] === 'API'; $isApi = $provider['verbindungstyp'] === 'API';
$isFints = $provider['verbindungstyp'] === 'FinTS'; $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) { if ($fintsUrl !== null) {
try { try {
SsrfGuard::assertPublicHttpsUrl($fintsUrl); SsrfGuard::assertPublicHttpsUrl($fintsUrl);
@ -504,17 +505,17 @@ class BankController extends BaseController
'type' => $isApi ? 'api' : 'fints', 'type' => $isApi ? 'api' : 'fints',
'provider_id' => $providerId, 'provider_id' => $providerId,
'bezeichnung' => $bezeichnung, 'bezeichnung' => $bezeichnung,
'kuerzel' => trim($_POST['kuerzel'] ?? '') ?: null, 'kuerzel' => trim((string)Request::post('kuerzel')) ?: null,
'farbe' => trim($_POST['farbe'] ?? '') ?: '#6366f1', 'farbe' => trim((string)Request::post('farbe')) ?: '#6366f1',
'sandbox' => $isApi ? (isset($_POST['sandbox']) ? 1 : 0) : 0, 'sandbox' => $isApi ? (Request::has('sandbox') ? 1 : 0) : 0,
'app_name' => $isApi ? (trim($_POST['app_name'] ?? '') ?: null) : null, 'app_name' => $isApi ? (trim((string)Request::post('app_name')) ?: null) : null,
'client_id' => $isApi ? (trim($_POST['client_id'] ?? '') ?: null) : null, 'client_id' => $isApi ? (trim((string)Request::post('client_id')) ?: null) : null,
'secret' => $isApi ? (trim($_POST['secret'] ?? '') ?: null) : null, 'secret' => $isApi ? (trim((string)Request::post('secret')) ?: null) : null,
'blz' => $isFints ? (trim($_POST['blz'] ?? '') ?: null) : null, 'blz' => $isFints ? (trim((string)Request::post('blz')) ?: null) : null,
'fints_url' => $fintsUrl, 'fints_url' => $fintsUrl,
'benutzerkennung' => $isFints ? (trim($_POST['benutzerkennung'] ?? '') ?: null) : null, 'benutzerkennung' => $isFints ? (trim((string)Request::post('benutzerkennung')) ?: null) : null,
'pin' => $isFints ? (trim($_POST['pin'] ?? '') ?: null) : null, 'pin' => $isFints ? (trim((string)Request::post('pin')) ?: null) : null,
'tan_mode_id' => $isFints ? (($_POST['tan_mode_id'] ?? '') !== '' ? (int)$_POST['tan_mode_id'] : 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 'account_filter' => null, // Konten werden beim ersten Abruf ausgewählt
]); ]);
@ -523,7 +524,7 @@ class BankController extends BaseController
public function direktRun(array $params): void public function direktRun(array $params): void
{ {
Auth::validateCsrfToken((string)($_GET['csrf_token'] ?? '')); Auth::validateCsrfToken((string)Request::get('csrf_token'));
$id = (int)($params['id'] ?? 0); $id = (int)($params['id'] ?? 0);
$source = BankSource::find($id); $source = BankSource::find($id);
@ -578,7 +579,7 @@ class BankController extends BaseController
public function direktUpdate(array $params): void public function direktUpdate(array $params): void
{ {
$id = (int)($params['id'] ?? 0); $id = (int)($params['id'] ?? 0);
$bezeichnung = trim($_POST['bezeichnung'] ?? ''); $bezeichnung = trim((string)Request::post('bezeichnung'));
if (!$id || $bezeichnung === '') { if (!$id || $bezeichnung === '') {
View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error'); View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error');
@ -607,11 +608,11 @@ class BankController extends BaseController
$data = [ $data = [
'bezeichnung' => $bezeichnung, 'bezeichnung' => $bezeichnung,
'kuerzel' => trim($_POST['kuerzel'] ?? '') ?: null, 'kuerzel' => trim((string)Request::post('kuerzel')) ?: null,
'farbe' => trim($_POST['farbe'] ?? '') ?: '#6366f1', '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)) { if (in_array($rangePost, BankSource::FETCH_RANGES, true)) {
$data['fetch_range'] = $rangePost; $data['fetch_range'] = $rangePost;
} }
@ -631,11 +632,11 @@ class BankController extends BaseController
private function buildApiUpdateData(array $source): array private function buildApiUpdateData(array $source): array
{ {
$newPwd = trim($_POST['client_id'] ?? ''); $newPwd = trim((string)Request::post('client_id'));
$newSecret = trim($_POST['secret'] ?? ''); $newSecret = trim((string)Request::post('secret'));
return [ return [
'sandbox' => isset($_POST['sandbox']) ? 1 : 0, 'sandbox' => Request::has('sandbox') ? 1 : 0,
'app_name' => trim($_POST['app_name'] ?? '') ?: null, 'app_name' => trim((string)Request::post('app_name')) ?: null,
'client_id' => $newPwd !== '' ? $newPwd : $source['client_id'], 'client_id' => $newPwd !== '' ? $newPwd : $source['client_id'],
'secret' => $newSecret !== '' ? $newSecret : $source['secret'], 'secret' => $newSecret !== '' ? $newSecret : $source['secret'],
]; ];
@ -643,8 +644,8 @@ class BankController extends BaseController
private function buildFintsUpdateData(array $source): ?array private function buildFintsUpdateData(array $source): ?array
{ {
$newPin = trim($_POST['pin'] ?? ''); $newPin = trim((string)Request::post('pin'));
$fintsUrl = trim($_POST['fints_url'] ?? '') ?: null; $fintsUrl = trim((string)Request::post('fints_url')) ?: null;
if ($fintsUrl !== null) { if ($fintsUrl !== null) {
try { try {
SsrfGuard::assertPublicHttpsUrl($fintsUrl); SsrfGuard::assertPublicHttpsUrl($fintsUrl);
@ -654,11 +655,11 @@ class BankController extends BaseController
} }
} }
$data = [ $data = [
'blz' => trim($_POST['blz'] ?? '') ?: null, 'blz' => trim((string)Request::post('blz')) ?: null,
'fints_url' => $fintsUrl, 'fints_url' => $fintsUrl,
'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null, 'benutzerkennung' => trim((string)Request::post('benutzerkennung')) ?: null,
'pin' => $newPin !== '' ? $newPin : $source['pin'], '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( $filterUpdate = BankSource::computeFilterFromPost(
$source['account_filter'] ?? null, $source['account_filter'] ?? null,
@ -682,7 +683,7 @@ class BankController extends BaseController
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
$raw = $_POST['ids'] ?? '[]'; $raw = Request::post('ids', '[]');
$ids = is_array($raw) ? $raw : (json_decode((string)$raw, true) ?: []); $ids = is_array($raw) ? $raw : (json_decode((string)$raw, true) ?: []);
if (!is_array($ids)) { if (!is_array($ids)) {
http_response_code(400); http_response_code(400);
@ -698,8 +699,8 @@ class BankController extends BaseController
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
$token = trim((string)($_POST['token'] ?? '')); $token = trim((string)Request::post('token'));
$tan = trim((string)($_POST['tan'] ?? '')); $tan = trim((string)Request::post('tan'));
if ($tan === '') { if ($tan === '') {
http_response_code(400); http_response_code(400);
@ -719,8 +720,8 @@ class BankController extends BaseController
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
$token = trim((string)($_POST['token'] ?? '')); $token = trim((string)Request::post('token'));
$raw = $_POST['ids'] ?? '[]'; $raw = Request::post('ids', '[]');
$ids = is_array($raw) ? $raw : (json_decode((string)$raw, true) ?: []); $ids = is_array($raw) ? $raw : (json_decode((string)$raw, true) ?: []);
if (!is_array($ids) || empty($ids)) { if (!is_array($ids) || empty($ids)) {

71
src/Request.php Normal file
View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Steuerkram;
/**
* Schlanke Helfer für $_POST/$_GET-Zugriffe: einheitliche Typumwandlung und
* Normalisierung fehlender/leerer Werte an einer Stelle statt verstreut in
* jedem Controller (mal `?? ''`, mal `isset()`, mal `(int)`, mal `trim()`).
* Ersetzt $_POST/$_GET nicht – beide bleiben nutzbar, die Umstellung der
* Controller erfolgt schrittweise (Fix #161d).
*/
class Request
{
public static function post(string $key, mixed $default = ''): mixed
{
return $_POST[$key] ?? $default;
}
public static function postInt(string $key, int $default = 0): int
{
if (!isset($_POST[$key]) || $_POST[$key] === '') {
return $default;
}
return (int)$_POST[$key];
}
/**
* Wandelt einen Betrag mit deutschem Dezimalkomma ("1234,56") in float
* um – wie die bisher verstreuten `(float)str_replace(',', '.', ...)`-
* Aufrufe in den Controllern.
*/
public static function postFloat(string $key, float $default = 0.0): float
{
if (!isset($_POST[$key]) || $_POST[$key] === '') {
return $default;
}
return (float)str_replace(',', '.', (string)$_POST[$key]);
}
/**
* Parst ein POST-Datum im vorgegebenen Eingabeformat und liefert es
* normalisiert als "Y-m-d" (wie BankImportParser::parseDate()) –
* andernfalls null (fehlender Wert, leerer Wert oder falsches Format).
*/
public static function postDate(string $key, string $format = 'Y-m-d'): ?string
{
$raw = $_POST[$key] ?? '';
if (!is_string($raw) || $raw === '') {
return null;
}
$date = \DateTime::createFromFormat($format, $raw);
if (!$date) {
return null;
}
return $date->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]);
}
}

View file

@ -0,0 +1,75 @@
<?php use Steuerkram\View; ?>
<div id="fints-data" data-connections="<?= $h($connectionsJson) ?>" style="display:none"></div>
<?php if (empty($connections)): ?>
<div class="card">
<div class="card-body" style="text-align:center;padding:3rem;color:var(--color-muted)">
Noch keine Direktverbindungen konfiguriert. Klicke oben rechts auf <strong>+</strong>, um eine hinzuzufügen.
</div>
</div>
<?php else: ?>
<style>
#connection-grid .card.drop-before::before,
#connection-grid .card.drop-after::after {
content: '';
position: absolute;
top: .25rem;
bottom: .25rem;
width: 4px;
background: #6366f1;
border-radius: 2px;
pointer-events: none;
}
#connection-grid .card.drop-before::before { left: -10px; }
#connection-grid .card.drop-after::after { right: -10px; }
</style>
<div id="connection-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem">
<?php foreach ($connections as $conn): ?>
<div class="card btn-run-connection" data-id="<?= (int)$conn['id'] ?>" style="margin:0;cursor:pointer;position:relative" title="Abruf starten">
<span class="drag-handle" draggable="true"
title="Ziehen zum Sortieren"
style="position:absolute;top:.4rem;left:.4rem;color:var(--color-muted);cursor:grab;user-select:none;padding:.15rem .35rem;font-size:.95rem;line-height:1">⠿</span>
<div class="card-body">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;padding-left:1rem">
<div>
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<?php if (!empty($conn['kuerzel'])): ?>
<?php $kf = $conn['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($conn['kuerzel']) ?></span>
<?php endif; ?>
<div style="font-weight:600;font-size:1rem"><?= $h($conn['bezeichnung']) ?></div>
</div>
<div class="text-muted text-sm" style="margin-top:.2rem"><?= $h($conn['provider_name']) ?></div>
<span class="badge <?= $conn['verbindungstyp'] === 'API' ? 'badge-blue' : 'badge-green' ?>" style="margin-top:.5rem">
<?= $h($conn['verbindungstyp']) ?>
</span>
<?php if ($conn['last_fetched_at'] && $conn['last_balance'] !== null): ?>
<div class="text-muted text-sm" style="display:grid;grid-template-columns:1fr auto;column-gap:.5rem;row-gap:.1rem;align-items:baseline;margin-top:.6rem">
<div>Abruf:</div>
<div style="text-align:right">Kontostand</div>
<div><?= $h(View::formatDate($conn['last_fetched_at'])) ?></div>
<div style="text-align:right"><?= $h(View::formatMoney($conn['last_balance'])) ?></div>
</div>
<?php else: ?>
<div class="text-muted text-sm" style="margin-top:.6rem">
Abruf: <?= $conn['last_fetched_at'] ? $h(View::formatDate($conn['last_fetched_at'])) : '<kein>' ?>
</div>
<?php endif; ?>
</div>
<button type="button"
class="btn-edit-connection"
data-id="<?= (int)$conn['id'] ?>"
style="background:none;border:none;cursor:pointer;color:var(--color-muted);padding:.2rem;line-height:1;flex-shrink:0"
title="Bearbeiten">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>

View file

@ -0,0 +1,14 @@
<!-- Modal: Konto-Auswahl bei mehreren Konten -->
<div id="modal-account-select"
style="display:none;position:fixed;inset:0;z-index:1100;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem;display:flex;flex-direction:column;gap:.75rem">
<h2 style="margin:0;font-size:1.125rem">Konten auswählen</h2>
<div class="text-muted text-sm">Wähle die Konten, die regelmäßig importiert werden sollen. Die Auswahl wird gespeichert und kann später im Bearbeiten-Dialog zurückgesetzt werden.</div>
<div id="account-list" style="display:flex;flex-direction:column;gap:.4rem;max-height:280px;overflow-y:auto;padding:.25rem"></div>
<div id="account-error" class="text-sm" style="color:#dc2626;display:none"></div>
<div style="display:flex;justify-content:flex-end;gap:.5rem">
<button type="button" id="btn-account-cancel" class="btn btn-secondary">Abbrechen</button>
<button type="button" id="btn-account-submit" class="btn btn-primary">Auswahl speichern</button>
</div>
</div>
</div>

View file

@ -0,0 +1,128 @@
<!-- Lösch-Formular (außerhalb des Modals, damit keine verschachtelten Forms entstehen) -->
<form id="form-edit-delete" method="post" action="" style="display:none"></form>
<!-- Modal: Verbindung bearbeiten -->
<div id="modal-edit-connection"
style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div class="modal-inline-form" style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.25rem">
<h2 style="margin:0;font-size:1.125rem">Verbindung bearbeiten</h2>
<button type="button" id="btn-edit-close"
style="background:none;border:none;font-size:1.25rem;cursor:pointer;color:var(--color-muted)">&times;</button>
</div>
<form id="form-edit-connection" method="post" action="">
<div class="form-group">
<label>Bank / Anbieter</label>
<div id="edit-provider-name" style="padding:.4rem 0;font-weight:500"></div>
</div>
<div class="form-group">
<label for="edit-bezeichnung">Bezeichnung</label>
<input type="text" id="edit-bezeichnung" name="bezeichnung" required>
</div>
<div style="display:flex;gap:.75rem;align-items:flex-end">
<div class="form-group" style="flex:1;margin-bottom:0">
<label for="edit-kuerzel">Kürzel (Badge)</label>
<input type="text" id="edit-kuerzel" name="kuerzel" maxlength="10" placeholder="PP">
</div>
<div class="form-group" style="margin-bottom:0">
<label for="edit-farbe">Farbe</label>
<input type="color" id="edit-farbe" name="farbe" value="#6366f1" style="width:48px;height:36px;padding:2px;cursor:pointer">
</div>
</div>
<div id="edit-api-fields" style="display:none">
<div class="form-group">
<label for="edit-app-name">API-Benutzer</label>
<input type="text" id="edit-app-name" name="app_name" autocomplete="off">
</div>
<div class="form-group">
<label for="edit-client-id">API-Kennwort</label>
<input type="password" id="edit-client-id" name="client_id" autocomplete="new-password"
placeholder="Leer = unverändert behalten">
</div>
<div class="form-group">
<label for="edit-secret">Signature (Secret)</label>
<input type="password" id="edit-secret" name="secret" autocomplete="new-password"
placeholder="Leer = unverändert behalten">
</div>
<div class="form-group" style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" id="edit-sandbox" name="sandbox" value="1" style="width:auto;margin:0">
<label for="edit-sandbox" style="margin:0;font-weight:normal;cursor:pointer">Sandbox-Modus (Testumgebung)</label>
</div>
<div class="form-group">
<label for="edit-fetch-range-api" title="Gilt nur für den nächsten Abruf – danach wieder „Ab letztem Abruf".">Abrufen ab</label>
<select id="edit-fetch-range-api" name="fetch_range" disabled>
<?php foreach ($fetchRangeOptions as $val => $label): ?>
<option value="<?= $h($val) ?>"><?= $h($label) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div id="edit-fints-fields" style="display:none">
<div class="form-group">
<label for="edit-blz">Bankleitzahl (BLZ)</label>
<input type="text" id="edit-blz" name="blz" maxlength="11">
</div>
<div class="form-group">
<label for="edit-benutzerkennung">Benutzerkennung / Anmeldename</label>
<input type="text" id="edit-benutzerkennung" name="benutzerkennung" autocomplete="off">
</div>
<div class="form-group">
<label for="edit-pin">PIN</label>
<input type="password" id="edit-pin" name="pin" autocomplete="new-password"
placeholder="Leer = unverändert behalten">
</div>
<div class="form-group">
<label for="edit-fints-url" title="Optional">FinTS-Server-URL</label>
<input type="url" id="edit-fints-url" name="fints_url" placeholder="https://...">
</div>
<div class="form-group">
<label for="edit-tan-mode-id" title="Optional. Leer = erstes verfügbares Verfahren">TAN-Verfahren-ID</label>
<input type="number" id="edit-tan-mode-id" name="tan_mode_id" min="1" placeholder="z.B. 913 für chipTAN QR">
</div>
<div class="form-group">
<label for="edit-fetch-range-fints" title="Gilt nur für den nächsten Abruf – danach wieder „Ab letztem Abruf".">Abrufen ab</label>
<select id="edit-fetch-range-fints" name="fetch_range" disabled>
<?php foreach ($fetchRangeOptions as $val => $label): ?>
<option value="<?= $h($val) ?>"><?= $h($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="display:block">
<label style="margin-bottom:.3rem">Aktive Konten</label>
<div id="edit-account-list" style="display:flex;flex-direction:column;gap:.3rem;padding:.2rem 0"></div>
<div id="edit-account-empty" class="text-sm text-muted" style="display:none;padding:.4rem 0">
(wird beim nächsten Abruf abgefragt)
</div>
<label style="display:flex;align-items:center;gap:.5rem;font-weight:normal;cursor:pointer;margin-top:.4rem">
<input type="checkbox" id="edit-reset-accounts" name="reset_account_filter" value="1" style="width:auto;margin:0">
<span>Auswahl zurücksetzen (beim nächsten Abruf neu fragen)</span>
</label>
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:1rem">
<button type="button" id="btn-edit-delete"
style="background:none;border:none;cursor:pointer;color:var(--color-danger);padding:.2rem;line-height:1;display:inline-flex;align-items:center"
title="Verbindung löschen">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3,6 5,6 21,6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
<div style="display:flex;gap:.5rem">
<button type="button" id="btn-edit-cancel" class="btn btn-secondary">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,162 @@
<!-- Modal: Neue Verbindung -->
<div id="modal-new-connection"
style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div class="modal-inline-form" style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.25rem">
<h2 style="margin:0;font-size:1.125rem">Neue Direktverbindung</h2>
<button type="button" id="btn-modal-close"
style="background:none;border:none;font-size:1.25rem;cursor:pointer;color:var(--color-muted)">&times;</button>
</div>
<form method="post" action="/bank/direkt">
<div class="form-group">
<label for="fc-provider">Bank / Anbieter</label>
<select id="fc-provider" name="provider_id" required>
<option value="">Bitte wählen…</option>
<?php foreach ($providers as $p): ?>
<option value="<?= (int)$p['id'] ?>"
data-typ="<?= $h($p['verbindungstyp']) ?>">
<?= $h($p['name']) ?>
</option>
<?php endforeach; ?>
<option value="__new__" data-typ="FinTS">+ Neue Bank…</option>
<option value="__csv__" data-typ="CSV">+ CSV-Importformat…</option>
</select>
</div>
<div id="fc-new-bank-wrap" style="display:none">
<div class="form-group">
<label for="fc-new-bank-name">Name der neuen Bank</label>
<input type="text" id="fc-new-bank-name" name="new_bank_name"
placeholder="z. B. Sparkasse Musterstadt" maxlength="100">
</div>
<div class="form-group">
<label title="FinTS = klassischer Bank-Abruf via PIN. API = REST/NVP-Schnittstelle (wie PayPal).">Verbindungstyp</label>
<div style="display:flex;gap:1rem;padding:.25rem 0">
<label style="display:flex;align-items:center;gap:.4rem;font-weight:normal;cursor:pointer">
<input type="radio" name="new_bank_typ" value="FinTS" checked style="width:auto;margin:0">
<span>FinTS</span>
</label>
<label style="display:flex;align-items:center;gap:.4rem;font-weight:normal;cursor:pointer">
<input type="radio" name="new_bank_typ" value="API" style="width:auto;margin:0">
<span>API</span>
</label>
</div>
</div>
</div>
<div class="form-group">
<label for="fc-bezeichnung">Bezeichnung</label>
<input type="text" id="fc-bezeichnung" name="bezeichnung" required
placeholder="z. B. Geschäftskonto PayPal">
</div>
<div style="display:flex;gap:.75rem;align-items:flex-end">
<div class="form-group" style="flex:1;margin-bottom:0">
<label for="fc-kuerzel">Kürzel (Badge)</label>
<input type="text" id="fc-kuerzel" name="kuerzel" maxlength="10" placeholder="PP">
</div>
<div class="form-group" style="margin-bottom:0">
<label for="fc-farbe">Farbe</label>
<input type="color" id="fc-farbe" name="farbe" value="#6366f1" style="width:48px;height:36px;padding:2px;cursor:pointer">
</div>
</div>
<div id="api-fields" style="display:none">
<div class="form-group">
<label for="fc-app-name">API-Benutzer</label>
<input type="text" id="fc-app-name" name="app_name" autocomplete="off">
</div>
<div class="form-group">
<label for="fc-client-id">API-Kennwort</label>
<input type="password" id="fc-client-id" name="client_id" autocomplete="new-password">
</div>
<div class="form-group">
<label for="fc-secret">Signature (Secret)</label>
<input type="password" id="fc-secret" name="secret" autocomplete="new-password">
</div>
<div class="form-group" style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" id="fc-sandbox" name="sandbox" value="1" style="width:auto;margin:0">
<label for="fc-sandbox" style="margin:0;font-weight:normal;cursor:pointer">Sandbox-Modus (Testumgebung)</label>
</div>
</div>
<div id="fints-fields" style="display:none">
<div class="form-group">
<label for="fc-blz">Bankleitzahl (BLZ)</label>
<input type="text" id="fc-blz" name="blz" maxlength="11" placeholder="z. B. 12030000">
</div>
<div class="form-group">
<label for="fc-benutzerkennung">Benutzerkennung / Anmeldename</label>
<input type="text" id="fc-benutzerkennung" name="benutzerkennung" autocomplete="off">
</div>
<div class="form-group">
<label for="fc-pin">PIN</label>
<input type="password" id="fc-pin" name="pin" autocomplete="new-password">
</div>
<div class="form-group">
<label for="fc-fints-url" title="Optional, wird sonst über BLZ ermittelt">FinTS-Server-URL</label>
<input type="url" id="fc-fints-url" name="fints_url" placeholder="https://...">
<div style="margin-top:.4rem;flex-basis:100%">
<input type="text" id="fc-blz-search" placeholder="Bankleitzahl, BIC, Name oder Ort suchen…" style="font-size:.8rem">
<div id="fc-blz-results" style="display:none;margin-top:.3rem;max-height:160px;overflow-y:auto;border:1px solid var(--color-border);border-radius:var(--radius)"></div>
</div>
</div>
<div class="text-muted text-sm">Die Konto-Auswahl wird beim ersten Abruf abgefragt.</div>
</div>
<div id="csv-fields" style="display:none">
<div class="text-muted text-sm" style="margin-bottom:.75rem">
Grundeinstellungen für ein neues CSV-Importformat. Die Spaltenzuordnung kann danach unter
<a href="/settings/bankimport">Einstellungen → CSV Bankimport Formate</a> verfeinert werden.
</div>
<div style="display:flex;gap:.75rem">
<div class="form-group" style="flex:1">
<label for="fc-csv-delimiter">Trennzeichen</label>
<input type="text" id="fc-csv-delimiter" name="csv_delimiter" value=";" maxlength="1">
</div>
<div class="form-group" style="flex:1">
<label for="fc-csv-enclosure">Anführungszeichen</label>
<input type="text" id="fc-csv-enclosure" name="csv_enclosure" value='"' maxlength="1">
</div>
</div>
<div class="form-group">
<label for="fc-csv-date-format">Datumsformat</label>
<select id="fc-csv-date-format" name="csv_date_format">
<?php foreach ($csvDateFormatOptions as $val => $label): ?>
<option value="<?= $h($val) ?>"><?= $h($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="display:flex;gap:.75rem">
<div class="form-group" style="flex:1">
<label for="fc-csv-decimal">Dezimaltrennzeichen</label>
<input type="text" id="fc-csv-decimal" name="csv_decimal_separator" value="," maxlength="1">
</div>
<div class="form-group" style="flex:1">
<label for="fc-csv-thousands">Tausendertrennzeichen</label>
<input type="text" id="fc-csv-thousands" name="csv_thousands_separator" value="." maxlength="1">
</div>
</div>
<div style="display:flex;gap:.75rem">
<div class="form-group" style="flex:1">
<label for="fc-csv-header">Headerzeile vorhanden</label>
<select id="fc-csv-header" name="csv_has_header_row">
<option value="1" selected>Ja</option>
<option value="0">Nein</option>
</select>
</div>
<div class="form-group" style="flex:1">
<label for="fc-csv-skip">Übersprungene Zeilen</label>
<input type="number" id="fc-csv-skip" name="csv_skip_rows" value="0" min="0" max="99">
</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem">
<button type="button" id="btn-modal-cancel" class="btn btn-secondary">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,17 @@
<!-- Modal: Abruf-Log -->
<div id="modal-run-log"
style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:680px;padding:1.5rem;display:flex;flex-direction:column;gap:1rem">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2 style="margin:0;font-size:1.125rem" id="run-log-title">Abruf läuft…</h2>
<span id="run-log-spinner" style="font-size:.8rem;color:var(--color-muted)">⏳ wird abgerufen…</span>
</div>
<div id="run-log-area"
style="background:#0f172a;color:#e2e8f0;font-family:monospace;font-size:.8rem;
padding:1rem;border-radius:var(--radius);height:340px;overflow-y:auto;white-space:pre-wrap;line-height:1.5">
</div>
<div style="display:flex;justify-content:flex-end">
<button type="button" id="btn-run-close" class="btn btn-primary" disabled>Schließen</button>
</div>
</div>
</div>

View file

@ -0,0 +1,21 @@
<!-- Modal: TAN-Eingabe (photoTAN/chipTAN) -->
<div id="modal-tan-input"
style="display:none;position:fixed;inset:0;z-index:1100;background:rgba(0,0,0,.4);align-items:center;justify-content:center">
<div class="modal-inline-form" style="background:#fff;border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.15);width:100%;max-width:480px;padding:1.5rem;display:flex;flex-direction:column;gap:.75rem">
<h2 style="margin:0;font-size:1.125rem">TAN eingeben</h2>
<div id="tan-context" class="text-muted text-sm"></div>
<div id="tan-challenge" class="text-sm" style="white-space:pre-wrap"></div>
<div id="tan-image-wrap" style="text-align:center"></div>
<div id="tan-medium" class="text-muted text-sm"></div>
<div class="form-group" style="margin:0">
<label for="tan-input">TAN</label>
<input type="text" id="tan-input" autocomplete="off" inputmode="numeric"
style="font-size:1.25rem;letter-spacing:.25em;text-align:center">
</div>
<div id="tan-error" class="text-sm" style="color:#dc2626;display:none"></div>
<div style="display:flex;justify-content:flex-end;gap:.5rem">
<button type="button" id="btn-tan-cancel" class="btn btn-secondary">Abbrechen</button>
<button type="button" id="btn-tan-submit" class="btn btn-primary">TAN absenden</button>
</div>
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,181 @@
<?php
$fmtId = (int)$fmt['id'];
$hdrs = $knownHeaders[$fmt['name']] ?? [];
$mappingRowsBefore = [
['buchungstag', $fmt['col_buchungstag'], 'int'],
['wertstellung', $fmt['col_wertstellung'], 'int'],
['vorgang', $fmt['col_vorgang'], 'int'],
];
$mappingRowsAfter = [
['wert', $fmt['col_wert'], 'int'],
];
?>
<div class="card mb-2" id="format-<?= $fmtId ?>" data-format-id="<?= $fmtId ?>">
<div class="card-header" style="display:flex;align-items:center;gap:.75rem">
<div class="card-title" style="flex:1">
<span class="bi-cell" data-field="name" data-type="text" data-value="<?= $h($fmt['name']) ?>"><?= $h($fmt['name']) ?></span>
</div>
<button type="button"
class="bi-delete-format"
data-format-id="<?= $fmtId ?>"
data-format-name="<?= $h($fmt['name']) ?>"
style="background:none;border:none;cursor:pointer;color:var(--color-danger);padding:.2rem;line-height:1;display:inline-flex;align-items:center"
title="Format löschen">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3,6 5,6 21,6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
</div>
<div class="card-body">
<div class="bi-section-label">Format-Konfiguration</div>
<table class="bi-table" style="margin-bottom:1.5rem">
<tbody>
<tr>
<td class="bi-label">Anzeige-Kürzel</td>
<td>
<?php $farbe = $fmt['farbe'] ?? '#6366f1'; ?>
<div style="display:inline-flex;align-items:center;gap:.5rem">
<span class="kuerzel-badge bi-kuerzel-preview"
style="background:<?= $h($farbe) ?>;color:<?= $kTextClr($farbe) ?>"><?= $h($fmt['kuerzel'] ?? '') ?></span>
<span class="bi-cell" data-field="kuerzel" data-type="text"
data-value="<?= $h($fmt['kuerzel'] ?? '') ?>"><?= $h($fmt['kuerzel'] ?? '') ?></span>
<input type="color" class="bi-color" data-field="farbe"
value="<?= $h($farbe) ?>"
title="Tag-Farbe anpassen"
style="width:28px;height:28px;padding:2px;border:1px solid var(--color-border);border-radius:4px;cursor:pointer;background:none;vertical-align:middle">
</div>
</td>
</tr>
<tr>
<td class="bi-label">Übersprungene Zeilen</td>
<td><span class="bi-cell" data-field="skip_rows" data-type="number" data-min="0" data-max="99" data-value="<?= (int)$fmt['skip_rows'] ?>"><?= (int)$fmt['skip_rows'] ?></span></td>
</tr>
<tr>
<td class="bi-label">Headerzeile vorhanden</td>
<td>
<label class="bi-checkbox-label">
<input type="checkbox" class="bi-checkbox" data-field="has_header_row"
<?= $fmt['has_header_row'] ? 'checked' : '' ?>>
<span class="bi-checkbox-text"><?= $fmt['has_header_row'] ? 'Ja' : 'Nein' ?></span>
</label>
</td>
</tr>
<tr>
<td class="bi-label">Trennzeichen</td>
<td><span class="bi-cell" data-field="delimiter" data-type="char" data-value="<?= $h($fmt['delimiter']) ?>"><code><?= $h($fmt['delimiter']) ?></code></span></td>
</tr>
<tr>
<td class="bi-label">Anführungszeichen</td>
<td><span class="bi-cell" data-field="enclosure" data-type="char" data-value="<?= $h($fmt['enclosure']) ?>"><code><?= $h($fmt['enclosure']) ?></code></span></td>
</tr>
<tr>
<td class="bi-label">Datumsformat</td>
<td>
<span class="bi-cell" data-field="date_format" data-type="dateformat" data-value="<?= $h($fmt['date_format']) ?>">
<code><?= $h($fmt['date_format']) ?></code>
<span style="color:var(--color-muted);margin-left:.4rem">→ <?= $h($dateFormatOptions[$fmt['date_format']] ?? '?') ?></span>
</span>
</td>
</tr>
<tr>
<td class="bi-label">Dezimaltrennzeichen</td>
<td><span class="bi-cell" data-field="decimal_separator" data-type="char" data-value="<?= $h($fmt['decimal_separator']) ?>"><code><?= $h($fmt['decimal_separator']) ?></code></span></td>
</tr>
<tr>
<td class="bi-label">Tausendertrennzeichen</td>
<td><span class="bi-cell" data-field="thousands_separator" data-type="char0" data-value="<?= $h($fmt['thousands_separator']) ?>">
<?php if ($fmt['thousands_separator'] !== ''): ?>
<code><?= $h($fmt['thousands_separator']) ?></code>
<?php else: ?>
<span style="color:var(--color-muted);font-style:italic">keines</span>
<?php endif; ?>
</span></td>
</tr>
</tbody>
</table>
<div class="bi-section-label">Feld-Zuordnungen</div>
<table class="bi-table">
<thead>
<tr>
<th class="bi-label">Datenbankfeld</th>
<th style="width:100px">Spalte (0-basiert)</th>
<th style="color:var(--color-muted);font-weight:500">CSV-Überschrift</th>
</tr>
</thead>
<tbody>
<?php
$renderMappingRow = function (string $field, mixed $colIdx, string $colType) use ($h, $hdrs, $dbLabels): void {
$colVal = $colIdx !== null ? (int)$colIdx : null;
?>
<tr>
<td class="bi-label"><?= $h($dbLabels[$field] ?? $field) ?></td>
<td>
<span class="bi-cell"
data-field="col_<?= $h($field) ?>"
data-type="<?= $colType === 'nullint' ? 'nullint' : 'number' ?>"
data-min="0" data-max="99"
data-value="<?= $colVal !== null ? $colVal : '' ?>">
<?php if ($colVal !== null): ?>
<code><?= $colVal ?></code>
<?php else: ?>
<span style="color:var(--color-muted);font-style:italic">–</span>
<?php endif; ?>
</span>
</td>
<td style="color:var(--color-muted);font-size:.875rem">
<?php if ($colVal !== null && isset($hdrs[$colVal])): ?>
<?= $h($hdrs[$colVal]) ?>
<?php else: ?>
<span style="font-style:italic">–</span>
<?php endif; ?>
</td>
</tr>
<?php }; ?>
<?php foreach ($mappingRowsBefore as [$field, $colIdx, $colType]): ?>
<?php $renderMappingRow($field, $colIdx, $colType); ?>
<?php endforeach; ?>
<?php
// Buchungstext-Template-Zeilen (immer)
foreach ([
['buchungstext_template_debit', 'Buchungstext <small style="font-weight:400">(Betrag &lt; 0)</small>', $fmt['buchungstext_template_debit'] ?? ''],
['buchungstext_template_credit', 'Buchungstext <small style="font-weight:400">(Betrag ≥ 0)</small>', $fmt['buchungstext_template_credit'] ?? ''],
] as [$tplField, $tplLabel, $tplValue]):
?>
<tr>
<td class="bi-label"><?= $tplLabel ?></td>
<td colspan="2" style="padding-right:0">
<span class="bi-cell bi-cell--wide" data-field="<?= $h($tplField) ?>" data-type="template"
data-value="<?= $h($tplValue) ?>">
<?php if ($tplValue !== ''): ?>
<code style="word-break:break-all"><?= $h($tplValue) ?></code>
<?php else: ?>
<span style="color:var(--color-muted);font-style:italic">nicht gesetzt</span>
<?php endif; ?>
</span>
</td>
</tr>
<?php endforeach; ?>
<?php foreach ($mappingRowsAfter as [$field, $colIdx, $colType]): ?>
<?php $renderMappingRow($field, $colIdx, $colType); ?>
<?php endforeach; ?>
<tr>
<td colspan="3" style="padding:.4rem 0 0;font-size:.78rem;color:var(--color-muted)">
Platzhalter <code>{CSV-Überschrift}</code> werden durch den Wert der jeweiligen Spalte ersetzt.
</td>
</tr>
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,172 @@
<?php
$sugHeader = $suggestion['header'] ?? [];
$sugMapping = $suggestion['mapping'] ?? [];
$allMappingFields = [
'buchungstag' => 'Buchungstag',
'wertstellung' => 'Wertstellung',
'vorgang' => 'Vorgang / Umsatztyp',
'wert' => 'Betrag',
'zahlungspflichtiger' => 'Zahlungspflichtiger',
'zahlungsempfaenger' => 'Zahlungsempfänger',
'verwendungszweck' => 'Verwendungszweck',
];
?>
<div class="card mb-2" id="suggestion-card" style="border:2px solid #6366f1">
<div class="card-header" style="background:#eef2ff;display:flex;align-items:center;gap:.75rem">
<div class="card-title" style="flex:1">Neues Format (automatischer Vorschlag)</div>
<span style="font-size:.75rem;font-weight:600;padding:.2rem .6rem;border-radius:9999px;background:#818cf8;color:#fff">Nicht gespeichert</span>
</div>
<div class="card-body">
<form method="post" action="/settings/bankimport/create">
<p style="font-size:.8rem;color:var(--color-muted);margin:0 0 1.25rem">
Das Programm hat die Headerzeile in der CSV gefunden und Spalten automatisch zugeordnet.
Bitte prüfe die Einstellungen und klicke auf <strong>Speichern</strong>, um das neue Format anzulegen.
</p>
<div class="bi-section-label">Format-Konfiguration</div>
<table class="bi-table" style="margin-bottom:1.5rem">
<tbody>
<tr>
<td class="bi-label">Name</td>
<td><input type="text" name="name" value="Neues Format" required maxlength="100" style="width:220px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
</tr>
<tr>
<td class="bi-label">Anzeige-Kürzel</td>
<td><input type="text" name="kuerzel" value="NEU" required maxlength="10" style="width:80px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
</tr>
<tr>
<td class="bi-label">Übersprungene Zeilen</td>
<td><input type="number" name="skip_rows" value="<?= (int)($suggestion['skip_rows'] ?? 0) ?>" min="0" max="99" style="width:70px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
</tr>
<tr>
<td class="bi-label">Headerzeile vorhanden</td>
<td>
<select name="has_header_row" style="padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)">
<option value="1" <?= ($suggestion['has_header_row'] ?? 1) ? 'selected' : '' ?>>Ja</option>
<option value="0" <?= !($suggestion['has_header_row'] ?? 1) ? 'selected' : '' ?>>Nein</option>
</select>
</td>
</tr>
<tr>
<td class="bi-label">Trennzeichen</td>
<td><input type="text" name="delimiter" value="<?= $h($suggestion['delimiter'] ?? ';') ?>" maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
<tr>
<td class="bi-label">Anführungszeichen</td>
<td><input type="text" name="enclosure" value="<?= $h($suggestion['enclosure'] ?? '"') ?>" maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
<tr>
<td class="bi-label">Datumsformat</td>
<td>
<select name="date_format" style="padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)">
<?php foreach ($dateFormatOptions as $dfFormat => $dfExample): ?>
<option value="<?= $h($dfFormat) ?>"><?= $h($dfFormat) ?> → <?= $h($dfExample) ?></option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<td class="bi-label">Dezimaltrennzeichen</td>
<td><input type="text" name="decimal_separator" value="," maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
<tr>
<td class="bi-label">Tausendertrennzeichen</td>
<td><input type="text" name="thousands_separator" value="." maxlength="1" placeholder="(leer)" style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
</tbody>
</table>
<?php
$sugFirstData = $suggestion['first_data'] ?? [];
if (!empty($sugHeader) || !empty($sugFirstData)):
?>
<div class="bi-section-label">Erkannte Headerzeile</div>
<div style="font-size:.8rem;font-family:monospace;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border);overflow-x:auto;white-space:nowrap;margin-bottom:.5rem">
<?php foreach ($sugHeader as $i => $col): ?>
<span title="Spalte <?= $i ?>"><span style="opacity:.45"><?= $i ?>:</span><?= $h($col) ?></span><?php if ($i < count($sugHeader) - 1): ?> <span style="color:var(--color-border)">|</span> <?php endif; ?>
<?php endforeach; ?>
</div>
<?php if (!empty($sugFirstData)): ?>
<div style="font-size:.75rem;color:var(--color-muted);margin-bottom:.3rem">Erste Datenzeile</div>
<div style="font-size:.8rem;font-family:monospace;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border);overflow-x:auto;white-space:nowrap;margin-bottom:1.5rem">
<?php foreach ($sugFirstData as $i => $val): ?>
<span title="Spalte <?= $i ?><?= isset($sugHeader[$i]) ? ': ' . htmlspecialchars($sugHeader[$i]) : '' ?>"><span style="opacity:.45"><?= $i ?>:</span><?= $h($val) ?></span><?php if ($i < count($sugFirstData) - 1): ?> <span style="color:var(--color-border)">|</span> <?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<div class="bi-section-label">Feld-Zuordnungen</div>
<table class="bi-table" style="margin-bottom:1.5rem">
<thead>
<tr>
<th class="bi-label">Datenbankfeld</th>
<th style="width:100px">Spalte (0-basiert)</th>
<th style="color:var(--color-muted);font-weight:500">Erkannte CSV-Überschrift</th>
</tr>
</thead>
<tbody>
<?php foreach ($allMappingFields as $field => $label):
$colKey = 'col_' . $field;
$colIdx = $sugMapping[$colKey] ?? null;
$headerVal = ($colIdx !== null && isset($sugHeader[$colIdx])) ? $sugHeader[$colIdx] : '';
$isNullInt = in_array($field, ['zahlungspflichtiger', 'zahlungsempfaenger', 'verwendungszweck'], true);
?>
<tr>
<td class="bi-label"><?= $h($label) ?></td>
<td>
<input type="number" name="col_<?= $h($field) ?>"
value="<?= $colIdx !== null ? $colIdx : '' ?>"
min="0" max="99"
<?= $isNullInt ? '' : 'required' ?>
style="width:65px;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace">
</td>
<td style="color:var(--color-muted);font-size:.875rem">
<?php if ($headerVal !== ''): ?>
<?= $h($headerVal) ?>
<?php if ($colIdx !== null): ?>
<span style="opacity:.5;font-size:.75rem"> (Sp.<?= $colIdx ?>)</span>
<?php endif; ?>
<?php else: ?>
<span style="font-style:italic">–</span>
<?php endif; ?>
</td>
</tr>
<?php if ($field === 'vorgang'): ?>
<tr>
<td class="bi-label">Buchungstext <small style="font-weight:400">(Betrag &lt; 0)</small></td>
<td colspan="2" style="padding-right:0">
<input type="text" name="buchungstext_template_debit" value=""
maxlength="500" placeholder="z.B. {Zahlungsempfänger} · {Verwendungszweck}"
style="width:100%;box-sizing:border-box;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius)">
</td>
</tr>
<tr>
<td class="bi-label">Buchungstext <small style="font-weight:400">(Betrag &gt; 0)</small></td>
<td colspan="2" style="padding-right:0">
<input type="text" name="buchungstext_template_credit" value=""
maxlength="500" placeholder="z.B. {Zahlungspflichtiger} · {Verwendungszweck}"
style="width:100%;box-sizing:border-box;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius)">
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
<tr>
<td colspan="3" style="padding:.4rem 0 0;font-size:.78rem;color:var(--color-muted)">
Platzhalter <code>{CSV-Überschrift}</code> werden durch den Wert der jeweiligen Spalte ersetzt.
Optional: Zahlungspflichtiger, Zahlungsempfänger, Verwendungszweck (für Templates).
</td>
</tr>
</tbody>
</table>
<div style="display:flex;gap:.75rem">
<button type="submit" class="btn btn-primary">Speichern</button>
<button type="button" id="suggestion-dismiss" class="btn btn-secondary">Verwerfen</button>
</div>
</form>
</div>
</div>

View file

@ -29,6 +29,8 @@ $dateFormatOptions = [
'Y.m.d' => '2026.05.15', 'Y.m.d' => '2026.05.15',
'd-m-Y' => '15-05-2026', 'd-m-Y' => '15-05-2026',
]; ];
$av = defined('ASSET_VERSION') ? ASSET_VERSION : 'dev';
?> ?>
<?php if (!empty($errors ?? [])): ?> <?php if (!empty($errors ?? [])): ?>
@ -40,178 +42,7 @@ $dateFormatOptions = [
<?php endif; ?> <?php endif; ?>
<?php if (!empty($suggestion)): ?> <?php if (!empty($suggestion)): ?>
<?php <?php include __DIR__ . '/_suggestion_card.php'; ?>
$sugHeader = $suggestion['header'] ?? [];
$sugMapping = $suggestion['mapping'] ?? [];
$allMappingFields = [
'buchungstag' => 'Buchungstag',
'wertstellung' => 'Wertstellung',
'vorgang' => 'Vorgang / Umsatztyp',
'wert' => 'Betrag',
'zahlungspflichtiger' => 'Zahlungspflichtiger',
'zahlungsempfaenger' => 'Zahlungsempfänger',
'verwendungszweck' => 'Verwendungszweck',
];
?>
<div class="card mb-2" id="suggestion-card" style="border:2px solid #6366f1">
<div class="card-header" style="background:#eef2ff;display:flex;align-items:center;gap:.75rem">
<div class="card-title" style="flex:1">Neues Format (automatischer Vorschlag)</div>
<span style="font-size:.75rem;font-weight:600;padding:.2rem .6rem;border-radius:9999px;background:#818cf8;color:#fff">Nicht gespeichert</span>
</div>
<div class="card-body">
<form method="post" action="/settings/bankimport/create">
<p style="font-size:.8rem;color:var(--color-muted);margin:0 0 1.25rem">
Das Programm hat die Headerzeile in der CSV gefunden und Spalten automatisch zugeordnet.
Bitte prüfe die Einstellungen und klicke auf <strong>Speichern</strong>, um das neue Format anzulegen.
</p>
<div class="bi-section-label">Format-Konfiguration</div>
<table class="bi-table" style="margin-bottom:1.5rem">
<tbody>
<tr>
<td class="bi-label">Name</td>
<td><input type="text" name="name" value="Neues Format" required maxlength="100" style="width:220px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
</tr>
<tr>
<td class="bi-label">Anzeige-Kürzel</td>
<td><input type="text" name="kuerzel" value="NEU" required maxlength="10" style="width:80px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
</tr>
<tr>
<td class="bi-label">Übersprungene Zeilen</td>
<td><input type="number" name="skip_rows" value="<?= (int)($suggestion['skip_rows'] ?? 0) ?>" min="0" max="99" style="width:70px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
</tr>
<tr>
<td class="bi-label">Headerzeile vorhanden</td>
<td>
<select name="has_header_row" style="padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)">
<option value="1" <?= ($suggestion['has_header_row'] ?? 1) ? 'selected' : '' ?>>Ja</option>
<option value="0" <?= !($suggestion['has_header_row'] ?? 1) ? 'selected' : '' ?>>Nein</option>
</select>
</td>
</tr>
<tr>
<td class="bi-label">Trennzeichen</td>
<td><input type="text" name="delimiter" value="<?= $h($suggestion['delimiter'] ?? ';') ?>" maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
<tr>
<td class="bi-label">Anführungszeichen</td>
<td><input type="text" name="enclosure" value="<?= $h($suggestion['enclosure'] ?? '"') ?>" maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
<tr>
<td class="bi-label">Datumsformat</td>
<td>
<select name="date_format" style="padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)">
<?php foreach ($dateFormatOptions as $fmt => $example): ?>
<option value="<?= $h($fmt) ?>"><?= $h($fmt) ?> → <?= $h($example) ?></option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<td class="bi-label">Dezimaltrennzeichen</td>
<td><input type="text" name="decimal_separator" value="," maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
<tr>
<td class="bi-label">Tausendertrennzeichen</td>
<td><input type="text" name="thousands_separator" value="." maxlength="1" placeholder="(leer)" style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
</tbody>
</table>
<?php
$sugFirstData = $suggestion['first_data'] ?? [];
if (!empty($sugHeader) || !empty($sugFirstData)):
?>
<div class="bi-section-label">Erkannte Headerzeile</div>
<div style="font-size:.8rem;font-family:monospace;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border);overflow-x:auto;white-space:nowrap;margin-bottom:.5rem">
<?php foreach ($sugHeader as $i => $col): ?>
<span title="Spalte <?= $i ?>"><span style="opacity:.45"><?= $i ?>:</span><?= $h($col) ?></span><?php if ($i < count($sugHeader) - 1): ?> <span style="color:var(--color-border)">|</span> <?php endif; ?>
<?php endforeach; ?>
</div>
<?php if (!empty($sugFirstData)): ?>
<div style="font-size:.75rem;color:var(--color-muted);margin-bottom:.3rem">Erste Datenzeile</div>
<div style="font-size:.8rem;font-family:monospace;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border);overflow-x:auto;white-space:nowrap;margin-bottom:1.5rem">
<?php foreach ($sugFirstData as $i => $val): ?>
<span title="Spalte <?= $i ?><?= isset($sugHeader[$i]) ? ': ' . htmlspecialchars($sugHeader[$i]) : '' ?>"><span style="opacity:.45"><?= $i ?>:</span><?= $h($val) ?></span><?php if ($i < count($sugFirstData) - 1): ?> <span style="color:var(--color-border)">|</span> <?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<div class="bi-section-label">Feld-Zuordnungen</div>
<table class="bi-table" style="margin-bottom:1.5rem">
<thead>
<tr>
<th class="bi-label">Datenbankfeld</th>
<th style="width:100px">Spalte (0-basiert)</th>
<th style="color:var(--color-muted);font-weight:500">Erkannte CSV-Überschrift</th>
</tr>
</thead>
<tbody>
<?php foreach ($allMappingFields as $field => $label):
$colKey = 'col_' . $field;
$colIdx = $sugMapping[$colKey] ?? null;
$headerVal = ($colIdx !== null && isset($sugHeader[$colIdx])) ? $sugHeader[$colIdx] : '';
$isNullInt = in_array($field, ['zahlungspflichtiger', 'zahlungsempfaenger', 'verwendungszweck'], true);
?>
<tr>
<td class="bi-label"><?= $h($label) ?></td>
<td>
<input type="number" name="col_<?= $h($field) ?>"
value="<?= $colIdx !== null ? $colIdx : '' ?>"
min="0" max="99"
<?= $isNullInt ? '' : 'required' ?>
style="width:65px;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace">
</td>
<td style="color:var(--color-muted);font-size:.875rem">
<?php if ($headerVal !== ''): ?>
<?= $h($headerVal) ?>
<?php if ($colIdx !== null): ?>
<span style="opacity:.5;font-size:.75rem"> (Sp.<?= $colIdx ?>)</span>
<?php endif; ?>
<?php else: ?>
<span style="font-style:italic">–</span>
<?php endif; ?>
</td>
</tr>
<?php if ($field === 'vorgang'): ?>
<tr>
<td class="bi-label">Buchungstext <small style="font-weight:400">(Betrag &lt; 0)</small></td>
<td colspan="2" style="padding-right:0">
<input type="text" name="buchungstext_template_debit" value=""
maxlength="500" placeholder="z.B. {Zahlungsempfänger} · {Verwendungszweck}"
style="width:100%;box-sizing:border-box;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius)">
</td>
</tr>
<tr>
<td class="bi-label">Buchungstext <small style="font-weight:400">(Betrag &gt; 0)</small></td>
<td colspan="2" style="padding-right:0">
<input type="text" name="buchungstext_template_credit" value=""
maxlength="500" placeholder="z.B. {Zahlungspflichtiger} · {Verwendungszweck}"
style="width:100%;box-sizing:border-box;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius)">
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
<tr>
<td colspan="3" style="padding:.4rem 0 0;font-size:.78rem;color:var(--color-muted)">
Platzhalter <code>{CSV-Überschrift}</code> werden durch den Wert der jeweiligen Spalte ersetzt.
Optional: Zahlungspflichtiger, Zahlungsempfänger, Verwendungszweck (für Templates).
</td>
</tr>
</tbody>
</table>
<div style="display:flex;gap:.75rem">
<button type="submit" class="btn btn-primary">Speichern</button>
<button type="button" id="suggestion-dismiss" class="btn btn-secondary">Verwerfen</button>
</div>
</form>
</div>
</div>
<?php endif; ?> <?php endif; ?>
<?php if (empty($formats)): ?> <?php if (empty($formats)): ?>
@ -225,187 +56,7 @@ $allMappingFields = [
</p> </p>
<?php foreach ($formats as $fmt): ?> <?php foreach ($formats as $fmt): ?>
<?php <?php include __DIR__ . '/_format_card.php'; ?>
$fmtId = (int)$fmt['id'];
$hdrs = $knownHeaders[$fmt['name']] ?? [];
$mappingRowsBefore = [
['buchungstag', $fmt['col_buchungstag'], 'int'],
['wertstellung', $fmt['col_wertstellung'], 'int'],
['vorgang', $fmt['col_vorgang'], 'int'],
];
$mappingRowsAfter = [
['wert', $fmt['col_wert'], 'int'],
];
?>
<div class="card mb-2" id="format-<?= $fmtId ?>" data-format-id="<?= $fmtId ?>">
<div class="card-header" style="display:flex;align-items:center;gap:.75rem">
<div class="card-title" style="flex:1">
<span class="bi-cell" data-field="name" data-type="text" data-value="<?= $h($fmt['name']) ?>"><?= $h($fmt['name']) ?></span>
</div>
<button type="button"
class="bi-delete-format"
data-format-id="<?= $fmtId ?>"
data-format-name="<?= $h($fmt['name']) ?>"
style="background:none;border:none;cursor:pointer;color:var(--color-danger);padding:.2rem;line-height:1;display:inline-flex;align-items:center"
title="Format löschen">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3,6 5,6 21,6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
</div>
<div class="card-body">
<div class="bi-section-label">Format-Konfiguration</div>
<table class="bi-table" style="margin-bottom:1.5rem">
<tbody>
<tr>
<td class="bi-label">Anzeige-Kürzel</td>
<td>
<?php $farbe = $fmt['farbe'] ?? '#6366f1'; ?>
<div style="display:inline-flex;align-items:center;gap:.5rem">
<span class="kuerzel-badge bi-kuerzel-preview"
style="background:<?= $h($farbe) ?>;color:<?= $kTextClr($farbe) ?>"><?= $h($fmt['kuerzel'] ?? '') ?></span>
<span class="bi-cell" data-field="kuerzel" data-type="text"
data-value="<?= $h($fmt['kuerzel'] ?? '') ?>"><?= $h($fmt['kuerzel'] ?? '') ?></span>
<input type="color" class="bi-color" data-field="farbe"
value="<?= $h($farbe) ?>"
title="Tag-Farbe anpassen"
style="width:28px;height:28px;padding:2px;border:1px solid var(--color-border);border-radius:4px;cursor:pointer;background:none;vertical-align:middle">
</div>
</td>
</tr>
<tr>
<td class="bi-label">Übersprungene Zeilen</td>
<td><span class="bi-cell" data-field="skip_rows" data-type="number" data-min="0" data-max="99" data-value="<?= (int)$fmt['skip_rows'] ?>"><?= (int)$fmt['skip_rows'] ?></span></td>
</tr>
<tr>
<td class="bi-label">Headerzeile vorhanden</td>
<td>
<label class="bi-checkbox-label">
<input type="checkbox" class="bi-checkbox" data-field="has_header_row"
<?= $fmt['has_header_row'] ? 'checked' : '' ?>>
<span class="bi-checkbox-text"><?= $fmt['has_header_row'] ? 'Ja' : 'Nein' ?></span>
</label>
</td>
</tr>
<tr>
<td class="bi-label">Trennzeichen</td>
<td><span class="bi-cell" data-field="delimiter" data-type="char" data-value="<?= $h($fmt['delimiter']) ?>"><code><?= $h($fmt['delimiter']) ?></code></span></td>
</tr>
<tr>
<td class="bi-label">Anführungszeichen</td>
<td><span class="bi-cell" data-field="enclosure" data-type="char" data-value="<?= $h($fmt['enclosure']) ?>"><code><?= $h($fmt['enclosure']) ?></code></span></td>
</tr>
<tr>
<td class="bi-label">Datumsformat</td>
<td>
<span class="bi-cell" data-field="date_format" data-type="dateformat" data-value="<?= $h($fmt['date_format']) ?>">
<code><?= $h($fmt['date_format']) ?></code>
<span style="color:var(--color-muted);margin-left:.4rem">→ <?= $h($dateFormatOptions[$fmt['date_format']] ?? '?') ?></span>
</span>
</td>
</tr>
<tr>
<td class="bi-label">Dezimaltrennzeichen</td>
<td><span class="bi-cell" data-field="decimal_separator" data-type="char" data-value="<?= $h($fmt['decimal_separator']) ?>"><code><?= $h($fmt['decimal_separator']) ?></code></span></td>
</tr>
<tr>
<td class="bi-label">Tausendertrennzeichen</td>
<td><span class="bi-cell" data-field="thousands_separator" data-type="char0" data-value="<?= $h($fmt['thousands_separator']) ?>">
<?php if ($fmt['thousands_separator'] !== ''): ?>
<code><?= $h($fmt['thousands_separator']) ?></code>
<?php else: ?>
<span style="color:var(--color-muted);font-style:italic">keines</span>
<?php endif; ?>
</span></td>
</tr>
</tbody>
</table>
<div class="bi-section-label">Feld-Zuordnungen</div>
<table class="bi-table">
<thead>
<tr>
<th class="bi-label">Datenbankfeld</th>
<th style="width:100px">Spalte (0-basiert)</th>
<th style="color:var(--color-muted);font-weight:500">CSV-Überschrift</th>
</tr>
</thead>
<tbody>
<?php
$renderMappingRow = function (string $field, mixed $colIdx, string $colType) use ($h, $hdrs, $dbLabels): void {
$colVal = $colIdx !== null ? (int)$colIdx : null;
?>
<tr>
<td class="bi-label"><?= $h($dbLabels[$field] ?? $field) ?></td>
<td>
<span class="bi-cell"
data-field="col_<?= $h($field) ?>"
data-type="<?= $colType === 'nullint' ? 'nullint' : 'number' ?>"
data-min="0" data-max="99"
data-value="<?= $colVal !== null ? $colVal : '' ?>">
<?php if ($colVal !== null): ?>
<code><?= $colVal ?></code>
<?php else: ?>
<span style="color:var(--color-muted);font-style:italic">–</span>
<?php endif; ?>
</span>
</td>
<td style="color:var(--color-muted);font-size:.875rem">
<?php if ($colVal !== null && isset($hdrs[$colVal])): ?>
<?= $h($hdrs[$colVal]) ?>
<?php else: ?>
<span style="font-style:italic">–</span>
<?php endif; ?>
</td>
</tr>
<?php }; ?>
<?php foreach ($mappingRowsBefore as [$field, $colIdx, $colType]): ?>
<?php $renderMappingRow($field, $colIdx, $colType); ?>
<?php endforeach; ?>
<?php
// Buchungstext-Template-Zeilen (immer)
foreach ([
['buchungstext_template_debit', 'Buchungstext <small style="font-weight:400">(Betrag &lt; 0)</small>', $fmt['buchungstext_template_debit'] ?? ''],
['buchungstext_template_credit', 'Buchungstext <small style="font-weight:400">(Betrag ≥ 0)</small>', $fmt['buchungstext_template_credit'] ?? ''],
] as [$tplField, $tplLabel, $tplValue]):
?>
<tr>
<td class="bi-label"><?= $tplLabel ?></td>
<td colspan="2" style="padding-right:0">
<span class="bi-cell bi-cell--wide" data-field="<?= $h($tplField) ?>" data-type="template"
data-value="<?= $h($tplValue) ?>">
<?php if ($tplValue !== ''): ?>
<code style="word-break:break-all"><?= $h($tplValue) ?></code>
<?php else: ?>
<span style="color:var(--color-muted);font-style:italic">nicht gesetzt</span>
<?php endif; ?>
</span>
</td>
</tr>
<?php endforeach; ?>
<?php foreach ($mappingRowsAfter as [$field, $colIdx, $colType]): ?>
<?php $renderMappingRow($field, $colIdx, $colType); ?>
<?php endforeach; ?>
<tr>
<td colspan="3" style="padding:.4rem 0 0;font-size:.78rem;color:var(--color-muted)">
Platzhalter <code>{CSV-Überschrift}</code> werden durch den Wert der jeweiligen Spalte ersetzt.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
@ -501,296 +152,5 @@ $mappingRowsAfter = [
} }
</style> </style>
<script> <div id="bankimport-data" data-date-format-options="<?= $h(json_encode($dateFormatOptions)) ?>" style="display:none"></div>
(function () { <script src="/static/js/bankimport.js?v=<?= $av ?>"></script>
const DATE_FORMAT_OPTIONS = <?= json_encode($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 '<span style="color:var(--color-muted);font-style:italic">–</span>';
}
return `<code>${value}</code>`;
}
if (type === 'char' || type === 'char0') {
if (value === '') return '<span style="color:var(--color-muted);font-style:italic">keines</span>';
return `<code>${value.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</code>`;
}
if (type === 'dateformat') {
const example = DATE_FORMAT_OPTIONS[value] || '?';
return `<code>${value}</code><span style="color:var(--color-muted);margin-left:.4rem">→ ${example}</span>`;
}
if (type === 'template') {
if (!value) return '<span style="color:var(--color-muted);font-style:italic">nicht gesetzt</span>';
const esc = value.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return `<code style="word-break:break-all">${esc}</code>`;
}
if (type === 'text') {
return value.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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';
});
})();
</script>

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/**
* Test 93: Sauberkeit des Webroots (Fix #161a)
*
* public/O-CHAT.html war ein Testartefakt ohne Referenz aus Router, View oder
* Navigation, wurde von Apache aber direkt ausgeliefert und lief damit an der
* Authentifizierung vorbei. Dieser Test hält den Webroot dauerhaft sauber:
* außer index.php, .htaccess und dem static/-Verzeichnis darf dort nichts
* direkt ausliefer-fähiges liegen.
*/
class WebrootSauberkeitTest extends TestCase
{
protected string $testName = 'Test 93: Sauberkeit des Webroots';
public function run(): void
{
$publicDir = __DIR__ . '/../public';
$this->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'
);
}
}

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
use Steuerkram\Request;
/**
* Test 94: Request-Helferklasse (Fix #161d)
*
* 176 Controller-Stellen griffen bisher direkt und uneinheitlich auf
* $_POST/$_GET zu (mal `?? ''`, mal `isset()`, mal `(int)`, mal `trim()`).
* Request::post()/postInt()/postFloat()/postDate()/get()/has() bündeln die
* Typumwandlung an einer Stelle. Dieser Test deckt die Helfer isoliert ab,
* ohne einen echten HTTP-Request zu benötigen.
*/
class RequestHelperTest extends TestCase
{
protected string $testName = 'Test 94: Request-Helferklasse';
public function run(): void
{
$_POST = [];
$_GET = [];
// ── post() ───────────────────────────────────────────────
$this->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 = [];
}
}

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* Test 95: Struktur von bank/fints.php (Fix #161b)
*
* fints.php war mit 986 Zeilen (davon 517 JavaScript) die zweitgrößte View
* der Anwendung. Dieser Test hält die im Zuge der Aufteilung entstandene
* Struktur fest, analog zu Test 86 für belege/detail.php: fints.php ist nur
* noch ein Gerüst aus Includes und einem <script src>-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('/<script>/', $content) === 0,
'Keine inline <script>-Blöcke mehr in fints.php'
);
preg_match_all('/<script\b[^>]*>/', $content, $scriptTags);
$this->assertGreaterThan(0, count($scriptTags[0]), 'fints.php bindet mindestens eine externe JS-Datei ein');
foreach ($scriptTags[0] as $tag) {
$this->assert(
str_contains($tag, 'src='),
"Script-Tag lädt eine externe Datei statt Inline-JS: $tag"
);
}
preg_match_all('/include\s+__DIR__\s*\.\s*\'\/([^\']+)\'/', $content, $includes);
$this->assertGreaterThan(0, count($includes[1]), 'fints.php bindet Teil-Views per include ein');
foreach ($includes[1] as $partial) {
$this->assert(
is_file($viewDir . '/' . $partial),
"Teil-View $partial existiert in src/Views/bank/"
);
}
preg_match_all('/<script\s+src="\/static\/js\/([^"?]+)/', $content, $scripts);
$this->assertGreaterThan(0, count($scripts[1]), 'fints.php bindet JS-Dateien per <script src> ein');
foreach ($scripts[1] as $js) {
$this->assert(
is_file($jsDir . '/' . $js),
"JS-Datei $js existiert in public/static/js/"
);
}
foreach (['_connection_grid.php', '_modal_new_connection.php', '_modal_edit_connection.php',
'_modal_account_select.php', '_modal_tan_input.php', '_modal_run_log.php'] as $partial) {
$this->assert(is_file($viewDir . '/' . $partial), "Teil-View $partial existiert");
}
$this->assert(is_file($jsDir . '/fints.js'), 'JS-Datei fints.js existiert');
$jsContent = file_get_contents($jsDir . '/fints.js');
$this->assert(
$jsContent !== false && !str_contains($jsContent, '<?php') && !str_contains($jsContent, '<?='),
'fints.js enthält keine PHP-Interpolation'
);
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* Test 96: Struktur von settings/bankimport.php (Fix #161c)
*
* bankimport.php war mit 797 Zeilen (davon 293 JavaScript) die drittgrößte
* View der Anwendung. Dieser Test hält die im Zuge der Aufteilung
* entstandene Struktur fest, analog zu Test 86 (belege/detail.php) und
* Test 95 (bank/fints.php).
*/
class BankimportViewStrukturTest extends TestCase
{
protected string $testName = 'Test 96: Struktur von settings/bankimport.php';
public function run(): void
{
$viewDir = __DIR__ . '/../src/Views/settings';
$jsDir = __DIR__ . '/../public/static/js';
$viewFile = $viewDir . '/bankimport.php';
$content = file_get_contents($viewFile);
$this->assert($content !== false, 'settings/bankimport.php ist lesbar');
$lines = substr_count($content, "\n") + 1;
$this->assert(
$lines < 300,
"settings/bankimport.php bleibt unter 300 Zeilen (erhalten: $lines)"
);
$this->assert(
preg_match('/<script>/', $content) === 0,
'Keine inline <script>-Blöcke mehr in bankimport.php'
);
preg_match_all('/<script\b[^>]*>/', $content, $scriptTags);
$this->assertGreaterThan(0, count($scriptTags[0]), 'bankimport.php bindet mindestens eine externe JS-Datei ein');
foreach ($scriptTags[0] as $tag) {
$this->assert(
str_contains($tag, 'src='),
"Script-Tag lädt eine externe Datei statt Inline-JS: $tag"
);
}
preg_match_all('/include\s+__DIR__\s*\.\s*\'\/([^\']+)\'/', $content, $includes);
$this->assertGreaterThan(0, count($includes[1]), 'bankimport.php bindet Teil-Views per include ein');
foreach ($includes[1] as $partial) {
$this->assert(
is_file($viewDir . '/' . $partial),
"Teil-View $partial existiert in src/Views/settings/"
);
}
preg_match_all('/<script\s+src="\/static\/js\/([^"?]+)/', $content, $scripts);
$this->assertGreaterThan(0, count($scripts[1]), 'bankimport.php bindet JS-Dateien per <script src> ein');
foreach ($scripts[1] as $js) {
$this->assert(
is_file($jsDir . '/' . $js),
"JS-Datei $js existiert in public/static/js/"
);
}
foreach (['_suggestion_card.php', '_format_card.php'] as $partial) {
$this->assert(is_file($viewDir . '/' . $partial), "Teil-View $partial existiert");
}
$this->assert(is_file($jsDir . '/bankimport.js'), 'JS-Datei bankimport.js existiert');
$jsContent = file_get_contents($jsDir . '/bankimport.js');
$this->assert(
$jsContent !== false && !str_contains($jsContent, '<?php') && !str_contains($jsContent, '<?='),
'bankimport.js enthält keine PHP-Interpolation'
);
}
}