Steuerkram/public/static/js/fints.js
fds2610-bot 23efce3196 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.
2026-08-18 13:12:12 +02:00

568 lines
26 KiB
JavaScript

'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';
});
});
})();