905 lines
46 KiB
PHP
905 lines
46 KiB
PHP
<?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,
|
||
'fetch_range' => $c['fetch_range'] ?? 'last_fetched',
|
||
'tan_mode_id' => isset($c['tan_mode_id']) && $c['tan_mode_id'] !== null ? (int)$c['tan_mode_id'] : null,
|
||
], $connections));
|
||
|
||
$fetchRangeOptions = [
|
||
'last_fetched' => 'Ab letztem Abruf (00:00 Uhr)',
|
||
'last_week' => 'Letzte 7 Tage',
|
||
'current_month' => 'Aktueller Monat (ab 01.)',
|
||
'last_30_days' => 'Letzte 30 Tage',
|
||
'full' => 'Ohne Datum (letzte 365 Tage)',
|
||
];
|
||
?>
|
||
|
||
<?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>
|
||
<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)">×</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)">×</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">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 class="text-muted text-sm">
|
||
Gilt nur für den nächsten Abruf – danach wieder „Ab letztem Abruf".
|
||
</div>
|
||
</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 for="edit-tan-mode-id">TAN-Verfahren-ID <span class="text-muted text-sm">(optional)</span></label>
|
||
<input type="number" id="edit-tan-mode-id" name="tan_mode_id" min="1" placeholder="z.B. 913 für chipTAN QR">
|
||
<div class="text-muted text-sm">Leer = erstes verfügbares Verfahren</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="edit-fetch-range-fints">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 class="text-muted text-sm">
|
||
Gilt nur für den nächsten Abruf – danach wieder „Ab letztem Abruf".
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>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>
|
||
|
||
<!-- 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 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;
|
||
|
||
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';
|
||
|
||
// 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';
|
||
});
|
||
});
|
||
})();
|
||
</script>
|