Steuerkram/src/Views/bank/fints.php
fds2610-bot b42758eb6e Feature: Drag-and-Drop Reihenfolge der Direktverbindungs-Kacheln
- Migration 071 ergänzt bank_sources.sort_order
- Drag-Handle (⠿) oben links jeder Kachel
- Gezogene Kachel hängt am Mauszeiger, Ursprung mit lila Rahmen
- Blauer Strich im Gap markiert die Einfügeposition
- Reihenfolge wird per AJAX nach /bank/direkt/_order persistiert
2026-05-22 09:24:56 +02:00

810 lines
40 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use Steuerkram\View;
$h = fn($v) => View::h($v);
$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff';
// Verbindungsdaten als JSON für JS bereitstellen.
// PINs und API-Secrets werden bewusst NICHT mitgeschickt – im Formular blanko, leer = unverändert.
$connectionsJson = json_encode(array_map(fn($c) => [
'id' => (int)$c['id'],
'bezeichnung' => $c['bezeichnung'],
'type' => $c['type'],
'provider_id' => (int)$c['provider_id'],
'provider_name' => $c['provider_name'],
'verbindungstyp' => $c['verbindungstyp'],
'kuerzel' => $c['kuerzel'] ?? '',
'farbe' => $c['farbe'] ?? '#6366f1',
'app_name' => $c['app_name'] ?? '',
'sandbox' => (bool)($c['sandbox'] ?? false),
'blz' => $c['blz'] ?? '',
'fints_url' => $c['fints_url'] ?? '',
'benutzerkennung' => $c['benutzerkennung'] ?? '',
'account_filter' => $c['account_filter'] ?? null,
], $connections));
?>
<?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 auf <strong>NEU</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>
<div class="text-muted text-sm" style="margin-top:.6rem">
Abruf: <?= $conn['last_fetched_at'] ? $h(View::formatDate($conn['last_fetched_at'])) : '<kein>' ?>
</div>
</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; ?>
<!-- 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 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>
</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>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 class="text-muted text-sm">FinTS = klassischer Bank-Abruf via PIN. API = REST/NVP-Schnittstelle (wie PayPal).</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">FinTS-Server-URL <span class="text-muted text-sm">(optional, wird sonst über BLZ ermittelt)</span></label>
<input type="url" id="fc-fints-url" name="fints_url" placeholder="https://...">
</div>
<div class="text-muted text-sm">Die Konto-Auswahl wird beim ersten Abruf abgefragt.</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>
<!-- 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 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>
<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">FinTS-Server-URL <span class="text-muted text-sm">(optional)</span></label>
<input type="url" id="edit-fints-url" name="fints_url" placeholder="https://...">
</div>
<div class="form-group">
<label>Aktive Konten</label>
<div id="edit-account-list" class="text-sm" style="padding:.4rem 0;color:#475569">–</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" class="btn btn-danger btn-sm">Löschen</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>
<!-- 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>
<!-- 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 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>
<!-- 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>
<script>
(function () {
const connections = <?= $connectionsJson ?>;
// ── 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 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(); });
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__';
newBankWrap.style.display = isNew ? 'block' : 'none';
newBankInput.required = isNew;
if (isNew) {
const selected = modalNew.querySelector('input[name="new_bank_typ"]:checked');
syncTypFields(selected ? selected.value : 'FinTS');
} 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 editAccountList = document.getElementById('edit-account-list');
const editResetAcc = document.getElementById('edit-reset-accounts');
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;
function closeRun() {
if (activeEs) { activeEs.close(); activeEs = null; }
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;
modalRun.style.display = 'flex';
if (activeEs) activeEs.close();
activeEs = new EventSource('/bank/direkt/' + id + '/run');
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;
runTitle.textContent = ev.data === 'ok' ? 'Abruf abgeschlossen' : 'Abruf mit Fehlern beendet';
runSpinner.textContent = ev.data === 'ok' ? '✓ 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';
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 || '';
editResetAcc.checked = false;
let accountsText = '–';
if (conn.account_filter) {
try {
const arr = JSON.parse(conn.account_filter);
if (Array.isArray(arr) && arr.length > 0) {
accountsText = arr.join(', ');
}
} catch (e) { /* ignore */ }
} else {
accountsText = '(wird beim nächsten Abruf abgefragt)';
}
editAccountList.textContent = accountsText;
}
modalEdit.style.display = 'flex';
});
});
})();
</script>